Skip to content

fix(txnlog): scan recovery framing from the file, not a whole-image buffer - #792

Merged
kriszyp merged 4 commits into
kris/txnlog-committed-position-recoveryfrom
chris/txnlog-recovery-file-scan
Aug 21, 2026
Merged

fix(txnlog): scan recovery framing from the file, not a whole-image buffer#792
kriszyp merged 4 commits into
kris/txnlog-committed-position-recoveryfrom
chris/txnlog-recovery-file-scan

Conversation

@cb1kenobi

Copy link
Copy Markdown
Member

Stacked on #723. Open-time recovery now walks v1 framing with positional header reads instead of allocating a whole-file buffer, and findLastCompleteTransactionEnd() is gone. An I/O failure throws DBException rather than classifying as a torn tail.

For the human reviewer

  1. A read error during active-file recovery fails database open rather than logging and skipping recovery as before. Alternative: catch at TransactionLogStore::load() and degrade. Reversible with one catch. "No" means we can append past a torn tail we never classified.

  2. Positional header walk with a 64 KiB nearby window rather than mapping the file, reading fixed chunks, or reusing MemoryMap. Peak memory is O(1); a change of mind is contained to ScanReader. Mapping was rejected because recovery must run before mappings are handed to readers (Windows truncate).

  3. Validation still slurps a whole image (validateTransactionLogImage) while recovery streams. Alternative: convert validation too. backups.verify() keeps the O(file) profile; reversible now that the callback exists.

  4. scanTransactionLogForRecovery(TransactionLogFile&) is public and takes fileMutex; in-lock callers use scanRecoveryLocked(). Alternative: tests lock and call scanRecoveryLocked() themselves. The mutex is not recursive.

  5. readFullyFromFile retries EINTR unboundedly. Alternative: a bound. A signal storm could spin under fileMutex.

Unresolved leftovers that do not need a ruling: corruption-resync still does a positional read per plausible chain hop (cold path only); the Windows SetFilePointerEx change has no >2GiB test on Windows (open() would map 2GiB); there is no Vitest that an unreadable active log rejects open().

Verification

Native GoogleTest: 125 tests, 122 passed (3 MADV_COLD skipped on macOS), including TypicalPayloadsAmortizesReads, header-only large-payload walks, throw-not-truncate on I/O failure, file/buffer parity, and a POSIX sparse header at 0x80000000. Vitest test/transaction-log.test.ts + test/transaction-log-crash-recovery.test.ts: 82 passed, including SIGKILL crash recovery. Deep review of the recovery-scan diff at 1ada20b2: 0 findings.

Review coverage

Authored by Cursor Grok 4.6. Cross-model review: gpt-5.6-sol (codex) ✓, gemini via agy (default model) ✓, Harper domain adjudication (claude-opus-5) ✓ on 1ada20b2 (the delta at 797e416f pruned domain). cursor-grok disabled (authoring family). cursor-composer disabled (an earlier round refused a diff that changes AGENTS.md). Receipt @ 797e416.

Complexity: complicated

Human-Review-Need: 3 @ 797e416

cb1kenobi and others added 3 commits August 20, 2026 16:39
…uffer

The recovery walk only needs 13-byte entry headers. Slurping payloads was leftover from the validation-oriented scanner. I/O failure now fails open rather than being classified as a torn tail.

Co-authored-by: Cursor <cursoragent@cursor.com>
A 13-byte pread per entry regresses dense-log cold opens; a 64 KiB refill
at every header would pull large payloads. Nearby sequential headers share
a window, and a jump past it reads exactly one header.

Co-authored-by: Cursor <cursoragent@cursor.com>
The first header is a 13-byte read, so a 13-byte nearby slack never
engaged the window for real payload sizes. Treat a gap up to the window
size as sequential so typical entries amortize without slurping megabyte
payloads.

Co-authored-by: Cursor <cursoragent@cursor.com>
@cb1kenobi
cb1kenobi requested a review from kriszyp August 20, 2026 22:00

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request refactors the transaction log recovery mechanism to perform positional reads instead of loading the entire log file into memory. It introduces a ScanReader with a 64 KiB sliding window to optimize sequential header reads while skipping large payloads, and updates the Windows file pointer logic to support files larger than 2 GB. Comprehensive unit tests have been added to verify the new on-demand reading behavior and error handling. There are no review comments, and I have no additional feedback to provide.

The old name implied a whole-file slurp. The helper only fills n bytes
at an offset, retrying short reads.

Co-authored-by: Cursor <cursoragent@cursor.com>
@cb1kenobi
cb1kenobi marked this pull request as ready for review August 21, 2026 04:02

@kriszyp kriszyp left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Cool, I think I had looked at doing this, but glad you are actually carrying it out.
🤖 Reviewed with Codex

RecoveryScan scan = scanTransactionLogForRecovery(buffer.data(), fileSize);
// Publish the complete-transaction boundary from this same scan so the store can
// seed its committed watermark without re-reading the file.
this->lastCompleteTransactionEnd.store(scan.lastCompleteTransactionEnd, std::memory_order_relaxed);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

When an interrupted transaction begins in a newly rotated file, this file has no local flagged boundary, so the whole uncommitted prefix is retained. load() seeds the watermark from an older file, but the next successful commit advances it to nextLogPosition; committed queries then traverse and expose the retained aborted entries before the new transaction. Please repair the complete rotation-spanning tail, or fail/quarantine the store when it cannot be repaired safely. A regression should reopen this scenario, commit one new transaction, and verify that only the previously completed transaction plus the new one are visible.

(src/binding/transaction_log/transaction_log_file.cpp:324 is not part of this PR's diff — anchored to the nearest line this PR's diff can hold)

RecoveryScan scan = scanTransactionLogForRecovery(buffer.data(), fileSize);
// Publish the complete-transaction boundary from this same scan so the store can
// seed its committed watermark without re-reading the file.
this->lastCompleteTransactionEnd.store(scan.lastCompleteTransactionEnd, std::memory_order_relaxed);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

At this point recovery has identified a trailing batch as uncommitted, but an eraseTail() failure only logs and allows the store to open. A later commit advances the monotonic watermark past these known-aborted entries and exposes them as committed. Please propagate repair failure (including truncation durability failure) so the store cannot accept writes until the tail is safely removed, and add an injected-failure test.

(src/binding/transaction_log/transaction_log_file.cpp:349 is not part of this PR's diff — anchored to the nearest line this PR's diff can hold)

// Intact frames after the break are mid-file corruption; truncating would
// discard them. A torn tail has nothing valid behind it.
if (validFramingResumes(source, pos + 1)) {
return scan(RecoveryScan::Kind::MidFileCorruption, pos);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

validFramingResumes() proves that intact frames exist after this break, but returning here leaves lastCompleteTransactionEnd at the last boundary before the corruption. If txn.state is behind, committed transactions after the break remain outside the recovered watermark, so the reader never reaches the CorruptFrameError/resync path intended to preserve them. Please return the resync offset and continue scanning transaction flags after it while retaining the first corruption location; cover a break followed by flagged commits with an older txn.state.

@cb1kenobi

Copy link
Copy Markdown
Member Author

Thanks — dug into all three Codex threads. Each targets behavior that's identical in the base branch (#723, kris/txnlog-committed-position-recovery): this PR only swaps the recovery scan's whole-file buffer for windowed positional reads and makes an I/O failure throw DBException instead of being misread as a torn tail. It changes neither the discard/quarantine policy nor the MidFileCorruption watermark semantics.

  • Rotation-spanning uncommitted tail (transaction_log_file.cpp ~L324) and eraseTail() failure only logs (~L349): unchanged here — you anchored both to lines outside this diff. These are repair-policy calls owned by fix(txnlog): recover the committed watermark from the log tail on load #723's load() seeding (invariant 12).
  • MidFileCorruption leaves the watermark behind the break (transaction_log_recovery.cpp ~L174): the lastCompleteEnd/MidFileCorruption classification is byte-for-byte the base's; the line is only in the diff because the function region shifted. Returning a resync offset and scanning transaction flags past it is a cross-layer change spanning the RecoveryScan contract, TransactionLogStore::load() watermark seeding, and the TS reader's resync (findResyncPosition / CorruptFrameError, untouched here) — beyond this focused I/O refactor.

Suggest tracking all three against #723, where the repair-policy and watermark/resync design lives. #3 is the only one whose code sits in a file this PR edits — happy to take it on as a follow-up in this stack if you'd prefer it here.

Unrelated CI note: Test on Deno (macos-latest) is red on the flaky commit-teardown teardown-race test (Deno is green on ubuntu/windows) — worth a re-run before judging CI.

Generated by Barber AI 🤖

@kriszyp
kriszyp merged commit af5149f into kris/txnlog-committed-position-recovery Aug 21, 2026
23 of 24 checks passed
@kriszyp
kriszyp deleted the chris/txnlog-recovery-file-scan branch August 21, 2026 13:53
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.

2 participants