Skip to content

Recover databases with torn region-count header fields#1293

Merged
cberner merged 1 commit into
masterfrom
claude/torn-header-write-corruption-t3xgeq
Jul 5, 2026
Merged

Recover databases with torn region-count header fields#1293
cberner merged 1 commit into
masterfrom
claude/torn-header-write-corruption-t3xgeq

Conversation

@cberner

@cberner cberner commented Jul 5, 2026

Copy link
Copy Markdown
Owner

Problem

full_regions and trailing_partial_region_pages in the database header record how the file is divided into regions. They are rewritten on every commit that resizes the file and are covered by no checksum, so a crash midway through a resize can tear them (partially update one field) even while both commit slots stay valid. The layout they describe is fully recoverable from the file length — which is why design.md documents these fields as valid only when the database does not need recovery, and otherwise recalculated from the file length.

Two checks rejected such a database as Corrupted before recovery could run, making it permanently unopenable despite intact, recoverable data:

  • from_bytes() treated a zeroed pair (num_regions == 0) as an invalid region count.
  • finalize() treated a trailing count torn above the true file length as a truncation below the stored layout ("File truncated below stored layout" — truncation happens after the header flush, so the stored count can exceed the actual file page count).

Concretely: a shrink {full=1, trailing=0} -> {0, N} tearing to {0, 0} hit the first check; a trailing-page shrink like 0x3E8 -> 0x1F4 tearing to 0x3F4 hit the second. In both cases recovery_required was set and both commit slots verified.

Fix

When recovery is required, distrust these two fields and rebuild the layout from the file length using only the immutable region geometry (page size and region sizes), which is fixed at creation and so cannot tear:

  • from_bytes() validates the immutable geometry unconditionally, but validates the region counts only for a cleanly shut down database (!recovery_required).
  • finalize() recomputes the layout from the file length when recovery is required, never deriving stored_len from the possibly-torn counts. The clean return value is derived by comparing the recomputed counts to the stored ones, preserving the exact behavior check_integrity() relies on. The clean-shutdown path (including external truncation/extension rejection) is unchanged.
  • The recalculation is extracted into a layout_from_file_len helper with min/max/boundary guards, so an adversarial or externally-mangled length returns Corrupted rather than panicking.

Regression status

This is a pre-existing bug, not a regression introduced this cycle. Against the released v4.1.0, the same torn header instead panicked on open (an assert!(raw_file_len >= header.layout().len()) failure / a num_regions() - 1 underflow); the header-hardening work in the 4.2.0 cycle converted that panic into a terminal Corrupted error but still failed to recover.

Testing

  • New test torn_layout_fields_recover_from_file_length reproduces both tear patterns ({0,0} and an inflated trailing count) with the commit slots left intact, and asserts the database opens and preserves the committed data. Verified it fails against the old code and passes with the fix.
  • cargo fmt --check, cargo clippy --all-targets --all-features (--deny warnings), and the full cargo test --all-features suite pass.

Generated by Claude Code

@cberner cberner force-pushed the claude/torn-header-write-corruption-t3xgeq branch from 9200acc to edf36bc Compare July 5, 2026 18:04
full_regions and trailing_partial_region_pages record how the file is
divided into regions. They are rewritten on every commit that resizes the
file and are covered by no checksum, so a crash midway through a resize
can tear them -- partially updating one -- even while both commit slots
stay valid. The layout they describe is fully recoverable from the file
length, which is why the design documents them as valid only when the
database does not need recovery, and otherwise recalculated from the file
length.

Two checks rejected such a database as Corrupted before recovery could
run: from_bytes() treated a zeroed pair (num_regions == 0) as an invalid
region count, and finalize() treated a trailing count torn above the true
file length as a truncation below the stored layout. In both cases
recovery was required and the committed data was intact, yet every
subsequent open returned a terminal error.

When recovery is required, distrust these two fields and rebuild the
layout from the file length using only the immutable region geometry
(page size and region sizes), which is fixed at creation and so cannot
tear. The mutable counts are validated only for a cleanly shut down
database, where they are trusted; such a file whose length no longer
matches its stored layout (external truncation or extension) is still
rejected as before.

The torn-header case has existed since at least 4.1.0, where it panicked
on open rather than returning a corruption error.

Assisted-by: Claude Code
https://claude.ai/code/session_01EkvNuVjLL1MJGyEqZAv6t6
@cberner cberner force-pushed the claude/torn-header-write-corruption-t3xgeq branch from edf36bc to 8f0f4ce Compare July 5, 2026 18:05
@codecov

codecov Bot commented Jul 5, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.90722% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.21%. Comparing base (df0c318) to head (8f0f4ce).

Files with missing lines Patch % Lines
src/tree_store/page_store/header.rs 96.90% 3 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #1293      +/-   ##
==========================================
+ Coverage   90.20%   90.21%   +0.01%     
==========================================
  Files          36       36              
  Lines       16226    16289      +63     
==========================================
+ Hits        14636    14695      +59     
- Misses       1590     1594       +4     
Files with missing lines Coverage Δ
src/tree_store/page_store/header.rs 96.06% <96.90%> (-0.20%) ⬇️

... and 3 files 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 merged commit 88881b8 into master Jul 5, 2026
8 checks passed
@cberner cberner deleted the claude/torn-header-write-corruption-t3xgeq branch July 5, 2026 19:40
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