Skip to content

About

Turns SQLAlchemy's MissingGreenlet crash into a diagnostic that names the model, attribute, instance and call site

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

2 Commits

Folders and files

Repository files navigation

missinggreenlet

Turns SQLAlchemy's MissingGreenlet crash into a diagnostic that names the model, the attribute, the instance, and the line that triggered it.

import missinggreenlet

handle = missinggreenlet.install(session_factory, mode="explain")
# ... run your app ...
# an implicit lazy load in an async-unsafe context now raises:
#
# missinggreenlet.LazyLoadError: Implicit relationship lazy load: Author.books
#   instance: Author(pk=1)
#   triggered at: app/views.py:42, in get_author
#   fix: add selectinload(Author.books) (or joinedload(...)) to the original
#   query, or `await session.refresh(instance, ['books'])` before touching
#   it, or mix AsyncAttrs into Author and use
#   `await instance.awaitable_attrs.books`.

Why

In SQLAlchemy's asyncio ORM, touching an unloaded relationship, a deferred column, or an expired attribute outside an awaited call raises sqlalchemy.exc.MissingGreenlet ("greenlet_spawn has not been called; can't call await_() here"). The message doesn't say which model, which attribute, which row, or which line of your code touched it - only that some internal await_() call had no greenlet to run in. The traceback is dozens of frames of asyncio/greenlet/SQLAlchemy internals with no application code in it at all.

lazy="raise" / raiseload("*") catch the same class of bug, but they require editing every model or every query, and they change behavior everywhere, permanently - not something you can flip on for one request while debugging a crash report.

Measured: python examples/demo.py (SQLAlchemy 2.1.1, aiosqlite), touching an unloaded relationship the same way in both cases:

1. Raw SQLAlchemy crash (no missinggreenlet):
    exception type : sqlalchemy.exc.StatementError
    message        : (sqlalchemy.exc.MissingGreenlet) greenlet_spawn has not been called; can't call await_() here. Was IO attempted in an unexpected place?
    traceback depth: 45 lines
    names the model/attribute/instance/call site? no
2. Same bug, missinggreenlet installed (mode='explain'):
    exception type : missinggreenlet._detect.LazyLoadError
    Implicit relationship lazy load: Author.books
      instance: Author(pk=1)
      triggered at: examples/demo.py:75, in with_missinggreenlet
      fix: add selectinload(Author.books) (or joinedload(...)) to the original query, or `await session.refresh(instance, ['books'])` before touching it, or mix AsyncAttrs into Author and use `await instance.awaitable_attrs.books`.
3. Applying the suggested fix (selectinload) - no crash, no false alarm:
    author.books preloaded: ['The Left Hand of Darkness']

The traceback depth varies a little across SQLAlchemy versions and DBAPI drivers (30-50 frames in practice); the point that it names nothing about your application code does not.

We checked whether SQLAlchemy already exposes this: the do_orm_execute event fires for every lazy load (relationship, deferred column, and expired-attribute refresh alike) before any I/O happens, and ORMExecuteState.lazy_loaded_from names the instance behind a relationship lazy load - but nothing public surfaces which specific attribute access crashed, and column loads don't expose their instance publicly at all. This package builds the diagnostic from that event (plus one small, isolated, version-gated read of an internal field - see How it works) instead of reinventing lazy-load detection.

Installation

pip install missinggreenlet

Requires SQLAlchemy >= 2.0. Tested against 2.0.54 and 2.1.1 (see Compatibility).

Usage

Diagnose crashes in development or production

from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
import missinggreenlet

engine = create_async_engine("postgresql+asyncpg://...")
session_factory = async_sessionmaker(engine, class_=AsyncSession)

missinggreenlet.install(session_factory, mode="explain")

Leave this on. It only ever intervenes on a load that was already about to crash with MissingGreenlet - it replaces that crash with the same exception chained under a LazyLoadError that names the model, attribute, instance and call site. It never changes behavior for a load that would have succeeded (eager loads, await session.refresh(...), AsyncAttrs).

Fail fast in a specific request path or job

missinggreenlet.install(session_factory, mode="raise")

Raises LazyLoadError at the point of the implicit access, before any query is sent to the database - no half-run statement, no wrapped DBAPI error.

Audit an existing codebase without breaking it yet

missinggreenlet.install(session_factory, mode="warn")

Emits a LazyLoadWarning with the same diagnostic and lets the original crash happen afterwards (or lets the load succeed, if it's actually safe) - useful for seeing how many call sites are affected before switching to mode="raise".

Catch N+1s in tests (sync or async)

from missinggreenlet import assert_no_lazy_loads

async def test_no_n_plus_one():
    async with assert_no_lazy_loads():
        result = await session.execute(select(Author).options(selectinload(Author.books)))
        for author in result.scalars():
            author.books  # preloaded - fine

assert_no_lazy_loads() is independent of install(): it fails on any implicit lazy load inside the block, whether or not it would actually crash

  • a lazy load that succeeds because it happens to run inside an already-safe context is still an N+1 query pattern worth catching in a test. Works as both with and async with.

Removing the hooks

handle = missinggreenlet.install(session_factory)
handle.uninstall()          # or: with missinggreenlet.install(...) as handle: ...

Reference

install(engine_or_sessionmaker, mode="explain")

Parameter Type Default Description
engine_or_sessionmaker Engine | AsyncEngine | sessionmaker | async_sessionmaker required What to watch. See Limitations for how scoping works.
mode "explain" | "raise" | "warn" "explain" See table below.

Returns an InstallHandle with .uninstall(); also usable as a context manager (with install(...) as handle:).

Mode When it acts What it does
explain A load is about to raise MissingGreenlet Raises LazyLoadError with the full diagnostic, chained from the original MissingGreenlet (exc.__cause__). No-op on a sync engine/session - MissingGreenlet can't happen there.
raise Same condition as explain (async target); any implicit lazy load (sync target) Raises LazyLoadError immediately, before any I/O - no DBAPI round trip, no wrapped exception.
warn Same condition as raise Emits LazyLoadWarning via warnings.warn, then lets the original outcome happen (crash or success).

assert_no_lazy_loads()

Context manager (with and async with). Raises LazyLoadDetected (subclasses AssertionError) on any implicit relationship lazy load, deferred-column load, or expired-attribute refresh inside the block, sync or async, regardless of whether it would have crashed.

Exceptions and warnings

Name Base Raised when
LazyLoadError RuntimeError mode="explain" or mode="raise"
LazyLoadWarning UserWarning mode="warn"
LazyLoadDetected AssertionError inside assert_no_lazy_loads()
LazyLoadDiagnostic dataclass not raised - .format() builds the message; available on all three exceptions' args[0]

How it works

Every relationship lazy load, deferred-column load, and expired-attribute refresh is issued as a Session.execute() call internally, so the public do_orm_execute event (SessionEvents) fires for all three before any I/O happens. From ORMExecuteState this package reads:

  • is_relationship_load / is_column_load - which family.
  • lazy_loaded_from - the InstanceState behind a relationship lazy load (populated only for a true per-instance load, not an eager batch load like selectinload, which is how eager loading is told apart from implicit loading with no false positives).
  • loader_strategy_path - the mapper path, whose last element names the relationship property.

Whether a load is safe is answered by sqlalchemy.util.concurrency.in_greenlet(): True inside the greenlet SQLAlchemy's asyncio bridge spawned for the current awaited call (safe - await session.refresh(...), AsyncAttrs.awaitable_attrs, and eager loads all resolve inside it), False outside it (about to crash). This is not public/documented API - it is a small, stable, one-purpose internal check, isolated in missinggreenlet/_greenlet.py with a single try/except: if a future SQLAlchemy release renames or removes it, the check degrades to "do nothing" rather than guessing.

Column loads (deferred columns, expired-attribute refreshes) don't get a public lazy_loaded_from. The InstanceState is still recoverable, best-effort, from ORMExecuteState.load_options._refresh_state - a private field of the (public) load_options attribute, set by sqlalchemy/orm/loading.py. This is isolated in one function (_refresh_state_of in _detect.py), documented there, and version-gated: if it's absent, the diagnostic falls back to naming the model without the specific instance. Verified against SQLAlchemy 2.0.54 and 2.1.1.

mode="explain" pairs this with the public DialectEvents.handle_error/ConnectionEvents.handle_error event (documented as supporting "exception re-writing"): the diagnostic built in do_orm_execute is stashed in a contextvars.ContextVar, and if the subsequent DBAPI call actually raises MissingGreenlet, handle_error substitutes a LazyLoadError built from that diagnostic, chained from the original via Python's normal raise ... from ....

Limitations

  • Scoping is best-effort, not per-instance. SQLAlchemy's do_orm_execute event can only be attached to the Session class (to reach a sessionmaker/async_sessionmaker with no static bind=) or to a specific engine (if there is one to compare against). Passing an engine, or a sessionmaker created with bind=<engine>, scopes the check correctly. Passing a sessionmaker with no static bind falls back to matching by session class - if your app has multiple engines sharing one Session/AsyncSession subclass, all of them are watched.
  • mode="explain" needs a concrete engine. If given a sessionmaker/async_sessionmaker with no static bind=, install() raises ValueError - there is no engine to attach handle_error to. Pass the engine directly, or use a sessionmaker created with bind=.
  • The instance-recovery for column loads relies on one private field (load_options._refresh_state, see How it works). If a future SQLAlchemy release removes it, deferred-column and expired-attribute diagnostics still fire and still name the model, but fall back to <instance unavailable> instead of the identity key.
  • The "first user frame" is a heuristic: the first stack frame outside sqlalchemy/, asyncio/, greenlet/, and this package's own source. A framework that wraps SQLAlchemy calls deep in its own internals (e.g. a generic repository layer) will show that wrapper's frame, not your business-logic call site.
  • Concurrent unsafe loads across tasks: mode="explain"'s do_orm_execute -> handle_error correlation uses a contextvars.ContextVar, which is per-asyncio-task. Two unsafe loads from two different, unrelated sync threads sharing one process could theoretically cross wires; this wasn't exercised by the test suite (async tests only).
  • Triggering the unsafe path always logs a stray RuntimeWarning: coroutine 'Connection.cursor' was never awaited. This comes from SQLAlchemy's own cleanup of the cancelled DBAPI coroutine when MissingGreenlet fires (see _safe_cancel_awaitable in sqlalchemy/util/concurrency.py) - it happens with or without this package installed and is not something missinggreenlet can suppress.
  • Not a substitute for assert_no_lazy_loads() as a runtime guard. assert_no_lazy_loads() raises AssertionError on any implicit load, which is right for a test but too strict for production code that sometimes safely lazy-loads inside an awaited context.
  • Doesn't cover bulk operations (session.execute(update(...)), Session.bulk_*) or Core-only usage - both are already un-ORM'd, so MissingGreenlet there isn't about a "lazy load" in this package's sense.

Compatibility

Verified locally (this machine has Python 3.14.7 only - CI covers 3.11-3.14):

  • SQLAlchemy 2.0.54 and 2.1.1, each in its own virtualenv, full test suite (pytest -q, 16 tests) passing against both.
  • Driver: aiosqlite (async), sqlite3 (sync, for assert_no_lazy_loads and the sync half of install()). Not tested against asyncpg or asyncmy - the mechanism is driver-agnostic (it triggers on the ORM loader, not the DBAPI), but only sqlite was actually run.
  • mypy --strict over src/ and tests/: passing, on Python 3.14.7.
  • CI (.github/workflows/test.yml) runs the suite on 3.11-3.14 - "run in CI", not independently verified here.

Development

python3 -m venv .venv
.venv/bin/pip install -e '.[dev]'
.venv/bin/python -m pytest -q
.venv/bin/python -m mypy --strict src tests
.venv/bin/python examples/demo.py

License

MIT - see LICENSE.

About

Turns SQLAlchemy's MissingGreenlet crash into a diagnostic that names the model, attribute, instance and call site

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages