Skip to content

extend_sqlglot() is not idempotent — a second call makes Parser._parse_types recurse infinitely (RecursionError) #5908

Description

@asanko-nettle

extend_sqlglot() (sqlmesh/core/dialect.py) patches sqlglot's global Tokenizer/Parser/Generator classes via _override:

def _override(klass: t.Type[Tokenizer | Parser], func: t.Callable) -> None:
    name = func.__name__
    setattr(klass, f"_{name}", getattr(klass, name))
    setattr(klass, name, func)

_override saves whatever is currently installed under _<name> — so applying it twice saves the wrapper itself as "the original". For _parse_types (saved as __parse_types, which the module-level wrapper reads back un-mangled at dialect.py:487), the second application makes the wrapper call itself: the next type/CAST parse anywhere in the process raises RecursionError, and sqlglot's original method is lost (overwritten), so the process cannot recover without re-importing sqlglot.

Minimal repro

import sqlglot
import sqlmesh                                  # 1st extend_sqlglot() — sqlmesh/__init__.py:19

from sqlmesh.core.dialect import extend_sqlglot
extend_sqlglot()                                # 2nd application

sqlglot.parse_one("SELECT CAST(1 AS INT)")      # RecursionError: maximum recursion depth exceeded

How this happens without anyone calling it twice on purpose

extend_sqlglot() is the first statement of sqlmesh/__init__.py (line 19), before the rest of the import cascade (analytics, context, model kinds, croniter, ...). If anything later in that first import sqlmesh raises, CPython evicts the half-initialised sqlmesh package from sys.modules — but the fully-imported sqlmesh.core.dialect (and the patched global Parser) survive. Any retry of import sqlmesh then re-runs extend_sqlglot() on the already-patched classes and arms the recursion.

We hit this in a real test suite two ways: a sandboxed/monkeypatched environment made croniter's import-time platform.architecture() subprocess probe raise mid-import; and a deep ambient call stack made a class-body type-parse overflow during the model-kind imports. Both left the parser patched, the package evicted, and the next import poisoned the whole process (RecursionError at dialect.py:487 on every subsequent parse).

Suggested fix

Make the patch idempotent — e.g. in _override:

def _override(klass: t.Type[Tokenizer | Parser], func: t.Callable) -> None:
    name = func.__name__
    if getattr(klass, name, None) is func:      # already installed — don't re-save
        return
    setattr(klass, f"_{name}", getattr(klass, name))
    setattr(klass, name, func)

or a module-level ran-once flag at the top of extend_sqlglot(). Note the function already has partial re-entry guards (the generator TRANSFORMS/WITH_SEPARATED_COMMENTS membership checks) — but not on the _override path, and generator.UNWRAPPED_INTERVAL_VALUES also grows on every call.

Environment

  • sqlmesh 0.236.0 (also reproduced by inspection on main @ 97d60cb), sqlglot 30.8.0, Python 3.13, macOS/Linux.

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions