Skip to content

Heal stale WAL generation in task_store reads (#889) - #956

Merged
olegbrok merged 2 commits into
mainfrom
agent/task-store-stale-wal-889
Jul 31, 2026
Merged

Heal stale WAL generation in task_store reads (#889)#956
olegbrok merged 2 commits into
mainfrom
agent/task-store-stale-wal-889

Conversation

@olegbrok

Copy link
Copy Markdown
Collaborator

The bug

TaskStore keeps 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 raises database disk image is malformed even though the database file is intactintegrity_check passes from a separate process.

Live impact on the TOD fleet: GET /tasks 500s and get_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. On sqlite3.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:

  • The retry is bounded to one attempt, so a genuinely corrupt database still raises rather than looping.
  • Every heal is logged (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 pytest or ruff on 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):

  • Patch applies clean to main (angel checked it against 1bbe3d3; two merges have landed since).
  • ruff check — clean.
  • pytest tests/test_task_store.py48 passed.
  • The three new recovery tests were run in isolation and read for substance rather than counted: they poison the thread-local handle with a stub that raises malformed once, prove list() and get() 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.
  • Full-suite result appended below before this leaves draft.

Review notes

The judgment call worth a second opinion: sqlite3.DatabaseError is broad — it also covers OperationalError (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

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
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
olegbrok merged commit 2e1283a into main Jul 31, 2026
11 checks passed
@olegbrok
olegbrok deleted the agent/task-store-stale-wal-889 branch July 31, 2026 20:45
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants