Make DB writes rollback-safe and self-heal poisoned connections#168
Merged
Conversation
A write task abandoned mid-transaction leaves the shared aiosqlite
connection pinned to a stale WAL snapshot; after any external commit,
every write fails instantly with SQLITE_BUSY_SNAPSHOT ('database is
locked', busy_timeout not consulted) until ROLLBACK - which nothing
ever called. Wedged a production daemon for 10h on 2026-07-06.
- _atomic(): commit on success, rollback on any error incl. cancellation
- _write(): single-statement writes serialized under the write lock
- _heal_leaked_txn(): recover from leaked transactions on any write
- migrate all 52 unlocked execute+commit sites; fix bare commits in
checkpoint()/vacuum()
- regression tests incl. the poisoned-connection reproduction
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Incident
On 2026-07-06 04:04 UTC a production daemon's write path wedged for 10 hours: every
nerve.dbwrite failed instantly withdatabase is locked(43K+ errors) while reads kept working, no file lock was actually held, andbusy_timeout=10000never engaged. Only a daemon restart recovered it.Root cause
The shared aiosqlite connection was left inside an abandoned open transaction (a write task errored or was cancelled between the implicit
BEGINandcommit()). That pinned a WAL read snapshot. The moment a second connection (CLI / helper script / backup) committed, the snapshot went stale and every write-upgrade on the shared connection returnedSQLITE_BUSY_SNAPSHOT— reported asdatabase is locked, instant (the busy handler is deliberately not invoked for snapshot conflicts), and permanent untilROLLBACK. Nothing in the codebase calledrollback(), so the state could never self-clear. Reads silently served the frozen snapshot the whole time.Pre-existing related hazards (same missing-rollback root):
_atomic()half-commits on error (partial writes flushed by the next commit), and ~50 single-statement writers committed outside_write_lock, able to prematurely flush someone else's in-flight transaction.Fix
_atomic()is now a real transaction: commit on success, rollback on any exception includingasyncio.CancelledError; commit/rollback are cancellation-shielded so they always run to completion._write(sql, params)— new helper for single-statement writes with the same guarantees, serialized under_write_lock. Returns aWriteResult(lastrowid, rowcount)._heal_leaked_txn()— belt-and-suspenders guard on every write entry: if the connection is unexpectedlyin_transaction(any future code path that leaks), roll back loudly instead of wedging forever. This alone converts a repeat of the incident into a one-line error log.execute()+commit()sites across the 16 store mixins migrated to the helpers;checkpoint()/vacuum()no longer issue bare unlocked commits; the startup FTS reseed runs inside_atomic().update_session_metadatano longer nests a locked call inside its transaction (extracted a shared SQL builder).Tests
tests/test_db_atomicity.py(11 tests): full incident reproduction (leaked txn + external commit → write self-heals; raw path shown to fail without the guard), rollback on body error / failed statement / mid-transaction cancellation, no-premature-commit serialization regression, 50-writer concurrency smoke, andWriteResultplumbing. One existing test adjusted — its fixture relied on the old half-commit behavior (raw uncommitted execute flushed by the next method's commit).Full suite: 1400 passed, 2 skipped.