Skip to content

fix(state): bound the state.db repair loop and stop 89GB dead-backup accumulation - #86867

Merged
teknium1 merged 1 commit into
mainfrom
fix/state-db-repair-loop-cap
Aug 15, 2026
Merged

teknium1 merged 1 commit into
mainfrom
fix/state-db-repair-loop-cap

Conversation

@teknium1

Copy link
Copy Markdown
Collaborator

Summary

Fixes #86747 — the state.db repair loop ran forever on unrepairable corruption, accumulating 89 GB of identical dead forensic backups (105 failed attempts over 11 days in @jermynyee's install).

Root cause

_claim_repair_attempt is an in-memory set — it bounds the repair to one attempt per process, but every restart got a fresh set. B-tree page corruption is beyond all of repair_state_db_schema's strategies (FTS rebuild, sqlite_master dedupe, FTS drop+VACUUM), so each restart re-ran the full surgery and took a fresh ~900 MB state.db.malformed-backup-* copy of the same damaged bytes.

What this PR does (the reporter's P0 + P1-terminal-error, sidecar-file based, no schema changes)

  1. Persistent attempt ledger (<db>.repair-attempts.json): after 3 failed repair passes against the same file fingerprint (size + mtime_ns), repair_state_db_schema refuses with a terminal, actionable error — restore a backup, salvage via sqlite3 state.db ".recover", or delete the ledger to force a retry — instead of re-running surgery. A successful repair clears the ledger; a replaced/restored file re-keys it and gets a fresh budget. Missing or corrupt ledger fails open, so a first repair is never blocked.
  2. Backup dedupe: _backup_db_file reuses the newest existing forensic backup when it's identical to the damaged file (size + mtime match, preserved by copy2) — the single highest-impact change per the report (stops ~90% of the waste).
  3. Retention cap: only the 3 newest malformed-backup copies (incl. -wal/-shm sidecars) are kept; older ones are pruned. Also fixes a same-second timestamp collision that could silently overwrite an earlier forensic copy.

Not included (left for follow-up discussion): flipping updates.pre_update_backup default to true (the reporter's P2 — a behavior/default change worth its own review) and a VACUUM INTO last-ditch strategy.

Tests (sabotage-run verified: suite fails against origin/main, green with the fix)

Ledger accumulation, terminal refusal (surgery provably not called, no new backup taken), budget reset on file change, success-clears-ledger, corrupt-ledger tolerance, backup dedupe, distinct-state backups, retention prune incl. sidecars, and the end-to-end invariant that N failed repairs on the same file leave exactly one backup. Existing tests/test_state_db_malformed_repair.py and tests/test_state_db_notadb_selfheal.py stay green.

Credit: excellent forensic report with exact line numbers and a prioritized fix list by @jermynyee.

Fixes #86747

Infographic

Repair loop: bounded

…ulation (#86747)

A corruption class the repair strategies cannot heal (b-tree page
damage) failed repair_state_db_schema on every process start, forever:
_claim_repair_attempt's in-memory set only bounds one process, so each
restart re-ran the full surgery AND took a fresh ~900MB forensic backup
of the same damaged bytes — 105 attempts / 89GB of dead
state.db.malformed-backup-* files over 11 days in the reporting install.

Three bounded behaviors, all sidecar-file based (no schema changes):

1. Persistent attempt ledger (<db>.repair-attempts.json): after 3 failed
   repair passes against the same file fingerprint (size + mtime_ns),
   repair_state_db_schema refuses with a terminal, actionable error
   (restore a backup / `sqlite3 state.db ".recover"` / delete the ledger
   to force a retry) instead of re-running surgery. Success clears the
   ledger; a replaced or restored file re-keys it and gets fresh
   attempts. Missing/corrupt ledger fails open (never blocks a first
   repair).

2. Backup dedupe: _backup_db_file reuses the newest existing forensic
   backup when it is byte-identical to the damaged file (size+mtime
   match, preserved by copy2) instead of copying another ~900MB.

3. Retention cap: only the 3 newest malformed-backup copies (plus
   sidecars) are kept; older ones are pruned after each new backup.
   Also fixes a same-second timestamp collision that silently
   overwrote an earlier forensic copy.

Tests cover ledger accumulation, terminal refusal (surgery not called,
no new backup), budget reset on file change, success-clears-ledger,
corrupt-ledger tolerance, dedupe, distinct-state backups, retention
prune incl. sidecars, and the end-to-end one-backup invariant.

Fixes #86747
@github-actions

github-actions Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

૮ >ﻌ< ა ci review

ran on bb88a4c — fix(state): bound the state.db repair loop and stop dead-bac

⚠️ Warnings

OSV vulnerability scan · View job

5 known vulnerabilities found in pinned dependencies.

How to fix:

Review the findings in the Security tab. Update the affected dependencies if a patched version is available.


debug info

CI timings

CI timings · View report · View job

Wall time 5m29s vs 8m28s (-35.2%). 11 job(s) slower, 11 faster, 2 unchanged.

  • Python tests / Run tests slice 1/12: +28.0s
  • Python tests / Run tests slice 4/12: -23.0s
  • Python tests / Run tests slice 7/12: -20.0s
  • Python tests / Run tests slice 6/12: -18.0s
  • Python tests / Run tests slice 12/12: -15.0s

@teknium1
teknium1 merged commit 0d00ebe into main Aug 15, 2026
45 checks passed
@teknium1
teknium1 deleted the fix/state-db-repair-loop-cap branch August 15, 2026 09:23
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.

state.db corruption cascade: repair path loops forever, accumulating 89GB of dead backups

1 participant