Skip to content

Preserve torn commit slot bytes during recovery#1290

Merged
cberner merged 1 commit into
masterfrom
claude/header-checksum-laundering-u3g72j
Jul 5, 2026
Merged

Preserve torn commit slot bytes during recovery#1290
cberner merged 1 commit into
masterfrom
claude/header-checksum-laundering-u3g72j

Conversation

@cberner

@cberner cberner commented Jul 5, 2026

Copy link
Copy Markdown
Owner

Problem

A crash mid-fsync of a 1-phase commit legitimately leaves the secondary slot torn (invalid checksum). select_primary_slot() correctly keeps the valid primary — but on reopen with needs_recovery, the header is immediately rewritten via to_bytes(true), and TransactionHeader::to_bytes() always recomputes the slot checksum. This re-serializes the parsed torn secondary with a freshly computed, now-valid checksum, "laundering" it.

The laundered slot then sits on disk for the entire repair (three full tree walks — minutes on a large database). If the process crashes again in that window, the next open sees two valid slots and promotes the laundered one (its transaction id T+1 > T). Tree-checksum verification is the only remaining defense, and torn byte-mixes exist that pass it:

  • old roots + new txn id -> silent rollback of a durably-committed transaction;
  • old user root + new system root -> the new DATA_FREED_TABLE lists pages the old user root still references, so a later commit frees and reuses live pages -> corruption.

Preconditions are narrow (byte-granular tear + double crash), but this is exactly the "poorly behaved media" model docs/design.md commits to defending, and the laundering defeats the checksum design: without it, a torn slot stays invalid forever and none of this is reachable.

This affects the two recovery rewrite paths (the open path and clear_cache_and_reload), and is present in released v4.1.0 (verified against the tagged source — same to_bytes re-checksum + pick_primary_for_repair promotion), so it is not a regression on master.

Fix

Preserve the original on-disk bytes of any slot that fails checksum verification, and write them back verbatim during a header rewrite, so a torn slot keeps its invalid checksum until a genuine commit overwrites it.

  • TransactionHeader gains a corrupt_bytes: Option<[u8; TRANSACTION_SIZE]> field, set by from_bytes() whenever the parsed slot's checksum did not verify.
  • to_bytes() returns those bytes verbatim when present, instead of recomputing a fresh (valid) checksum. This fixes both rewrite sites at the source, and means the newer-secondary promotion in select_primary_slot() can never see a laundered slot.
  • secondary_slot_mut() is replaced by write_secondary_slot(), which clears corrupt_bytes as an atomic part of installing a new commit — so a torn slot's bytes are preserved only until the slot is genuinely overwritten, with no way to take a mutable reference and forget to clear it.

Testing

  • Added recovery_does_not_launder_torn_slot: fabricates a torn, newer secondary slot, opens with the repair aborted (freezing the exact post-rewrite on-disk state), and asserts the torn slot is byte-for-byte unchanged. Confirmed it fails on the pre-fix behavior ("recovery laundered the torn secondary slot") and passes with the fix.
  • cargo fmt --check and cargo clippy --all-targets --all-features clean.
  • Full cargo test --all-features suite passes, including crash_consistency.
  • 90s / ~57k-iteration cargo fuzz run fuzz_redb smoke run on the crash-recovery target — no failures.
  • Added a CHANGELOG.md entry.

🤖 Generated with Claude Code

https://claude.ai/code/session_018uK6KdWZRdM91oRQyiEGF8


Generated by Claude Code

@cberner cberner force-pushed the claude/header-checksum-laundering-u3g72j branch from 4e6cf41 to 3eb7a70 Compare July 5, 2026 01:42
@codecov

codecov Bot commented Jul 5, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.40260% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.18%. Comparing base (f4d86a0) to head (1021153).

Files with missing lines Patch % Lines
src/tree_store/page_store/header.rs 97.26% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #1290      +/-   ##
==========================================
+ Coverage   90.16%   90.18%   +0.01%     
==========================================
  Files          36       36              
  Lines       16161    16227      +66     
==========================================
+ Hits        14572    14634      +62     
- Misses       1589     1593       +4     
Files with missing lines Coverage Δ
src/tree_store/page_store/page_manager.rs 95.29% <100.00%> (-0.03%) ⬇️
src/tree_store/page_store/header.rs 96.26% <97.26%> (+0.08%) ⬆️

... and 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@cberner cberner left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comments are too specific to the fix. Like the write secondary should not mention the issue. The change log should focus only on the user visible issue and its conditions to trigger, not the details

@cberner cberner force-pushed the claude/header-checksum-laundering-u3g72j branch from 3eb7a70 to 9e0ca80 Compare July 5, 2026 02:32

cberner commented Jul 5, 2026

Copy link
Copy Markdown
Owner Author

Addressed both points (force-pushed):

  • Trimmed the code comments to describe invariants rather than this specific fix — write_secondary_slot no longer references the issue, and the corrupt_bytes/to_bytes comments just explain that a slot which failed checksum verification is written back verbatim.
  • Rewrote the CHANGELOG entry to focus on the user-visible symptom (durably committed transactions silently rolled back or corrupted) and the conditions to trigger it (two crashes — one during a commit, one during the subsequent repair; not with two-phase commit), dropping the implementation details.

Generated by Claude Code

@cberner cberner force-pushed the claude/header-checksum-laundering-u3g72j branch from 9e0ca80 to ab1fd84 Compare July 5, 2026 03:26
A crash mid-fsync of a 1-phase commit can leave the secondary slot torn
(invalid checksum). select_primary_slot() correctly keeps the valid
primary, but recovery then rewrote the whole header via to_bytes(), which
re-serialized the parsed secondary with a freshly computed, now-valid
checksum -- "laundering" the torn slot. The laundered slot sat on disk
for the entire repair (three full tree walks). A second crash in that
window left two valid slots, and the next open promoted the laundered
slot (higher transaction id), silently rolling back or mixing durably
committed transactions. This defeats the checksum design, whose whole
point is that a torn slot stays invalid forever.

Preserve the original bytes of any slot that fails checksum verification
and write them back verbatim, so a torn slot keeps its invalid checksum
until a genuine commit overwrites it. Replace secondary_slot_mut() with
write_secondary_slot(), which clears the preserved bytes as part of
installing a new commit.

Assisted-by: Claude Code
Claude-Session: https://claude.ai/code/session_018uK6KdWZRdM91oRQyiEGF8
@cberner cberner force-pushed the claude/header-checksum-laundering-u3g72j branch from ab1fd84 to 1021153 Compare July 5, 2026 03:29
@cberner cberner merged commit 8e04e95 into master Jul 5, 2026
8 checks passed
@cberner cberner deleted the claude/header-checksum-laundering-u3g72j branch July 5, 2026 03:37
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.

1 participant