fix(state): PASSIVE not TRUNCATE for all state.db checkpoints (#45383) - #84277
Open
lkz-de wants to merge 1 commit into
Open
fix(state): PASSIVE not TRUNCATE for all state.db checkpoints (#45383)#84277lkz-de wants to merge 1 commit into
lkz-de wants to merge 1 commit into
Conversation
…search#45383) SessionDB.close() ran `PRAGMA wal_checkpoint(TRUNCATE)`. Every cron run_agent opens and closes its own transient SessionDB, so on a busy fleet this fired a full WAL reset many times an hour, racing the gateway's long-lived writer on a large WAL database and tearing hot B-tree pages -- structurally the same corruption this module's own periodic checkpoint was already switched to PASSIVE to avoid (NousResearch#45383). Only close() and two manual-maintenance paths still used TRUNCATE. Route every checkpoint on the shared state.db through PASSIVE: - close() (hermes_state.py) - pre-VACUUM in vacuum() (hermes_state.py) - post-optimize-storage (hermes_state_search.py) PASSIVE never resets/truncates the WAL and never takes the exclusive checkpoint lock, so it cannot lose a transient closer's race with the live writer. The WAL is instead bounded by `journal_size_limit` and the writer's natural post-checkpoint reset. TRUNCATE belongs only on a sole-opener/quiescent connection (e.g. offline maintenance); this change does not try to detect that -- PASSIVE is the safe default. Diagnosed as the root cause of three state.db B-tree corruptions in 2026-08: damage localized to the hottest-written pages (gateway_routing and the sessions indexes), with whole zero-filled pages still live and off the freelist -- the checkpoint/reset-race signature, not disk or application SQL. Tests: tests/test_wal_checkpoint_strategy.py now asserts PASSIVE at close(), before vacuum(), and after optimize_fts_storage() VACUUM; tests/test_hermes_state.py asserts close() likewise. Focused run: 226 passed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
lkz-de
force-pushed
the
fix/wal-checkpoint-passive-45383
branch
from
August 12, 2026 06:09
6f9f210 to
d19913d
Compare
Contributor
|
Thanks @lkz-de for the superset — our #80346 covered close + pre-VACUUM but missed the post-optimize-storage site, and your production corruption evidence is the decisive argument for PASSIVE-everywhere. Closed #80346 in favor of this one; the full TRUNCATE→PASSIVE conversion + WAL-race protection is exactly what #80255 needed. Happy to help with the merge or any follow-up. |
teknium1
pushed a commit
that referenced
this pull request
Aug 13, 2026
Follow-up to the state.db PASSIVE checkpoint salvage (PR #84277, #45383/#80255/#44795): the kanban dispatcher's periodic explicit checkpoint still used TRUNCATE on the shared kanban.db. The dispatch flock only serializes dispatchers — CLI kanban commands in other processes write to the same board without it, so the TRUNCATE races live writers exactly like the state.db close() path did. Switch it to PASSIVE and bound the -wal file with journal_size_limit=8MiB set at connection init (SQLite trims the file on the writer's natural post-checkpoint reset), since PASSIVE never truncates. tests/hermes_cli/test_kanban_db_repair.py updated to assert PASSIVE and reject TRUNCATE. Remaining TRUNCATE call sites are test fixtures operating on private temp DBs (sole opener), which is the legitimate use.
teknium1
pushed a commit
that referenced
this pull request
Aug 13, 2026
…try transient EIO on journal-mode probe Salvaged remainder of PR #82280 (state.db hardening rollup): - Runtime connection corruption: a sibling process replacing/truncating the backing file breaks the live write connection — every subsequent write raises 'file is not a database' and the gateway wedges permanently (messages pile up in memory). Add a bounded one-shot reconnect on the write path: close the broken connection, reopen the DB file (re-running WAL activation + schema reconciliation), retry the failed write once. - _on_disk_journal_mode: retry transient 'disk i/o error' (virtualized block devices) a few times before returning None, so a one-shot EIO doesn't push callers onto the fail-closed unknown-mode branch. The rollup's write-lock machinery, checkpoint-strategy changes, and repair serialization are intentionally NOT included — superseded by PRs #84277 and #69609, or wrong-direction per the POSIX lock-cancellation findings (#71724 lineage).
Contributor
fix(state): PASSIVE not TRUNCATE for all state.db checkpoints (#45383) The corruption rationale is convincing and the TRUNCATE-everywhere removal is consistent. Observations:
|
19 tasks
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.
fix(state): PASSIVE not TRUNCATE for all state.db checkpoints (#45383)
Problem
SessionDB.close()ranPRAGMA wal_checkpoint(TRUNCATE). Every cronrun_agentopens and closes its own transient
SessionDB, so on a busy fleet TRUNCATE — afull WAL reset — fired many times an hour, racing the gateway's long-lived writer
on a large (multi-GB, ~500k-page) WAL database and tearing hot B-tree pages.
This is structurally the corruption this module's own periodic checkpoint was
already switched to PASSIVE to avoid (issue #45383 — see the
_try_wal_checkpointdocstring). But
close()and two manual-maintenance paths kept using TRUNCATE.Evidence
Diagnosed as the root cause of three
state.dbB-tree corruptions in Aug 2026:gateway_routingand thesessionsindexes — across all incidents. Random storage faults would notrepeat the same victim set.
(
secure_delete=OFF, so app deletes never zero pages) — the signature ofzero-filled WAL-frame reads over hot page slots during a checkpoint/reset
race, not disk or application SQL.
impossible for a plain SQL bug, and inconsistent with fsync/host faults (with
no crash, the page cache returns correct data regardless of fsync honesty).
Fix
Route every checkpoint on the shared
state.dbthrough PASSIVE:close()(hermes_state.py)vacuum()(hermes_state.py)optimize-storage(hermes_state_search.py)PASSIVE never resets/truncates the WAL and never takes the exclusive checkpoint
lock, so a transient closer cannot lose a race with the live writer. The WAL is
instead bounded by
journal_size_limitand the writer's naturalpost-checkpoint reset.
TRUNCATE legitimately belongs on a sole-opener/quiescent connection (offline
maintenance); this PR does not try to detect that — PASSIVE is the safe
default. A future refinement could gate TRUNCATE on
has_live_connection().Relationship to existing PRs / issues
conversion for
close()and the pre-VACUUM checkpoint, and closes close() still uses TRUNCATE checkpoint — #45383 recurs on the shutdown path #80255(the open issue tracking those two sites). Credit to that PR for identifying
them first. This PR overlaps on those two sites and additionally converts the
third remaining TRUNCATE on the shared
state.db— the post-optimize-storagecheckpoint inhermes_state_search.py— and adds theproduction corruption evidence above.
_try_wal_checkpoint()to PASSIVE;fix(state): use PASSIVE WAL checkpoint in close() and pre-VACUUM paths (#45383) #72546/fix(state): use PASSIVE WAL checkpoint in close() and pre-VACUUM paths (#45383) #72549 were earlier closed attempts in this area.
preferred, I'm happy to reduce this PR to the
hermes_state_search.pydeltaas a follow-up; if this PR is preferred, it covers all three sites against
current
main.Also addresses #80255.
Companion config (not in this diff)
Because
close()no longer truncates, deployments should bound the WAL: setdatabase.journal_size_limit(e.g. 64 MB). The plumbing already exists inapply_database_pragmas.Testing
uv run --with pytest --with pytest-asyncio python -m pytest tests/test_wal_checkpoint_strategy.py tests/test_hermes_state.py -q -o 'addopts='— 226 passed.