Heal stale WAL generation in task_store reads (#889) - #956
Merged
Conversation
A long-lived thread-local sqlite handle can end up bound to a WAL generation that was checkpointed away underneath it. Every statement on that handle then raises "database disk image is malformed" even though the file is intact — integrity_check passes from a separate process. GET /tasks 500s and get_next_task() fails until a restart, and the restart both masks the cause and eats queued scheduled work. Route the SELECT paths through _read/_read_one: on DatabaseError, drop the thread-local handle, reopen, and retry exactly once. Reads are safe to retry — the error fires before any row is produced. Write paths are deliberately left alone: a blind retry could re-apply a statement that had partially landed before the handle went bad. Persistent breakage still propagates (bounded to one retry, no loop), so genuine corruption surfaces instead of being papered over, and each heal is logged. Found and patched by angel (TOD fleet) after Onesie hit it twice in one night. Same family as #889 / the deleted-WAL split-brain incident. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| if connection is not None: | ||
| try: | ||
| connection.close() | ||
| except sqlite3.Error: |
olegbrok
marked this pull request as ready for review
July 31, 2026 20:24
A read failure during an active transaction must propagate without closing the cached connection: reset would silently roll back pending invariant-preserving writes before retrying the read. Cover start_sprint's nested-read boundary and preserve the existing bounded heal outside transactions. Original stale-WAL recovery by angel; transaction guard follow-up by Kuzya.
olegbrok
pushed a commit
that referenced
this pull request
Aug 1, 2026
…ections (#942) SQLite's 5s default lock wait is too short for the write queue under concurrent FastAPI worker threads, surfacing as spurious 'database is locked'. Raises the wait to 30s on every thread-local store connection (14 factories across 13 files), set at creation before the handle is cached. Verified: complete coverage of every _thread_local store; composes cleanly with #956 — _reset_connection reopens through the same factory so healed handles retain the setting; merged-state tests pass against current main. Scope: thread-local stores only. Shared-connection stores keep the 5s default (pending batch migration). Follow-up filed for pragma ordering during connection setup. Reviewed by Murzik on exact head 29f6b69.
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.
The bug
TaskStorekeeps a long-lived thread-local sqlite handle. That handle can end up bound to a WAL generation that was checkpointed away underneath it. Once that happens, every statement on that handle raisesdatabase disk image is malformedeven though the database file is intact —integrity_checkpasses from a separate process.Live impact on the TOD fleet:
GET /tasks500s andget_next_task()fails until the daemon is restarted. The restart both masks the cause and eats queued scheduled work, so the failure looks transient and its evidence disappears. Onesie hit it twice in one night.Same family as #889 and the earlier deleted-WAL split-brain incident.
The fix
Route the SELECT paths through
_read/_read_one. Onsqlite3.DatabaseError: drop the thread-local handle, reopen, retry exactly once.Why reads are safe to retry: the error fires before any row is produced, so a retry cannot double-apply anything.
Write paths are deliberately untouched. A blind retry could re-apply a statement that had partially landed before the handle went bad. Hardening writes is a separate, deliberate pass — verified in this diff: zero INSERT/UPDATE/DELETE call sites were rerouted.
Failure behavior is preserved, not papered over:
task_store: read hit stale handle (...); reopening connection, retry 1/1), so self-healing doesn't hide the incident rate — this class of fix is only acceptable if it stays loud.Provenance and verification
Found, diagnosed, and patched by angel (TOD fleet) after Onesie routed the incident to it. Angel could not run
pytestorruffon that box (neither is in its venv) and validated via a standalone harness instead — so it explicitly did not claim CI-grade verification, and did not push or open this PR itself.Verified by me on the Mini against current
main(efbb09d):main(angel checked it against1bbe3d3; two merges have landed since).ruff check— clean.pytest tests/test_task_store.py— 48 passed.malformedonce, provelist()andget()return correct data afterward, assert the poisoned handle was actually replaced, and — for the bound — force the reopen to also yield a broken handle so the error must propagate. That last one is the test that makes the "no infinite retry" claim real.Review notes
The judgment call worth a second opinion:
sqlite3.DatabaseErroris broad — it also coversOperationalError(e.g.database is locked). Such an error would trigger a drop-and-reopen that cannot clear a lock, then one retry, then propagate. Wasteful but not harmful, and the alternative (matching on the message string) is more brittle. Flagging it rather than changing it.Closes the read-path half of #889.
🤖 Opened by Barsik