fix(kanban): stop corruption amplification + spurious quarantines in the DB health guard - #41795
fix(kanban): stop corruption amplification + spurious quarantines in the DB health guard#41795jamesraddock wants to merge 11 commits into
Conversation
Code Review: Positive VerificationReviewed: Findings: Well-designed fix for a real corruption amplification bug. The previous one-shot lifetime cache in Design is sound:
Test coverage: No issues found. LGTM. |
|
Pushed two follow-up commits after further production soak (multi-worker dispatch on WSL2):
PR description updated to cover the full stack; 219/219 tests pass. |
|
One more layer found in production soak, pushed as ceeb58a6: under checkpoint contention the r/w probe can also fail with a hard SQLITE_CORRUPT ( |
|
bb99b1f1 — production falsified my previous claim: the read-only arbiter false-positived too (the quarantined copy itself later checked out The discriminator that has held across every observed failure shape is persistence: real corruption is permanent; probe⇄checkpoint contention clears within seconds. The decision window is now multi-second instead of ~300ms: 4 r/w attempts with exponential backoff (~2s), then the arbiter's damage verdict must repeat across 3 ro attempts spaced 1s apart — one clean/undecided read anywhere means transient ( Heads-up for reviewers: the corrupt-file tests now pay the real decision window, so |
|
Pushed two more commits after another production soak on the WSL2 host — these invert the probe design rather than adding a fifth countermeasure:
So the guard now runs ONE read-only probe at TTL expiry with a tri-state verdict:
This subsumes the confirm-N loop and the separate ro arbiter (both deleted) while keeping the TTL re-probe that stops corruption amplification and the IOERR-family classifier (now feeding the undecided verdict). Net: −226/+187 lines, and the corrupt-path tests run faster because there's one persistence window instead of two stacked ones.
Both deployed on the affected host (gateway + web UI restarted). Suite: 222/222. |
|
This is still in testing and may cross over to webui. May be particular to users using WSL |
teknium1
left a comment
There was a problem hiding this comment.
Thanks for the detailed production investigation and the focused regression coverage. The underlying lifetime-cache premise is still present on current main, but this needs a current-main-aware salvage.
Problems
- Current main added an initialized-path fast return at
hermes_cli/kanban_db.py:1722-1737after this branch diverged. It returns before_guard_existing_db_is_healthy(), while this PR's TTL changes only affect the slow initialization path. Therefore post-init connects would still skip the re-probe. - Current main also sets
wal_autocheckpoint=100in that fast path athermes_cli/kanban_db.py:1730. The PR changes only the initialization-path assignment, so the requested 1000-page setting would not apply to normal subsequent connections.
Suggested changes
- Integrate the bounded health check with the current fast path and add an end-to-end
connect()regression covering an initialized database after TTL expiry. - Set and test
wal_autocheckpointon both connection paths.
Automated hermes-sweeper review.
| # checkpointing an already-corrupt file, compounding the damage on every cycle | ||
| # while new/cold connects correctly fail closed. Re-probe on a short TTL so a | ||
| # writer notices post-init corruption within one window instead of never. | ||
| _LAST_HEALTH_OK: dict[str, float] = {} |
There was a problem hiding this comment.
Current main added an _INITIALIZED_PATHS fast return before the guard (hermes_cli/kanban_db.py:1722-1737). This cache is therefore never consulted for ordinary post-init connect() calls after a salvage. Please wire the TTL check into that branch and cover it through connect(), not only a direct guard call.
…e process lifetime The integrity probe in `_guard_existing_db_is_healthy` was skipped for the entire process lifetime once a path entered `_INITIALIZED_PATHS`. That set conflates two different facts: "schema is migrated" (genuinely once per process) and "the DB is healthy" (which can change *after* first connect). Consequence is a corruption *amplification* loop. When the main DB file is torn — e.g. an interrupted WAL checkpoint under WSL2 / abrupt VM stop — a long-lived writer (kanban worker, gateway) that first connected while healthy never re-checks. It keeps opening and checkpointing the damaged file, compounding the damage on every cycle and producing a fresh content-addressed `.corrupt.<token>.bak` each time, while new/cold connects correctly fail closed. Observed in the wild: ~20 distinct quarantine copies (~150 MB) generated in ~10 minutes from a single initial torn write, with the board unusable (every kanban API 409) until the live DB was restored by hand. Fix: track health separately from schema-init, with a short TTL. - `_LAST_HEALTH_OK: dict[str, float]` records the monotonic time of the last "ok" probe; the full probe is skipped only within `_HEALTH_CHECK_TTL_SECONDS` (30s). The cheap header check in `connect()` still runs on every connect. - A successful probe records the stamp; a failed probe evicts it before raising, so subsequent connects keep failing closed. - `_LAST_HEALTH_OK` is cleared alongside the existing `_INITIALIZED_PATHS` evictions (board delete, init_db). Because workers open a fresh connection per operation (`connect_closing`), the next operation after the TTL elapses fails closed instead of writing — capping the blast radius to ~one TTL window rather than "until the process exits". Adds a regression test covering record-on-ok, skip-within-TTL, and re-probe-and-fail-closed-after-TTL. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A single PRAGMA integrity_check failure under concurrent writers on a no-FUA disk (WSL2 virtual disk) can be a transient mid-checkpoint read, not real corruption. The read/write guard probe was treating any one non-ok result as definitive, producing spurious .corrupt.bak storms of a healthy, progressing board (~45 copies in 20 min observed live) and failing worker connects closed for no reason. Require _HEALTH_CONFIRM_ATTEMPTS consecutive non-ok probes (brief backoff between) before quarantining. Real corruption reproduces on every probe; a transient torn read clears on retry. Healthy path still costs one probe. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…not corruption PRAGMA integrity_check racing a concurrent WAL checkpoint reports 'unable to get the page. error code=522' (SQLITE_IOERR_SHORT_READ) — the read failed, the content is not malformed. Under sustained worker load that race outlives the N-probe confirmation loop, so the guard still quarantined a healthy, progressing board (observed live: 3 .corrupt.bak of an 'ok' DB in 90s, every failure line an IOERR). When every confirming probe fails and the report contains only IOERR-family unreadable-page lines (plus the 'never used' noise that follows from unvisited pages), raise sqlite3.OperationalError like the lock/busy case instead of quarantining + failing closed. Any content- damage line, non-IOERR code, or unexpected shape still quarantines. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Under checkpoint contention the read/write integrity probe can fail
with a hard SQLITE_CORRUPT ('database disk image is malformed') on
open/first-read that clears on the very next read — the r/w probe
participates in WAL recovery, so it races concurrent checkpoints in
ways a reader does not. That string is identical to real damage, so it
cannot be pattern-matched as transient (unlike the all-IOERR case).
Observed live: quarantines of an integrity_check=ok board minutes
apart, with the N-probe confirmation loop and IOERR classifier active.
When the confirmation loop exhausts and the failure is not classified
transient-I/O, run PRAGMA integrity_check over a mode=ro connection as
the final arbiter: a read-only view takes snapshot semantics, stays
out of recovery/checkpointing, and (empirically, a day of 5-minute
read-only cron probes plus every manual check) has never
false-positived. ro says ok -> raise OperationalError like the
lock/busy path, no quarantine; ro confirms damage (which real
corruption always does) -> quarantine and fail closed as before.
Lock/busy during the ro probe counts as undecided, never as evidence.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…milliseconds The read-only arbiter also false-positived in production: it samples at the hottest possible moment — immediately after every r/w probe failed — and even a read-only open can transiently report SQLITE_CORRUPT there. The quarantined copy itself later checked out integrity_check=ok, as did the live board (which kept progressing throughout). The discriminator that has held across every observed failure shape is persistence: real corruption is permanent, while probe⇄checkpoint contention clears within a second or two. So stretch the decision window from ~300ms to multi-second: - confirmation loop: 4 attempts with exponential backoff (0.15/0.45/ 1.35s, ~2s total) - read-only arbiter: damage verdict must repeat across 3 attempts spaced 1s apart; a single clean or undecided read anywhere in the window means transient (OperationalError, no quarantine) - transient outcomes now log a warning so contention episodes stay visible without minting quarantine copies Only the suspected-corrupt path ever sleeps; a healthy DB still costs exactly one probe. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…probe The guard's scheduled integrity probe opened the DB read/write so SQLite could replay a hot WAL before checking. Under concurrent writers on a weak-durability FS that probe false-positived four distinct ways (single torn read, persistent IOERR storm, hard SQLITE_CORRUPT at open that cleared on the next read, and even the hottest-moment ro arbiter) — each needing its own countermeasure layered on top of the last. Meanwhile a read-only probe at a *random* moment (which is what a TTL expiry is) never produced a false positive across the same period. Invert the design instead of adding a fifth countermeasure: - The TTL probe is now READ-ONLY (mode=ro): it stays out of WAL recovery/checkpointing, cannot tear anything, and does not contend with writers. The only read/write open left is the real connection. - Tri-state verdict: ok → stamp and return. undecided (lock/busy, a hot WAL a ro connection cannot recover, or an all-IOERR report) → fail OPEN without stamping — no error, no quarantine; the next connect re-probes, and the connection itself surfaces real damage anyway (cell_size_check=ON, write_txn page-count invariant). damage → must persist across every spaced probe in a multi-second window before quarantining: real corruption is permanent, contention clears in seconds. - Callers no longer see spurious OperationalError from suspected-transient episodes; those are logged warnings now. This subsumes the confirm-N read/write loop and the separate read-only arbiter while keeping the IOERR-family classifier (now used to mark a ro report undecided) and the TTL re-probe that stops corruption amplification. The suite gets faster too: the quarantine path pays one ~3s persistence window instead of confirm-loop + arbiter. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Every checkpoint rewrites main-DB pages — exactly the torn-write window on a weak-durability FS (WSL2 virtual disk: write cache enabled, no DPO/FUA), and the contention window integrity probes race against. 100 forced a checkpoint roughly every ~400KB written, i.e. near-constant churn under a dispatcher swarm on an event-heavy board; that churn is what every observed spurious-corruption shape raced against. 1000 (the SQLite default, now asserted explicitly) means ~10x fewer checkpoint events. Durability is unchanged: synchronous=FULL still fsyncs the WAL at commit, so a crash replays from the WAL regardless of when the last checkpoint ran. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…fast path connect()'s _INITIALIZED_PATHS fast path (the NousResearch#36644 no-cross-process-lock optimization) returned before _guard_existing_db_is_healthy() and set wal_autocheckpoint=100. So the TTL health re-probe and the 1000-page checkpoint setting only ever applied to the first-open init path — a long-lived writer (the gateway dispatcher) took the fast path on every tick and thus never re-probed and checkpointed ~10x more often than intended, exactly the amplification the TTL guard and the raise to 1000 were meant to stop. Wire the bounded health check into the fast path: call the read-only, TTL-gated guard before the r/w open (a dict lookup on a cache hit; it takes no cross-process lock, so it does not reintroduce the NousResearch#36644 stall), and set wal_autocheckpoint=1000 there too so both paths match. Adds two end-to-end connect() regressions: the fast path re-probes after TTL expiry (and fails closed on persistent damage), and wal_autocheckpoint is 1000 on both the init and already-initialized paths. Both fail without the change. Addresses maintainer review on NousResearch#41795. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
984f501 to
baaa8fd
Compare
|
Good catch, and thanks for the current-main-aware framing. Pushed a fix and rebased the branch onto current Worth noting the fast-path bypass was actually latent in the branch itself, not only in the merge-with-main view: the Changes
Tests (both fail without the change)
The 7 original commits are unchanged in content (just replayed onto current |
|
@teknium1 — both points are addressed and the branch is rebased onto current |
|
Author note for maintainers — please hold the Two commits here raise What happened in the field. On 2026-07-15, ~6.5h after 1000 first went live fleet-wide — on my heaviest process-churn day on record — the shared kanban board corrupted three times in one afternoon ( What the investigation showed (~120 controlled trials + artifact forensics). The simple causal story does not survive scrutiny in either direction:
What I run now, and propose for this PR: Happy to push the updated commits to this branch — flagging it first so the current diff doesn't merge with the 1000 assertion locked in by the test. |
|
Follow-up: the corruption is now REPRODUCED and the 100-vs-1000 question is settled experimentally. I built a churn harness (~830 real short-lived
So the earlier request stands, now with direct evidence rather than field correlation: the two commits raising this to 1000 (and the test asserting 1000) must not merge. I'll push the 100-both-paths commits to this branch. Independent of the pragma value, the durable fix is to stop running the migration write-transaction on every process start when there is nothing to migrate — that removes the trigger entirely; I'll follow up with that change once it's validated against the same harness. |
…st path Local stopgap, not pushed to PR NousResearch#41795. Raising the fast path to 1000 (d228f239b, deployed 2026-07-15 11:27) removed the long-lived writers' small-checkpoint pacemaker: gateway/webui steady-state connects set the effective WAL ceiling for the whole fleet, and at 1000 pages checkpoints became ~4MB writebacks 10x rarer. On this WSL2 no-FUA vhdx each writeback is the torn-write window: three real index-count corruptions on task_events followed within six hours, on a board that ran a month clean at 100 and at 6x lighter load than its heaviest clean day (2026-07-13, 7634 events). Keep the init path at 1000 and the fast-path health guard as-is; only the steady-state autocheckpoint returns to 100. The read-only-first guard fails open on contention, so small-checkpoint churn no longer mints probe FPs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…on both connect paths - _backup_corrupt_db captures WAL/SHM bytes BEFORE the main-file hash+copy pass (every 2026-07-15 quarantine recorded a 0-byte WAL because sidecars were copied last; the WAL is the primary forensic artifact) - confirmed quarantines append incident context (full integrity report, MemAvailable, / and /mnt/c free space, fd holders + cmdlines) to <board>/quarantine-forensics.jsonl - init path now matches the fast path at wal_autocheckpoint=100 and both folk-theory comments are replaced: the value is a labeled precaution, not a root-cause fix (analysis: ~/kanban-db-investigation/REVIEW.md) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ing to backfill _migrate_add_optional_columns runs on every process's first connect, and its unconditional BEGIN IMMEDIATE was one leg of a reproducible corruption trigger: health probe + fresh rw connection + early write transaction, under process churn at wal_autocheckpoint=1000, corrupts a live board in under a minute (churn harness, ~830 real connect processes per 150s run; see the reproduction matrix posted on PR NousResearch#41795). Probe with a plain SELECT first and only open the write transaction when a legacy running-task row actually needs a task_runs backfill. The re-SELECT inside the transaction is unchanged, so a row appearing between probe and txn is still handled; legacy rows can only pre-exist anyway (new claims always set current_run_id). Validated: the harness arms that corrupted before (all1000, migrateonly) run clean with this change; kanban_db suite 240/240. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Pushed as promised (append-only): |
|
Thanks for carrying those improvements forward and for the credit. I’ll
rebase the TTL re-probe work onto current main and submit it separately as
a focused PR.
…On Tue, Jul 21, 2026 at 4:15 PM Teknium ***@***.***> wrote:
*teknium1* left a comment (NousResearch/hermes-agent#41795)
<#41795 (comment)>
Merged via #68654
<#68654>. The
backup-retention and guard-improvement ideas from your PR informed #68654
<#68654> (retention cap,
narrow auto-REINDEX). The TTL re-probe half didn't survive the rebase onto
the current fast path; if you want to rebase that half onto current main as
a focused PR, it's still welcome.
—
Reply to this email directly, view it on GitHub
<#41795?email_source=notifications&email_token=AAMJY5QMBT4T44V4H4CHNOL5F7FNFA5CNFSNUABFM5UWIORPF5TWS5BNNB2WEL2JONZXKZKDN5WW2ZLOOQXTKMBTHA3DANJWGE22M4TFMFZW63VGMF2XI2DPOKSWK5TFNZ2KYZTPN52GK4S7MNWGSY3L#issuecomment-5038605615>,
or unsubscribe
<https://github.com/notifications/unsubscribe-auth/AAMJY5WIVVZIUQIB6V3O2LT5F7FNFAVCNFSNUABGKJSXA33TNF2G64TZHMYTAMRUGU2TIMRWG45US43TOVSTWNBWGEYDAMJUGM3TJILWAI>
.
Triage notifications, keep track of coding agent tasks and review pull
requests on the go with GitHub Mobile for iOS
<https://github.com/notifications/mobile/ios/AAMJY5RAYTJ5SO7UWLAM5JT5F7FNFA5CNFSNUABFM5UWIORPF5TWS5BNNB2WEL2JONZXKZKDN5WW2ZLOOQXTKMBTHA3DANJWGE22M4TFMFZW63VGMF2XI2DPOKSWK5TFNZ2KUZTPN52GK4S7NFXXG>
and Android
<https://github.com/notifications/mobile/android/AAMJY5WZY3GNNCNAT3CO7535F7FNFA5CNFSNUABFM5UWIORPF5TWS5BNNB2WEL2JONZXKZKDN5WW2ZLOOQXTKMBTHA3DANJWGE22M4TFMFZW63VGMF2XI2DPOKSWK5TFNZ2K4ZTPN52GK4S7MFXGI4TPNFSA>.
Download it today!
You are receiving this because you authored the thread.Message ID:
***@***.***>
|
|
Follow-up root-cause evidence is now tracked in #69784. SQLite has documented a WAL-reset corruption race affecting the 3.50.4 library embedded by Hermes' current uv-managed Python. The documented trigger (multi-process WAL writers plus overlapping checkpoints/WAL reset) and failure shape match this investigation. Using the same
That changes the root-cause priority: the guard/quarantine work here and the recovery work merged through #68654 remain valuable defense-in-depth, but they cannot prevent vulnerable SQLite from creating the damage. The upstream issue asks Hermes to ship/select a fixed SQLite runtime or gate WAL on vulnerable versions. No need to reopen this PR; linking it so the investigation history stays intact. |
What does this PR do?
Hardens the Kanban SQLite health guard against three failure modes we hit in production (WSL2, multi-worker dispatch). The three commits build on each other and ship together because the first one alone is actively harmful (details below).
1.
b4a98f74-equivalent — TTL re-probe (stops the corruption amplification loop)_guard_existing_db_is_healthy()runsPRAGMA integrity_checkon connect, but skipped it for the entire process lifetime once a path was in_INITIALIZED_PATHS. That set conflates "schema is migrated" (genuinely once-per-process) with "the DB is healthy" (can change after first connect). When the file was later torn (e.g. an interrupted WAL checkpoint), a long-lived writer that first connected while healthy never re-checked — it kept checkpointing the damaged file, compounding corruption on every cycle. Observed: one torn write snowballed into ~20 quarantine copies (~150 MB) in ~10 minutes with the board hard-down (HTTP 409) throughout.Fix: decouple health from schema-init; re-probe on a 30s TTL so writers notice post-init corruption within one window.
2. Confirm across N probes before quarantining (stops spurious quarantines)
With the TTL re-probe in place, long-lived processes probe far more often — and a read/write
integrity_checkunder concurrent writers on a weak-durability FS (WSL2 virtual disk, no DPO/FUA) can transiently read a mid-checkpoint page as bad while the DB is fine. Observed: ~45 spurious.corrupt.bakcopies of a healthy, progressing board in 20 minutes. This is why the TTL commit must not ship alone.Fix: require
_HEALTH_CONFIRM_ATTEMPTS = 3consecutive non-ok probes (100 ms backoff) before quarantining. Real corruption reproduces on every probe; a transient clears on retry. The healthy path still costs exactly one probe.3. Classify all-IOERR integrity failures as transient I/O, not corruption
Under sustained load the probe⇄checkpoint race can outlive the whole confirmation loop. The tell: every failure line is
unable to get the page. error code=522(SQLITE_IOERR_SHORT_READ) — the read failed, not the content is malformed. Observed: 3 quarantines of anintegrity_check = okboard in 90 seconds, with the confirm loop active.Fix: when every confirming probe fails and the report contains only IOERR-family lines (
error code & 0xFF == 10, plus thePage N: never usedaccounting noise from unvisited pages), raisesqlite3.OperationalError— same contract as the existing lock/busy path: no quarantine copy, no fail-closed. Any content-damage line (is malformed,missing from index, …), any non-IOERR code, or any unexpected report shape still quarantines and fails closed.Related Issue
Fixes #
Type of Change
Changes Made
hermes_cli/kanban_db.py_LAST_HEALTH_OKTTL cache (30s) replaces lifetime health caching; stamp evicted on detected damage so subsequent connects keep re-probing_run_integrity_probe()extracted;OperationalError(lock/busy) propagates raw as before — never classified as corruption_HEALTH_CONFIRM_ATTEMPTS = 3confirmation loop with backoff before any quarantine_integrity_failure_is_transient_io()— strict classifier for all-IOERR reports →OperationalErrorinstead of quarantinetests/hermes_cli/test_kanban_db.py— 5 regression tests covering: TTL honored then re-probed, lone transient probe failure ignored, persistent corruption still quarantined, persistent SHORT_READ raisesOperationalErrorwith no quarantine, and classifier edge cases (mixed damage+IOERR, non-IOERR codes, refused-open). Full file: 219 passed.Testing
Also soak-tested on the live multi-worker board that originally reproduced all three failure modes: zero spurious quarantines since, real-corruption path verified by injected malformed file.