fix(state): evict a poisoned pooled read connection instead of requeuing it - #85255
fix(state): evict a poisoned pooled read connection instead of requeuing it#85255pierrenode wants to merge 1 commit into
Conversation
…ing it
_reconnect_after_notadb() self-heals the single shared WRITE connection
when it starts raising sqlite3.DatabaseError("file is not a database")
— the runtime-corruption signature left when a sibling process
replaces/truncates the backing file out from under a live connection.
It is only ever called from _execute_write's retry loop.
_read_ctx()'s pooled READ connections have no equivalent. Its finally
block unconditionally requeues the checked-out connection regardless
of whether the yielded block raised. If the same corruption hits a
pooled read connection, every subsequent read that draws it from the
pool hits the identical "file is not a database" error forever —
unlike the writer's self._conn, nothing ever reopens a pooled
connection once it starts failing, since the write-side self-heal has
no visibility into (or reach into) the read pool's separate
mode=ro connections.
Scope the fix to the same signature _is_not_a_database_error already
detects: on that specific error, close/discard the connection
(releasing its permit) instead of returning it to the pool, so a
fresh connection opens on the next miss via _get_read_conn(). Any
other exception (a caller's own bad query, etc.) still requeues the
connection exactly as before — a healthy connection must not be
discarded just because the block above it raised.
fix(state): evict a poisoned pooled read connection instead of requeuing it
|
Summary
_reconnect_after_notadb()self-heals the single shared write connection (self._conn) when it starts raisingsqlite3.DatabaseError("file is not a database")— the runtime-corruption signature left when a sibling process (a forked curator agent, an external repair pass) replaces/truncates the backing file out from under a live connection. It's called from exactly one place:_execute_write's retry loop._read_ctx()'s pooled read connections (self._read_pool, opened via_get_read_conn()) have no equivalent. Itsfinallyblock unconditionally requeues the checked-out connection regardless of whether theyield connblock raised:If the same corruption class hits a pooled read connection, every subsequent read that draws that connection from the pool hits the identical
"file is not a database"error, forever — unlike the writer'sself._conn, nothing ever reopens a pooled connection once it starts failing, since the write-side self-heal only touchesself._connand has no visibility into (or reach into) the read pool's separatemode=roconnections. Up to_READ_POOL_MAX(8) read slots can become permanently wedged for the life of the process this way, even after the write path has already self-healed.Fix
Scope the fix to the exact same signature
_is_not_a_database_error()already detects for the write path. On that specific error, close/discard the pooled connection (releasing its descriptor permit via the existing_close_read_conn()) instead of returning it to the pool — a fresh connection opens on the next miss via_get_read_conn(). Any other exception (a caller's own bad query, aDatabaseErrorof a different class) still requeues the connection exactly as before: a healthy connection must not be discarded just because the code using it raised for an unrelated reason.Testing
TestReadPoolEvictsPoisonedConnectiontotests/test_state_db_notadb_selfheal.py(the existing home for this exact self-heal mechanism's tests), mirroring its established real-SessionDB-instance testing style:test_poisoned_connection_is_evicted_not_requeued: checks a connection out of the pool twice (proving it's the same pooled object), simulates the corruption signature on the second use, and verifies the pool is empty afterward and a third read opens a genuinely new connection rather than the poisoned one.test_other_database_errors_still_requeue_normally: proves the eviction is scoped to the specific signature — an unrelatedDatabaseError(e.g. "database disk image is malformed") still requeues a healthy connection.test_poisoned_connection_is_evicted_not_requeuedfails against pre-fix code (assert 1 == 0— the poisoned connection was requeued).tests/test_state_db_notadb_selfheal.py: 11/11 passed (9 pre-existing + 2 new).tests/test_hermes_state.py— 221/222 passed. The one failure (TestFTS5Search::test_search_projection_skips_context_enrichment_queries) is pre-existing and unrelated: verified by reverting the fix and re-running that single test in isolation — it fails identically with or without this change.tests/test_wal_checkpoint_strategy.py,tests/test_state_db_malformed_repair.py,tests/test_state_db_stats.py: 36/36 passed.ruff checkclean on both touched files.