fix(db): use a private engine for prequery connections to avoid listener race - #41642
Conversation
…ner race Database.get_sqla_engine attaches a per-call "connect" listener when prequeries exist (e.g. SET search_path on PostgreSQL, apache#40194) and removes it on exit. SQLAlchemy stores listeners in an unlocked deque, so once engines became shared across threads via _ENGINE_CACHE (apache#40237), the per-call listen/remove raced with concurrent connection checkouts iterating the same deque, intermittently raising "RuntimeError: deque mutated during iteration" and surfacing as 500s under load. Fix: compute prequeries before fetching the engine and request an uncached, private engine (cacheable=False) whenever prequeries are present, so the listener mutation never touches a shared object. No-prequery paths keep the shared cache. Includes a concurrency stress test that reproduces the RuntimeError against the previous code and a deterministic engine-identity regression test.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #41642 +/- ##
==========================================
+ Coverage 64.52% 64.55% +0.03%
==========================================
Files 2673 2684 +11
Lines 147639 148274 +635
Branches 34093 34153 +60
==========================================
+ Hits 95260 95725 +465
- Misses 50650 50804 +154
- Partials 1729 1745 +16
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
…ion test Two hardening items from an independent second-model review of this fix (which confirmed the diagnosis with a standalone SQLAlchemy repro and recommended merge, with these as follow-ups): 1. Private prequery engines are now disposed in the finally block. With the default nullpool=True this is a no-op safety net; it matters if a caller ever passes nullpool=False, where each private engine would otherwise keep a short-lived QueuePool alive until GC. 2. The 4-thread stress test is timing-based and can pass on pre-fix code on a lucky run. Added a deterministic companion: a patched engine creator parks thread A provably mid-connect-dispatch (inside the prequery listener's first cursor() call) while thread B enters the prequery path and mutates the listener collection, then releases A. A warm-up connection first consumes the dialect's one-time first_connect isolation probe — parking inside that probe would hold the engine's first_connect mutex and deadlock the sibling thread on it instead of exercising the deque race (found via faulthandler stack dumps). Validated bidirectionally: pre-fix code fails every run with the exact "deque mutated during iteration" error in under a second; fixed code passes standalone and in-suite.
✅ Deploy Preview for superset-docs-preview ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
Independent validation summarySince a concurrency claim is hard to review by inspection, we ran this PR through an independent second-model review (OpenAI Codex, adversarial brief: verify both the diagnosis and the fix; say plainly if either is wrong). Summary of its findings: Diagnosis confirmed — including an independent reproduction. It traced the same mechanism (shared Fix confirmed sound and complete. It searched for other attach/remove paths on cached engines and found none; non-prequery databases keep caching exactly as before. Verdict: "Merge as-is." Both of its improvement suggestions are now applied (
Between the original stress repro, the second model's standalone reproduction, and the deterministic test, the diagnosis and fix are validated three independent ways. |
|
cc @fitzee and @rusackas as the authors of the two PRs whose interaction produces this race — to be clear, neither change is at fault on its own:
This fix keeps both behaviors intact: prequeries still execute on every new DBAPI connection (behavioral guard test included), and non-prequery databases keep full engine caching. Only prequery-bearing calls opt out of the cache, restoring their pre-#40237 private-engine behavior. Reviews from either of you would be especially valuable since you know these two code paths best — the validation summary above (independent second-model review + deterministic regression test) should make the concurrency claim easy to check without re-deriving it. |
rusackas
left a comment
There was a problem hiding this comment.
Really nice work @mikebridge, LGTM. The diagnosis holds up... a shared _ENGINE_CACHE engine plus per-call listener add/remove on an unlocked deque is exactly the race, and gating the cache with cacheable=False for prequery engines is the right fix (keeps the #27897 caching everywhere else, restores fresh-per-call only where the listener needs to mutate). The deterministic parking-harness test is a thing of beauty, reproduces the deque mutated during iteration crash on the old code and passes clean on the new. Since #40237 was mine, extra glad to see it pinned down properly. Approving, CI is green.
|
Bito Automatic Review Skipped – PR Already Merged |
SUMMARY
Fixes an intermittent
RuntimeError: deque mutated during iteration(surfacing as HTTP 500s and E2E flakiness) caused by an interaction between two changes:Database.get_sqla_engineattach a per-call SQLAlchemyconnectlistener when prequeries exist (e.g.SET search_pathon PostgreSQL), removing it again on context exit — two mutations of the engine's connect-listener deque per call._ENGINE_CACHE, makingEngineobjects shared across threads per(database_id, url, engine_kwargs).SQLAlchemy stores event listeners in a plain, unlocked
collections.deque, and dispatch iterates it unguarded (for fn in self.listeners). With NullPool, everyraw_connection()creates a fresh DBAPI connection and fires that dispatch. So one thread's connection checkout can iterate the shared engine's deque while another thread'sget_sqla_engineenter/exit mutates it — a classic race that reproduces readily under concurrent load (observed as consistentcreate-dataset500s on one Playwright run and flaked-but-recovered failures on another).Fix: compute prequeries before fetching the engine, and request a private, uncached engine (
cacheable=False) whenever prequeries are present. The per-call listener add/remove then only ever touches an engine no other thread can see. No-prequery paths keep the shared cache and its #27897 semantics. The listener also closes over per-call, schema-specific prequeries and must not leak to other requests sharing a cached engine — which is why a register-once approach was rejected.This restores the pre-#40237 behaviour for prequery engines only (fresh engine per call); prequery-less databases keep full engine caching. Private engines are explicitly
dispose()d on context exit — a no-op under the defaultnullpool=True, and a pool-leak guard if a caller ever passesnullpool=False.Independent validation: the diagnosis and fix were adversarially reviewed by a second model (verdict: merge as-is), which independently reproduced the exact
RuntimeErrorwith standalone SQLAlchemy-only code; both of its hardening suggestions (thedispose()above and a deterministic regression test below) are applied. Details: #41642 (comment)BEFORE/AFTER SCREENSHOTS OR ANIMATED GIF
N/A (backend concurrency fix)
TESTING INSTRUCTIONS
Unit tests in
tests/unit_tests/models/core_test.py:test_prequery_engine_bypasses_shared_cache— deterministic regression: with prequeries the yielded engine is never the cached instance and_ENGINE_CACHEis untouched; without prequeries caching behaviour is unchanged.test_prequeries_execute_on_real_connections— behavioural guard for fix(sqllab): execute prequeries on streaming connection to fix PostgreSQL CSV export #40194: prequeries still execute on every new DBAPI connection (file-backed SQLite, table-creating prequery, observed on the same connection).test_prequery_listener_mutation_race_deterministic— deterministic regression (no timing dependence): a patched enginecreatorparks one thread provably mid-connect-dispatch (inside the prequery listener's firstcursor()call) while a second thread enters the prequery path and mutates the listener collection, then releases the first. A warm-up connection first consumes the dialect's one-timefirst_connectisolation probe, which would otherwise hold the engine's once-mutex and deadlock the sibling thread instead of exercising the race. On pre-fix code this fails every run (6/6 observed) with the exactdeque mutated during iterationerror in under a second; with the fix it passes standalone and in-suite.test_concurrent_prequery_connections_do_not_race— stress companion: 4 threads × 50get_raw_connectioncalls with prequeries. Against the previous code this reproduced the production error readily (probabilistic by nature — the deterministic test above is the authoritative pin); with this fix it passes.ADDITIONAL INFORMATION
🤖 Generated with Claude Code