Files
learningcircuit--local-deep…/docs/decisions/0004-nullpool-for-sqlcipher.md
T
wehub-resource-sync 7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

4.2 KiB
Raw Blame History

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 2030 queries per page load, NullPool (new connection per checkout) adds a noticeable 46 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.