Skip to content

fix: register session listeners in framework extension configs (#709)#712

Open
hasansezertasan wants to merge 27 commits intolitestar-org:mainfrom
hasansezertasan:fix/extension-create-session-maker-listeners
Open

fix: register session listeners in framework extension configs (#709)#712
hasansezertasan wants to merge 27 commits intolitestar-org:mainfrom
hasansezertasan:fix/extension-create-session-maker-listeners

Conversation

@hasansezertasan
Copy link
Copy Markdown
Contributor

@hasansezertasan hasansezertasan commented Apr 10, 2026

Summary

Fixes #709 — framework extension configs (Litestar, Starlette, Sanic, Flask, async and sync each) override create_session_maker without delegating to super(), silently skipping AsyncFileObjectListener / SyncFileObjectListener, touch_updated_timestamp, and AsyncCacheListener / SyncCacheListener registration. User-visible effect: file uploads via FileObject / StoredObject persist metadata to the database but the actual file content is silently discarded.

  • Fix: Each override now delegates to super().create_session_maker() so the middle-layer listener wiring runs. Framework-specific pre-steps (notably Starlette/Sanic/Flask's engine_instance caching) are preserved verbatim — the principle is add listener wiring, preserve every other observable behavior.
  • Scope: 8 source files (4 extensions × async + sync). FastAPI re-exports the Starlette configs at the package level, so it inherits the fix for free (pinned by a dedicated regression test).
  • Tests: Listener-registration contract is locked per extension via a shared helper module (tests/unit/test_extensions/_listener_contract.py). Flask, Sanic, Starlette, FastAPI, and Litestar each get 6 thin tests that call into the shared assertions, so future overrides cannot silently drop listeners again.

Design notes

  • Why mock-based unit tests and not integration tests? The base class's integration tests already cover end-to-end listener behavior. The bug is that listeners weren't registered at all in subclasses — a unit-test question. Mocks catch exactly that failure mode loudly and specifically (call_count == 0 vs expected 6), whereas an integration test would fail as a confusing silent missing-file, which is exactly the failure mode Bug: Litestar SQLAlchemyAsyncConfig.create_session_maker() does not register FileObject session listeners #709 reported.
  • Why engine_instance pre-step preserved in Starlette/Sanic/Flask? Analysis shows it's technically redundant with get_engine()'s internal memoization, so the overrides could be deleted outright. But "minimal harm" mandates preserving exact call sequences in case downstream users have subclassed or monkey-patched in unexpected ways. The tests pre-populate config.engine_instance = MagicMock() to isolate listener-count assertions from the aiosqlite dialect's internal event.listen calls.
  • Shared test helper: Flask/Sanic/Starlette/FastAPI listener tests now delegate to _listener_contract.py helpers (~160 lines) instead of duplicating ~600 lines across four files. Each per-extension test file is now ~45 lines of thin wrappers that pass the extension's config class into the shared assertions. Litestar tests remain separate because they predate the extraction and live under their own subdirectory.
  • Atomic commits: small commits tell the full story: failing-test commits (red) → super() delegation commits (green) → test-isolation commit → pre-step restoration commits → test strengthening and helper-extraction commits. Each commit is individually revertable.

Side effects worth noting

  • Litestar session_maker caching is now enabled. The pre-fix Litestar async/sync overrides ended with return self.session_maker_class(**session_kws) — they built and returned a session maker but never assigned it to self.session_maker, so every call rebuilt it. After the fix, super().create_session_maker() routes through the middle layer (advanced_alchemy/config/{asyncio,sync}.py), which does cache via self.session_maker = .... The if self.session_maker: return self.session_maker guard at the top of the Litestar override now short-circuits on subsequent calls. This is a latent bug fix (nothing wants multiple session makers for one config), not a regression — flagged here so anyone tracking id(config.session_maker) stability across calls isn't surprised.
  • Starlette/Sanic/Flask overrides are unchanged in this dimension — they already cached via self.session_maker = self.session_maker_class(**session_kws) in the pre-fix code, and the middle layer preserves the same caching semantics.

Test Plan

  • Shared helper passes on all 5 extensions (Flask, Sanic, Starlette, FastAPI, Litestar) — 30 tests total
  • Full tests/unit/ suite — no regressions (pre-existing failures in test_repository.py and test_utils/test_fixtures.py confirmed unrelated by checking out base commit)
  • Reviewer manually verifies MCVE from Bug: Litestar SQLAlchemyAsyncConfig.create_session_maker() does not register FileObject session listeners #709 now produces "file_exists_on_disk": true
  • Reviewer confirms the "minimal harm" design approach (preserve engine_instance pre-step) matches project preference

Draft status

Drafting for reviewer discussion on whether to keep the conservative engine_instance pre-step or delete the non-Litestar overrides outright (simpler, provably safe per get_engine() memoization analysis). CHANGELOG entries deferred to the release-bump process per project convention.

Supersedes

Supersedes #711 — same commits, renamed branch (feat/hasansezertasan/afix/extension-create-session-maker-listeners). The GitHub branch rename API closed the previous PR because cross-fork PRs don't preserve rename linkage.

🤖 Generated with Claude Code

@codecov-commenter
Copy link
Copy Markdown

codecov-commenter commented Apr 10, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 80.68%. Comparing base (e1e16b1) to head (6d862c9).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #712      +/-   ##
==========================================
+ Coverage   80.61%   80.68%   +0.07%     
==========================================
  Files          99       99              
  Lines        8227     8197      -30     
  Branches     1124     1116       -8     
==========================================
- Hits         6632     6614      -18     
+ Misses       1270     1269       -1     
+ Partials      325      314      -11     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

hasansezertasan and others added 18 commits April 26, 2026 10:27
Regression tests for litestar-org#709.
Lock the contract that the Litestar SQLAlchemyAsyncConfig subclass must
register the base-class listener set (file-object, timestamp, cache) when
create_session_maker() is called.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Regression tests for litestar-org#709.
Lock the contract that the Litestar SQLAlchemySyncConfig subclass must
register the base-class listener set directly on the session_maker.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Regression tests for litestar-org#709.
Lock the contract that both Starlette async and sync config subclasses must
register the base-class listener set when create_session_maker() is called.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Regression tests for litestar-org#709.
Lock the contract that both Sanic async and sync config subclasses must
register the base-class listener set when create_session_maker() is called.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Regression tests for litestar-org#709.
Lock the contract that both Flask async and sync config subclasses must
register the base-class listener set when create_session_maker() is called.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ring (litestar-org#709)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…er wiring (litestar-org#709)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ener wiring (litestar-org#709)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ner wiring (litestar-org#709)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
… wiring (litestar-org#709)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…wiring (litestar-org#709)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…wiring (litestar-org#709)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
… wiring (litestar-org#709)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The Starlette/Sanic/Flask listener tests call create_session_maker on a
fresh config. When get_engine() runs for real, the aiosqlite dialect
registers spurious event.listen calls that pollute the mock count.
Pre-populating engine_instance with a MagicMock makes get_engine()
short-circuit so only the listener-registration path contributes to
the mock.

This unblocks restoring the engine_instance caching pre-step in the
extension overrides per the "minimal harm" design directive from litestar-org#709.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Preserves exact observable behavior of the previous override:
engine_instance is populated during create_session_maker, not
lazily on a later get_engine() call. This honors the "minimal harm"
design directive from the litestar-org#709 reviewer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Preserves exact observable behavior of the previous overrides:
engine_instance is populated during create_session_maker, not
lazily on a later get_engine() call. Honors the "minimal harm"
design directive from the litestar-org#709 reviewer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Preserves exact observable behavior of the previous overrides:
engine_instance is populated during create_session_maker, not
lazily on a later get_engine() call. Honors the "minimal harm"
design directive from the litestar-org#709 reviewer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add per-call target assertions (every listener attached to the synthetic
sync_maker, not the async session_maker) and event-name set assertion to
match the reference pattern in test_config/test_async_config.py and the
Litestar async listener test. Catches a regression where listeners are
incorrectly attached to the async session_maker instead of the synthetic
sync_maker.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
hasansezertasan and others added 2 commits April 26, 2026 10:27
Flask/Sanic/Starlette listener tests were ~156 lines of near-identical
code each. Extract the 6 assertions into _listener_contract.py and
reduce each per-extension test file to ~45 lines of thin wrappers that
pass the extension's config class into the shared helpers. Keeps the
per-extension files for locality and per-framework skip markers, while
eliminating ~400 lines of duplication.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
FastAPI re-exports SQLAlchemyAsyncConfig / SQLAlchemySyncConfig from the
Starlette extension at the package level, so the listener-wiring fix
carries over for free. Add a dedicated regression test that delegates
to the shared _listener_contract helpers — if someone later adds a
FastAPI-specific override without calling super(), this test will fail
loudly rather than silently dropping listeners like litestar-org#709.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@cofin cofin force-pushed the fix/extension-create-session-maker-listeners branch from 6d862c9 to 71d2651 Compare April 26, 2026 17:27
Comment thread tests/unit/test_extensions/_listener_contract.py Outdated
@cofin
Copy link
Copy Markdown
Member

cofin commented Apr 26, 2026

@hasansezertasan what did you have remaining on this? the change looks correct from what i can tell.

Comment thread advanced_alchemy/extensions/flask/config.py Outdated
Comment thread advanced_alchemy/extensions/litestar/plugins/init/config/asyncio.py Outdated
hasansezertasan and others added 4 commits April 27, 2026 13:24
Co-authored-by: Cody Fincher <204685+cofin@users.noreply.github.com>
Signed-off-by: Hasan Sezer Taşan <13135006+hasansezertasan@users.noreply.github.com>
Co-authored-by: Cody Fincher <204685+cofin@users.noreply.github.com>
Signed-off-by: Hasan Sezer Taşan <13135006+hasansezertasan@users.noreply.github.com>
…io.py

Co-authored-by: Cody Fincher <204685+cofin@users.noreply.github.com>
Signed-off-by: Hasan Sezer Taşan <13135006+hasansezertasan@users.noreply.github.com>
Pin mysql-connector-python exclude-newer to a TZ-qualified ISO
timestamp so uv lock produces deterministic output across runners
(was date-only, drifted by local TZ on every uv sync). Also drop
the extra blank line ruff-format flagged in the listener contract
helper.
@hasansezertasan
Copy link
Copy Markdown
Contributor Author

@hasansezertasan what did you have remaining on this? the change looks correct from what i can tell.

Hey @cofin, thanks for your time. I converted this one because I had some stuff in mind but I can't recall right now 🫤. I'll do a final review of my work and mark it as "ready for review".

@hasansezertasan hasansezertasan marked this pull request as ready for review April 27, 2026 10:45
@hasansezertasan hasansezertasan requested review from a team as code owners April 27, 2026 10:45
@hasansezertasan hasansezertasan requested a review from cofin April 27, 2026 10:45
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f169d58f00

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread advanced_alchemy/extensions/starlette/config.py
When `routing_config` is set, `super().create_session_maker()` constructs a
`RoutingAsync/SyncSessionMaker` that owns its own per-group engine pools
(primary + replicas). Extension shutdown previously only disposed
`engine_instance`, leaking replica connections across reload/shutdown.

Add `dispose_session_maker` / `adispose_session_maker` helpers that no-op for
non-routing makers and call `close_all()` for routing makers, then wire them
into Starlette, Flask, Sanic, and Litestar shutdown paths (FastAPI inherits
from Starlette). Includes unit coverage for both helpers.

Refs codex review on litestar-org#712.
hasansezertasan and others added 2 commits April 28, 2026 03:14
Starlette's run_in_threadpool is typed as Callable[[], T], so passing
positional args fails pyright. Wrap dispose_session_maker call in a
lambda to match the zero-arg signature, mirroring the existing pattern
used at line 380.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: Litestar SQLAlchemyAsyncConfig.create_session_maker() does not register FileObject session listeners

3 participants