Give each request and worker its own database session#1149
Conversation
session_factory is a scoped_session, which hands each thread its own Session. init_session() then did `self.session = self.session_factory()`, storing that thread's Session on a single shared attribute of a module-level singleton — so whichever thread called it most recently published its Session there, and all ~270 `calibre_db.session` call sites read that one. The thread-locality the wrapper exists to provide was discarded by the assignment. CWNG runs gevent without monkey.patch_all(), so WorkerThread is a real OS thread rather than a greenlet: two genuinely concurrent users of one Session and one SQLite connection. `session` is now a property that resolves through the registry on every read. The setter is kept because callers assign to it — editbooks drops a poisoned session with `calibre_db.session = None`, and tests substitute stubs — but those assignments are now scoped to the assigning thread instead of leaking to every other one. Two call sites needed care: - __init__ no longer assigns `self.session = None`. That would run the setter and tear down the calling thread's session, and CalibreDB() is constructed inside request handlers (cps/web.py:1373, magic_shelf). - dispose() reads _peek_session() rather than the property, so it does not create a Session for the disposing thread purely to close it. Reconnects also propagate further than before: threads now read the current session_factory on every access, so a factory rebuilt by setup_db reaches every thread rather than only the one that ran the loop. Un-quarantines the test from #1122, which was RED by design. For #1121
Two contracts the plain attribute had that the property did not: - Instances built with `CalibreDB.__new__(CalibreDB)` never run __init__, which several tests do deliberately to avoid needing a Flask app. The thread-local slot is now created on demand instead of only in __init__, and expire_on_commit falls back to True when unset. - `mock.patch.object(calibre_db, "session", ...)` records that the attribute was absent from __dict__ and restores by deleting it, which raised "property has no deleter" on every such patch exit. Deleting now drops this thread's override and returns to registry lookup; unlike assigning None it does not discard the thread's Session. For #1121
#1121 recorded the link to #1048 as a hypothesis and said what would be needed to settle it: force the overlap, with the worker closing while a request is partway through a result set it has not finished streaming. Two earlier attempts closed and re-used a Session sequentially, which is harmless because a Session re-acquires a connection after close(). This builds the genuine interleave. The reader iterates yield_per so rows arrive from a live cursor in batches, stops mid-stream, and only then lets the worker run duplicate_scan's teardown shape. Against main the reader dies: InvalidRequestError: Object <_Row> cannot be converted to 'persistent' state, as this identity map is no longer valid. Has the owning Session been closed? which is the reported mechanism — a background task tearing down the session a request is streaming from. The reporter's traceback surfaced as sqlite3.ProgrammingError from _raw_all_rows rather than this, so what is reproduced here is the mechanism, not that exact frame; where it lands depends on how far into the fetch the close arrives. The engine mirrors production: StaticPool over one shared connection, which the UDF-deadlock design note records as architecturally required. Reader and worker share a connection either way — what changed is that they no longer share a Session. For #1121
Turning CalibreDB.session into a property is only safe if it keeps every contract the plain attribute had. Three were found by the suite rather than by reading the code -- lazy init for __new__-built instances, a deleter for mock.patch.object teardown, and assigning None as editbooks' recovery path -- so pin all of them plus per-thread scoping and the expire_on_commit preference. Red on main (5 of 6 fail, incl. the cross-thread one), green here.
Review disposition — cross-family (Terra) + proofing passTerra reviewed 🔴 BLOCKER (found in the proofing pass, not by Terra or the PR)
This PR reasons about Meanwhile So greenlet A finishing its request silently swaps the Session out from under greenlet B, mid-request. Before this change B kept reading the same object off the shared attribute (closed, but re-usable — a closed Session re-acquires a connection). Now a read early in a request and a That converts a background-task-only failure into one reachable by any two concurrent requests, which is the opposite of the intent. It needs a design answer (a generation counter, or teardown that doesn't 🟠 Confirmed, not blocking
🟡 Noted
✅ Verified sound
Where this leaves the PRThe core diagnosis is right and the OS-thread half of the fix is well built. What's missing is the greenlet half — |
Requests are greenlets, and gevent runs unpatched here, so every concurrent request shares one OS thread. SQLAlchemy's default scope is the thread, so they also shared one Session -- while shutdown_session calls session_factory.remove() on every request teardown. The first request to finish closed the Session the others were still reading from, and the victim failed wherever it happened to be, usually with "Instance ... is not persistent within this Session" (#1150). greenlet.getcurrent() is a distinct object per greenlet and a distinct root greenlet per OS thread, so this scope subsumes the thread scoping worker threads rely on (#1121) rather than replacing it. Per-request remove() becomes correct: it can only reach the caller's own Session. Passing any scopefunc swaps ThreadLocalRegistry (a threading.local, freed with its thread) for a plain dict, which would retain a Session for every greenlet that never reaches a teardown -- measured at 50 retained for 50 dead greenlets. The map is weak-keyed, which brings that back to 0. Transaction boundaries are still process-global, because StaticPool keeps one DBAPI connection. That is unchanged and tracked separately; it was equally true when all greenlets shared a single Session.
The #3670 pin read the source of setup_db for the string "autoflush=True". Extracting the factory into _make_session_factory moved that string out of the function the pin was reading, so the pin went green while looking at nothing. It now builds a factory and asserts the Session it hands out has autoflush enabled, which is the property the tag-collapse behaviour actually depends on and which no relocation of the call can hide. Verified falsifiable: flipping autoflush to False in _make_session_factory fails this test.
Addresses #1121. Also reproduces the mechanism behind #1048, which #1121 had recorded as a hypothesis.
Why
CalibreDB.session_factoryis ascoped_session, which exists to hand each thread its own Session.init_session()then did:storing that thread's Session on a single shared attribute of a module-level singleton. Whichever thread called
init_session()most recently published its Session there, and all ~270calibre_db.sessioncall sites read that one. The thread-locality the wrapper exists to provide was discarded by the assignment.This matters more here than in a typical Flask app because CWNG runs gevent without
monkey.patch_all(), soWorkerThreadis a real OS thread rather than a greenlet — two genuinely concurrent users of one Session.Root cause
A single shared attribute standing in for per-thread state. Not the background tasks themselves: every one of them inherits the defect, so the symptom reappears in unrelated places with unrelated-looking tracebacks.
Reproduction
Two levels, both committed.
The mechanism (
test_1121_session_is_thread_local.py, from #1122, previously quarantined as RED-by-design). Againstmain:The symptom (
test_1121_worker_close_during_request_fetch.py, new). #1121 asked for this specifically — "queue a manual scan while a slow paginated browse is streaming, and assert the browse completes" — and noted two earlier attempts had failed because closing and re-using a Session sequentially is harmless (a Session re-acquires a connection). This builds the genuine interleave: the reader iteratesyield_perso rows arrive from a live cursor, pauses mid-stream, and only then lets the worker runduplicate_scan's teardown shape (ensure_session()…session.close()). Againstmainthe reader dies:That is the reported mechanism — a background task tearing down the session a request is streaming from. Honest scope: the reporter's traceback on #1048 surfaced as
sqlite3.ProgrammingErrorfrom_raw_all_rowsrather than this error. What is reproduced here is the mechanism; which error surfaces depends on how far into the fetch the close lands. I am not claiming the reporter's exact frame is reproduced.Fix
sessionbecomes a property that resolves through thescoped_sessionregistry on every read, so thread-locality survives. The setter is kept because callers assign to it —cps/editbooks.py:1072drops a poisoned session withcalibre_db.session = None, and tests substitute stubs — but those assignments are now scoped to the assigning thread instead of leaking to every other one.Three call sites needed care, each found by the test suite rather than by inspection:
__init__no longer assignsself.session = None. That would run the setter and tear down the calling thread's session, andCalibreDB()is constructed inside request handlers (cps/web.py:1373,magic_shelf.py).__init__: several tests build instances withCalibreDB.__new__(CalibreDB)to avoid needing a Flask app, and a property that raisesAttributeErroron a partially constructed instance is a worse contract than the plain attribute it replaced.sessionneeds a deleter.mock.patch.object(calibre_db, "session", ...)records that the attribute was absent from__dict__and restores by deleting it, so without one every such patch raises on exit.dispose()reads a new_peek_session()rather than the property, so it does not create a Session for the disposing thread purely to close it.One incidental improvement: threads now read the current
session_factoryon every access, so a factory rebuilt bysetup_dbreaches every thread rather than only the one that ran the loop.What this does NOT fix
Worth stating plainly, because the title could be read as "calibre_db is now thread-safe" and it is not.
notes/fix-udf-gil-deadlock-DESIGN.mdrecords thatStaticPoolis architecturally required here: the calibre engine issqlite://with per-connectionATTACHof calibre.db + app.db, and per-thread connections (QueuePool/NullPool) were considered and rejected as a much larger change. So every thread still shares one DBAPI connection, and transaction boundaries on it remain process-global — acommit()on one thread still commits another thread's in-flight writes.That is not a regression: with a single shared Session the same was true, plus the identity map was shared and could be closed underneath a reader. This change removes the Session sharing, which is what the reported crashes come from. The residual connection-level sharing is worth a follow-up issue, not a blocker.
Validation
main, GREEN on this branch (evidence quoted above).tests/unit+tests/smoke: 4172 passed, 8 failed — the 8 are byte-identical tomain's pre-existing baseline, so zero regressions. Baseline captured by running the same suite onmainin the same venv.One caveat recorded rather than hidden: during an intermediate state (deleter not yet added, so
patch.objectteardown was raising) one suite run stalled for ~10 minutes at 0.6% CPU withoutpytest-timeoutfiring — the signature of the known GIL↔sqlite-mutex class in that design note. It did not recur: the final code ran the full suite to completion twice, in 185s and clean. Flagging it because that class is hard to trigger and unpleasant to meet in production; I could not reproduce it against the committed code.Tier
needs-review— fork-original change to the highest-traffic file. Reviewed cross-family before merge per the autonomous merge gate.Also fixes #1150 — the greenlet half of the same question
The review of this PR surfaced a second defect, filed as #1150 and reproduced on
main. It is the other half of "what is the lifetime of a Session here", so itis fixed here rather than in a follow-up.
Why the thread fix alone was not enough
Restoring thread-locality is necessary but not sufficient. Requests are gevent
greenlets and
monkey.patch_all()is deliberately absent, so every concurrentrequest runs on one OS thread and
threading.local()cannot separate them.They therefore still shared a Session — while
shutdown_session(
cps/__init__.py:469-482) callssession_factory.remove()on every requestteardown.
So the first request to finish closed the Session the others were still reading
from. The victim failed wherever it happened to be, which is why this family has
produced a different-looking traceback every time (#1048, #1121, CWA #1228):
This is on
maintoday and pre-dates this PR. The property alone would have madeit sharper, since a foreign
remove()then changes whatcalibre_db.sessionreturns mid-request.
Fix
setup_dbnow builds the factory through_make_session_factory(), which passesscopefunc=greenlet.getcurrent.greenlet.getcurrent()is a distinct object per greenlet and a distinct rootgreenlet per OS thread — measured, 4 distinct across main + 3 threads. So
greenlet scope subsumes thread scope rather than replacing it: worker threads
stay isolated exactly as the fix above made them, and request greenlets are now
isolated from each other too. Per-request
remove()becomes correct instead ofdestructive, because it can only reach the caller's own Session.
A leak the scopefunc introduces, found by measuring rather than assuming.
Passing any
scopefuncswaps SQLAlchemy'sThreadLocalRegistry(athreading.local, freed with its thread) forScopedRegistry's plain dict,which retains a Session for every greenlet that never reaches a teardown —
measured at 50 retained for 50 dead greenlets. Greenlets are
weak-referenceable, so the registry map is a
WeakKeyDictionary, which measures0. Note this means the earlier "the registry does not leak" finding was about
ThreadLocalRegistryand does not carry over.greenletis already a pinned hard requirement (requirements.txt:40) and adependency of gevent, so no new dependency. The
ImportErrorfallback todefault thread scope exists only for the tornado path, where there are no
greenlets and thread scope is already correct.
One test needed repairing, and it was a real gap
test_calibre_session_configures_autoflush_on(janeczku#3670, case-variant tags)source-pinned
setup_dbfor the stringautoflush=True. Extracting the factorymoved that string out of the function the pin was reading, so the pin would
have gone green while looking at nothing. It now builds a factory and asserts
the Session it hands out has
autoflushenabled — the property the behaviouractually depends on, which no relocation can hide. Verified falsifiable: flipping
autoflushtoFalsein_make_session_factoryfails it.Validation
tests/unit/test_1150_greenlet_scoped_sessions.py, 7 tests: the interleaveabove, per-greenlet isolation, per-thread isolation still holding, the registry
leak guard, session identity stable across a foreign teardown, and a scope-key
pin.
notes/verify/FAILURE-MODES.mdclass 9b): the redtest cannot be run against
main— the function it imports does not existthere. So the file also runs the identical interleave against the factory
mainbuilds and asserts it still crashes. If that stops failing, the guard isworthless and the suite says so.
tests/unit+tests/smoke: 8 failed / 4186 passed, the same 8 asmain's baseline. The autoflush pin above was a 9th until it was repaired.cwn-local: the mechanism reproduces under the container's ownruntime (py3.13.14, SQLAlchemy 2.0.51 — a different version than the test
venv's 2.0.49, so it is not version-specific) and stops reproducing with the
fixed
cps/db.pycopied in. The deployed module really carriesScopedRegistry+greenlet.getcurrent+ a weak-keyed map.xargs -P 16) across/and/book/<id>— 0 error signatures, 0 tracebacks, library still renders.Stated honestly: this is a null result, not proof. A 42-book local library
serves pages too fast to reliably produce the overlap the bug needs, so the
hammer cannot discriminate it — its value here is evidence of no regression
under concurrency, and the discrimination comes from the two reproductions
above.
gevent.spawnincps/outside the WSGIserver, so requests are the only greenlets; and none of the three
parallel.fan_outcallsites (search_metadata,cover_booster,cover_picker) touch the ORM session in their workers — they are HTTP providercalls. So no request spans two greenlets while sharing a Session.
Still not fixed, deliberately
StaticPoolkeeps one DBAPIconnection (required, see
notes/fix-udf-gil-deadlock-DESIGN.md), so acommit()still commits another in-flight write. Measured, unchanged by this,and equally true when every greenlet shared one Session. Tracked separately.
cps/ub.pyhas the same defect at larger scale —init_db()collapses ascoped_sessioninto one shared global, ~434 call sites across 33 files,documented in
notes/cwa-1228-shared-session-gevent-DESIGN.mdand deferredsince 2026-05-07. Not touched here.
/security-reviewnot run: this changes ORM session scoping, not auth/session,CSRF, crypto, subprocess, user-controlled paths, or routes. Worth noting the
change moves away from two concurrent users' ORM objects sharing one identity
map.
Cross-family review (Terra, resumed thread) — disposition
Two HIGHs raised. Both proofed rather than taken on trust; one holds in reduced
form, one does not.
HIGH 2 — "N sessions on one StaticPool connection is a NEW failure mode."
NOT CONFIRMED. The claim was that B's teardown rollback can now discard A's
uncommitted work, where previously one shared Session meant one transaction
controller. Measured both shapes on the same interleave (A writes + flushes,
yields, B's teardown fires, A commits):
maintoday)Identical. The cross-transaction damage is a pre-existing property of one
process-wide DBAPI connection, not something this diff introduces — and the
predicted "cannot start a transaction within a transaction" did not occur in
either shape. What the diff removes is the additional harm the shared shape
had on top of this: destroying the other request's identity map, which is #1150.
Downgraded to the already-documented StaticPool caveat.
HIGH 1 — "
refresh_for_new_data()no longer reaches other requests'sessions." CONFIRMED, severity reduced to low. It iterates
CalibreDB.instancesand expires
inst.session, which now resolves to the calling greenlet'ssession, so concurrently in-flight requests keep their snapshot. Real, and the
docstring's "on every instance" is now inaccurate. Scope, checked: the only
caller is the manual reconnect endpoint (
cps/cwa_functions.py:684), sessionslive exactly one request (teardown removes them), so every request after the
refresh sees the new data and the staleness window is the tail of requests
already in flight. Not a blocker, not silently accepted: filed as a follow-up
with the measurement.
Review checks 1, 2, 3 and 5 returned no finding: no scope-key collision between
greenlets or threads,
WeakKeyDictionaryis compatible with theScopedRegistryoperations
scoped_sessionuses (the key is weak, not the value, and a runnablegreenlet stays strongly reachable),
dispose()/editbooksrecovery/desktop-compatbehave as intended, and no current production path creates a session outside a
Flask app context. Noted for the future: reaching into
factory.registry.registryis an implementation detail, and any future non-request greenlet touching
calibre_db.sessionneeds an explicit cleanup wrapper.