# ADR-0004: QueuePool (not NullPool) for SQLCipher databases **Date:** 2026-04-13 **Status:** Accepted ## Context Each user has their own SQLCipher-encrypted SQLite database, opened at login and closed at logout. Background threads (research workers, metric writers, news scheduler jobs) need database sessions for the same user concurrently with Flask request handlers. ### Why QueuePool SQLCipher's `PRAGMA key` adds ~0.2 ms per connection open. With 20–30 queries per page load, NullPool (new connection per checkout) adds a noticeable 4–6 ms overhead vs QueuePool's ~1.5 ms for pool-resident connections. ### Why not per-thread NullPool engines An earlier design maintained a second engine system: one NullPool engine per `(username, thread_id)` in `_thread_engines`, used by background threads for metric writes. This was removed in PR #3441 because: 1. **FD leak.** Each SQLCipher + WAL connection holds 3 file descriptors (main db + WAL + SHM). Orphaned thread engines — left behind when `@thread_cleanup` did not fire — accumulated FDs unboundedly, eventually exhausting the 1024 soft limit and crashing the server with `OSError: [Errno 24] Too many open files`. 2. **Architectural redundancy.** The per-user QueuePool engine is already created with `check_same_thread=False`, making it safe for background threads. Routing all work through one bounded pool per user keeps FD usage at `pool_size + max_overflow` (currently 60) instead of scaling with background-thread count. ### SQLCipher + WAL handle leak workaround SQLCipher in WAL mode leaks file handles when pooled connections close out of open-order (a known issue with WAL-mode SQLite engines under connection pooling). The cleanup scheduler in `connection_cleanup.py` calls `engine.dispose()` on all per-user engines every 30 minutes, closing all idle pooled connections and resetting handle state. This is a workaround, not a root fix — it limits accumulation to a 30-minute window. ### Current pool sizing ``` pool_size = 20 max_overflow = 40 pool_timeout = 10 # seconds; fail fast rather than queue pool_recycle = 3600 # seconds; recycle stale connections pool_pre_ping = True ``` Peak FD usage per user: `(20 + 40) × 2 + 1 = 121` (WAL mode). ## Decision Use a single shared QueuePool engine per user for all threads (request handlers and background workers). Do not maintain per-thread engines. Periodic `dispose()` mitigates the SQLCipher+WAL handle leak. ## Consequences - FD usage is bounded and predictable. - `pool_timeout=10` makes pool exhaustion a loud error rather than a silent deadlock. - The `ParallelConstrainedStrategy` (`max_workers=100`) could theoretically spike past 60 simultaneous checkouts. Sessions are short-lived (millisecond metric writes), so sustained contention is unlikely. Flagged as a known follow-up. ## Addendum — PR #3487 investigation (2026-04-16) PR #3487 proposed skipping the 30-min `engine.dispose()` for any engine with `pool.checkedout() > 0`, citing a claim that dispose orphans checked-out connections and causes torn writes in the post-login bulk settings import. Investigation found: - **SA 2.0 source disagrees.** `QueuePool.dispose` only drains idle queue entries (`_pool.get(False)`); `Engine.dispose` replaces the pool via `pool.recreate()`. SA docs: *"Connections that are still checked out will not be closed."* A thread holding a checked-out connection keeps using it until return. - **Real root cause was elsewhere.** The sticky-loop symptom came from the post-login path committing twice (`load_from_defaults_file(commit=True)` then `update_db_version()` with its own commit), which left `app.version` unwritten on any inter-commit failure. Fix: one session, one terminal commit, both calls use `commit=False`. See `web/auth/routes.py` ATOMICITY INVARIANT comment. - **Regression guard:** `tests/database/test_post_login_settings_atomicity.py` locks in both properties — the atomic all-or-nothing write and the SA 2.0 checked-out-survives-dispose contract — so neither can silently regress. Future refactors of the cleanup cycle should preserve the checked-out-survival property; do not add a `checkedout()` skip guard without a real reproducer.