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

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions