fix(state): reap finished-thread WAL read connections at runtime (#75269) - #75322
fix(state): reap finished-thread WAL read connections at runtime (#75269)#75322Tranquil-Flow wants to merge 1 commit into
Conversation
…sResearch#75269) SessionDB._get_read_conn() creates one read-only WAL connection per worker thread and registers it in the strong _read_conns set. When a short-lived worker exits the strong set keeps its connection reachable, so the .db/.wal/.shm descriptors stay open until SessionDB.close() runs -- which in a long-lived gateway may be days. Descriptor usage grew with the historical worker count rather than current reader concurrency, eventually exhausting RLIMIT_NOFILE (EMFILE). Fix: track the owning thread per connection (_read_conns becomes dict[conn -> Thread]) and reap (close + drop) connections whose owner thread has exited, lazily when a new connection registers. Read connections are now opened with check_same_thread=False so the cross-thread close succeeds, matching the writer and cross-profile read-only connections. This is complementary to NousResearch#74304, which only made close() able to drain worker connections at shutdown; it did not bound lifetime accumulation.
|
Thanks for the focused connection-lifecycle fix. The premise remains present on current main: The PR's owner-aware reap is serialized by the existing Automated hermes-sweeper review. |
|
Independent macOS production confirmation on Environment:
Live descriptor growth after a clean restart:
A separate temp-DB reproduction on the same checkout produced: This matches the approximately two-descriptor-per-finished-reader signature. The live process continued accumulating after an application update and restart; the restart only reset the descriptor count temporarily. The owner-aware runtime reaper plus |
|
Resolved on main by PR #83406 (rebase-merged), which landed a bounded read-connection pool for SessionDB: reads borrow from a LIFO pool (max 8) with a lifetime permit per open connection, bounding PEAK descriptors, and close() drains from any thread. Your diagnosis of the (SessionDB x thread) accumulation was correct — thank you for working on this. The pool approach from #76700 was chosen among the four competing fixes because it bounds peak descriptors rather than reaping after the fact; @Yishova's commits carry the class fix, with credit to all four submitters for converging on the root cause. |
Summary
Fixes #75269.
A long-lived shared
SessionDBretained one read-only SQLite WAL connection for every worker thread that ever used the read path._get_read_conn()cached the connection inthreading.local()but also added it to the strong_read_connsset, which was drained only bySessionDB.close(). In a long-lived gateway,close()may not run for days, so descriptor usage grew with the historical worker count rather than current reader concurrency — eventually exhaustingRLIMIT_NOFILE(EMFILE).Root cause
_read_connswas a strongsetwith no runtime reaping. The unbounded retention was introduced when tracked readers were added inf228e145ba.Fix
_read_connsis nowdict[Connection, Thread]instead ofset._reap_dead_read_conns()closes and drops connections whose owner thread has exited (not thread.is_alive()), invoked lazily when a new connection registers (under the existing_read_conns_lock).check_same_thread=False(matching the writer and cross-profile read-only connections), so the reaper can close a finished worker's connection from a different thread. These are SELECT-only, autocommit, no Python callbacks — safe for serialized SQLite.close()still drains whatever remains;list(dict)yields connection keys and.clear()works on the dict.Why not close-on-every-
_read_ctx?The issue body notes this avoids the leak but adds ~20× overhead for
get_session()and ~10× forsearch_messages()in local benchmarks.Relationship to #74304
#74304 (jmeadlock, OPEN) is complementary: it adds
check_same_thread=Falsefor shutdown-time cross-thread drain. It does not add runtime reaping, so descriptor growth still accumulates beforeclose(). This PR is a superset — it includescheck_same_thread=False(same line) and adds lifetime reaping.Test plan
4 new regression tests in
tests/test_session_db_read_path_split.py:test_finished_read_threads_do_not_accumulate_conns— 40 sequential worker threads, asserts retained < 12 (RED on main: 40 retained; GREEN with fix)test_reaped_connection_is_actually_closed— dead-thread connection closed on reap,executeraisesProgrammingError(RED→GREEN)test_live_thread_connection_not_reaped— a live reader thread's connection survives reap sweeps (guard)test_reaping_is_thread_safe_under_concurrency— 8 concurrent readers + interleaved reaps, no errors (guard)Tests force
_wal_active=Trueto exercise the per-thread read path on runtimes where WAL falls back to DELETE mode; the reaping contract is platform-independent.Auto-published by Moonsong via Path B automated pipeline.