Skip to content

fix(txnlog): recover the committed watermark from the log tail on load - #723

Open
kriszyp wants to merge 23 commits into
mainfrom
kris/txnlog-committed-position-recovery
Open

fix(txnlog): recover the committed watermark from the log tail on load#723
kriszyp wants to merge 23 commits into
mainfrom
kris/txnlog-committed-position-recovery

Conversation

@kriszyp

@kriszyp kriszyp commented Jul 27, 2026

Copy link
Copy Markdown
Member

Fixes HarperFast/harper#1949 — Crash recovery restores records but not the transaction log's committed watermark, silently stranding them from replication.

The defect

After an unclean exit (crash, OOM kill, container kill), transaction-log entries that are durable on disk are invisible to every committed read — replication's resume replay and read_transaction_log — until some unrelated later commit happens to advance the watermark past the whole stale tail. A peer that resumes replication after the crash never receives writes that were already acknowledged and durable before it.

The two positions involved are easy to conflate, so, precisely:

  • lastCommittedPosition is in-memory only and is never persisted. commitFinished()/commitAborted() advance it to "front of uncommittedTransactionPositions, else nextLogPosition". It is what bounds committed reads (loadLastPosition() in transaction-log-reader.ts).
  • lastFlushedPosition lives in txn.state and is written by databaseFlushed() — the RocksDB OnFlushCompleted callback, so on every memtable flush. It means "the log up to here is already in SSTs", and it bounds replay (startFromLastFlushed) and purge eligibility.

The link is the bug: getLastCommittedPosition() lazily seeded the in-memory watermark from txn.state on first use. A graceful shutdown looks clean only because shutdown() forces a final flush, which writes txn.state at the true end of log — nothing ever persists the committed watermark itself. After a crash, txn.state sits at the last periodic flush, so the watermark is seeded behind the recovered tail and stays there: the consumer's boot replay re-applies the tail with readUncommitted, and those replayed writes deliberately don't re-append to the log (that would prevent replay convergence), so commitFinished() never runs and the watermark never moves.

The fix

load() now seeds the watermark from the last entry that closed a transaction, using the active recovery scan and walking backward through rotated files until it finds a boundary or reaches the txn.state floor. The persisted txn.state flushed position is a safe floor, so recovery never hides entries already absorbed by RocksDB. Older-file probing is bounded, exception-safe, and closes files opened only for the scan. Active-file repair reads txn.state first and refuses to truncate below its same-file flushed offset, so a missing flag cannot erase history RocksDB already absorbed.

A store with no log files still leaves the position at {0, 0} for the existing lazy path to resolve.

Tests

test/transaction-log-crash-recovery.test.ts (+ a SIGKILL fixture). Both fail on main and pass here:

  • reopen after entries that no flush ever recorded: committed read returned 0 of 3
  • real SIGKILL, with a flush partway through: committed read returned 3 of 7 — the four post-flush entries stranded, the same shape as the harper-pro repro in the issue
  • a transaction spanning four rotated files: the old one-predecessor scan seeded 0 of 1 committed entries; the backward scan recovers the prior boundary
  • a flushed, uniform-timestamp tail with missing flags: the old repair shrank the log from 124 to 50 bytes; the flushed floor now preserves all three entries

Also strengthened the two existing tail-recovery tests in transaction-log.test.ts. One carried the comment "the committed position isn't persisted without a RocksDB flush, so read uncommitted to verify the entries survived on disk" — this bug written down as an accepted workaround.

Local: 764 vitest passed / 56 files (2 skipped), 117 native gtests passed, production build and pnpm check passed.

Worth a reviewer's attention

Complete-but-uncommitted tail entries are now visible to committed readers. The log entry is appended before the RocksDB commit, and recoverTail() only strips torn tails, so a crash can leave a structurally complete entry whose data commit never landed — the seed exposes it. Two things make that the right behavior rather than a new hazard:

  1. It is the same exposure commitAborted() already produces at runtime: when a batch was written but the commit failed, TransactionHandle::close() calls it and the watermark advances past those bytes.
  2. The consumer reconciles the tail by replaying it. In Harper, replayLogs() runs synchronously inside readRocksMetaDb() immediately after the database is opened and before the root store is returned, so no committed reader can observe the tail before replay has applied it.

The residual case is a replay that aborts early — Harper bounds replay on a no-progress counter (harper#1266) and a wall-clock limit (harper#1316). There the watermark advertises a tail the database doesn't fully have. Both abort paths already log fatal with "re-clone this node", so this seemed acceptable rather than worth gating the advance on replay completion, but it is a deliberate call and worth a second opinion.

Recovery ends the log on a transaction boundary

Only a batch's final entry carries TRANSACTION_LOG_ENTRY_LAST_FLAG, so a crash partway through a multi-entry transaction leaves whole, well-framed entries that are merely a prefix of it. Left alone, the next transaction's flag closes the phantom group, merging two source transactions into one over replication. The PR closes that two ways, because either alone is incomplete:

  1. The committed watermark is seeded at the last complete transaction, not at nextLogPosition. scanTransactionLogForRecovery() reports that boundary alongside validEnd from the same walk, so open-time recovery gets it without a second pass.
  2. recoverTail() discards the leftover entries (discardUnclosedTransaction), so the file itself ends on a boundary. Seeding alone only holds until the next commit: the bytes stay on disk, the first commitFinished() after boot advances the watermark past them, and the merge happens then — one commit later rather than never.

Discarding is safe because nothing durable depends on those bytes. writeBatch() completes before Transaction::Commit() in every commit path (transaction.cpp:236/286 async, 701/710 sync) and both commit-thread lanes preserve dispatch order, so an interrupted log write is always the newest thing in the log and its RocksDB commit never ran.

The discard is gated, deliberately. The last-entry flag only landed in a69528c1 (2025-12-02); a log written before that has no flagged entries at all, and a naive "truncate to the last flagged entry" would wipe the whole file. So it requires both:

  • a complete-transaction boundary earlier in the same file — the proof that this writer sets the flag; and
  • a single timestamp across the trailing runwriteEntriesV1() stamps every entry of a batch with the batch timestamp while a prior flagged boundary prevents conflating separate transactions even if a caller reuses a timestamp, so more than one timestamp means more than one transaction went unflagged, which a flag-setting writer cannot produce.

Without that proof the bytes are kept and a warning is emitted. That is also what covers a batch writeBatch() split across a rotation (no flagged entry in the active file), which is why (1) is still needed: there the seed walks backward across every unflagged rotation until it finds a boundary or reaches the txn.state floor.

Both platforms physically truncate every torn tail the framing scan can identify. Windows drops its cached mapping before SetEndOfFile, uses the same path for torn and unclosed tails, and resets the timestamp index after either recovery shrink. A durable header followed by a partial payload remains indistinguishable from a valid zero-ending payload on a pre-extended Windows file; detecting that case requires an entry checksum.

Behavior change worth noting: a readUncommitted replay no longer sees the interrupted batch's prefix. That is intentional and arguably a second fix — Harper's replayLogs() currently applies those entries, i.e. writes a partial transaction into the database.

Covered by 13 GoogleTests on the pure scan (framing boundary + the unclosed-tail description that gates the discard) and two end-to-end tests: one truncates a real three-entry transaction to two and asserts the file is back at the boundary, readUncommitted sees 1 entry, and the following transaction's entries parse as flags [1, 1]; the other clears the flag on the last two entries of a three-transaction log (a pre-flag log's shape) and asserts nothing is dropped. Reverting just the source changes fails the first with expected 124 to be 50.

Relationship to rocksdb-js #668

#668 — Data loss: IsBusy commit retry orphans an uncommitted log position, pinning the committed watermark → committed reads truncate is the runtime sibling of this defect: there an orphaned entry in uncommittedTransactionPositions pins lastCommittedPosition as a permanent floor; here a stale txn.state seed sets that floor too low at boot. Same watermark, same silent-truncation symptom, different path — this PR does not fix #668.

Its "Suggested fix → Subtlety" section is worth reading alongside the design question above: it already names the same tension (writeBatch durably writes a transaction's bytes before the commit outcome is known, so advancing the watermark past them exposes bytes whose transaction never committed). Recovery now answers that structurally — the log cannot retain an unclosed transaction — while #668's runtime path can still strand one via an orphaned IsBusy position, and there the pin is permanent rather than cleared by the next commit. Its fix should probably adopt the same boundary rule rather than inventing a second one.

Follow-up

Harper needs a @harperfast/rocksdb-js bump once this releases; crashWindowReplication.test.mjs in harper-pro can then drop its HARPER_TEST_CRASH_WINDOW opt-in and become a real regression guard.


Current-head review at 2a1833cb: a full Gemini + Harper storage-domain review found that destructive tail repair ran before reading txn.state and could erase a committed, flushed tail. That major is fixed and regression-tested. A delta pass confirmed the fix; its remaining Human-Review-Need 4 items are the deliberate log-authoritative recovery decision Kris approved, inferred interrupted-tail deletion, and the documented Windows checksum gap. Cursor refused the earlier AGENTS.md diff and was policy-pruned from the low-risk delta; no Claude leg was used.

Cross-model review coverage at ba0a59c7: full passes by Claude, Gemini, and the Harper storage adjudicator, followed by a Claude + Gemini delta pass. Two review-found majors were fixed; the optional Cursor/Grok leg refused the AGENTS.md diff. Gemini's sole blocker — that lastCommittedPosition is a std::optional and the assignment is UB — did not survive a source check: it is a std::shared_ptr<LogPosition> always constructed via make_shared. Its two test findings (async console.log handshake lost to the SIGKILL; Windows self-kill surfacing as exit code 1) were real and are fixed.

Codex additionally caught that the seed's fullPosition > 0 guard is unsound — that union member aliases the two uint32s as a double, so a logSequenceNumber >= 0x7ff00000 reads back as NaN or negative and would have silently skipped the seed. The repo's own CI review bot independently flagged the same line. Now tested on logSequenceNumber. Its remaining finding was the transaction-boundary issue, now fixed here.

The Windows CI failure this PR initially hit was a genuine latent bug it exposed, not a test artifact: findPositionByTimestamp() checked for the zero-timestamp end-of-data marker before special-casing the header's own timestamp slot, so a log file with a zero header timestamp had its size corrected down below the 13-byte header and read as empty. Only Windows reached it at open (its openFile() indexes to undo memory-map zero-padding), but the indexing path is cross-platform and the added regression test reproduces it everywhere.

Generated with Claude Opus 5.

🤖 Generated with Claude Code

Review-Coverage: authored=unknown; ran=none; rounds=1 @ 027f001

Human-Review-Need: 4 @ 027f001

@kriszyp
kriszyp requested a review from cb1kenobi July 27, 2026 01:07

@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 fixes an issue where transaction log entries written after the last flush were invisible to committed reads after an unclean exit. It seeds the in-memory committed watermark at the recovered write head during store loading and adds comprehensive crash recovery tests. The feedback suggests replacing the fragile fullPosition > 0 check with a safer check on logSequenceNumber > 0 to avoid potential issues with union-based double comparisons.

Comment thread src/binding/transaction_log/transaction_log_store.cpp Outdated
@github-actions

github-actions Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

get-sync.bench.ts

getSync() > random keys - small key size (100 records)

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 lmdb 1 24.40K ops/sec 40.98 39.72 568.131 0.116 122,016
🥈 rocksdb 2 10.86K ops/sec 92.07 89.64 31,602.59 1.25 54,310

getSync() > sequential keys - small key size (100 records)

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 lmdb 1 28.09K ops/sec 35.60 34.31 502.281 0.107 140,462
🥈 rocksdb 2 10.68K ops/sec 93.59 91.40 547.646 0.046 53,424

ranges.bench.ts

getRange() > small range (100 records, 50 range)

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 lmdb 1 25.81K ops/sec 38.74 35.57 1,881.546 0.291 129,059
🥈 rocksdb 2 16.85K ops/sec 59.34 51.92 1,074.133 0.125 84,260

realistic-load.bench.ts

Realistic write load with workers > write variable records with transaction log

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 rocksdb 1 385.87 ops/sec 2,591.565 112.067 45,504.867 15.54 773
🥈 lmdb 2 26.81 ops/sec 37,294.62 418.48 1,187,907.185 136.401 64.00

transaction-log.bench.ts

Transaction log > read 100 iterators while write log with 100 byte records

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 rocksdb 1 38.80K ops/sec 25.77 12.28 13,596.019 0.580 194,011
🥈 lmdb 2 441.96 ops/sec 2,262.662 167.689 12,163.789 1.26 2,210

Transaction log > read one entry from random position from log with 1000 100 byte records

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 rocksdb 1 721.43K ops/sec 1.39 1.20 4,509.577 0.189 3,607,129
🥈 lmdb 2 467.73K ops/sec 2.14 1.08 7,093.257 0.391 2,338,633

worker-put-sync.bench.ts

putSync() > random keys - small key size (100 records, 10 workers)

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 rocksdb 1 837.70 ops/sec 1,193.742 1,031.416 2,842.734 0.414 1,676
🥈 lmdb 2 1.16 ops/sec 862,078.538 821,275.896 923,003.307 2.97 10.00

worker-transaction-log.bench.ts

Transaction log with workers > write log with 100 byte records

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 rocksdb 1 22.62K ops/sec 44.21 29.59 466.157 0.562 45,236
🥈 lmdb 2 824.76 ops/sec 1,212.467 159.206 13,055.638 5.46 1,651

Results from commit 12d0215

kriszyp added a commit that referenced this pull request Jul 27, 2026
findPositionByTimestamp()'s indexing loop checked `entryTimestamp == 0`
before checking whether the current position was the file header's
timestamp slot. A header timestamp of exactly 0 (e.g. an unset/epoch
value, as several test fixtures write) was therefore treated as the
zero-padding end-of-data marker and truncated this->size back into the
header itself -- below TRANSACTION_LOG_FILE_HEADER_SIZE.

This is normally invisible: only TransactionLogFile::openFile() on
Windows proactively calls findPositionByTimestamp() at open time (to
correct for mmap zero-padding), so the corruption only ever surfaced
there. It stayed latent until 13d57f7/f385ace8 started seeding
lastCommittedPosition from the recovered nextLogPosition on load,
which propagated the corrupted size into a value CI asserts on --
failing every Windows runtime (Node 22/24/26, Bun, Deno) on PR #723's
new/pre-existing "should return valid lastCommittedPosition after
purging earlier log files and reopening" case.

Root cause: the header-timestamp branch and the end-of-data check were
ordered so the latter could fire on a position that isn't an entry.
Reordering to always handle the header slot first (as the timestamp
index's designated position-0 entry) restores the invariant that the
end-of-data heuristic only ever applies to real entries.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@kriszyp
kriszyp marked this pull request as ready for review July 27, 2026 02:11
@kriszyp

kriszyp commented Jul 27, 2026

Copy link
Copy Markdown
Member Author

I will note that 24b12b9 significantly expands this PR to more carefully avoid a partial transaction, but if you prefer it to be separated into a different PR, happy to do so.

@kriszyp

kriszyp commented Jul 27, 2026

Copy link
Copy Markdown
Member Author

Pushed 1b0630bb — recovery now discards the leftover entries of a transaction that never closed, instead of only holding the committed watermark short of them.

The reason for the follow-up: seeding the watermark at the last complete transaction is only true until the next commit. The bytes stayed in the file, the first commitFinished() after boot advanced the watermark past them, and the next batch's TRANSACTION_LOG_ENTRY_LAST_FLAG closed the phantom group — the same merge, one commit later. recoverTail() now ends the file on a boundary, which makes the invariant enforceable rather than merely observed.

Dropping the bytes is safe because writeBatch() completes before Transaction::Commit() in every commit path and both commit-thread lanes preserve dispatch order, so an interrupted log write is always the newest thing in the log and its RocksDB commit never ran.

The gating is the part most worth your eye: the last-entry flag only landed in a69528c1 (2025-12-02), so a naive truncate-to-last-flagged-entry would wipe a pre-flag log wholesale. The discard therefore requires a boundary earlier in the same file and a single timestamp across the trailing run; anything else is kept and warned about, with the watermark seed still covering committed readers. Details and the platform split (POSIX truncates, Windows zero-fills) are in the updated description.

One intentional behavior change: a readUncommitted replay no longer sees the interrupted batch's prefix. In Harper that prefix was being applied by replayLogs(), i.e. a partial transaction written into the database, so this looks like a fix in its own right — but flagging it explicitly in case you read it the other way.

660 vitest / 51 files and 110 native gtests locally; the new end-to-end test fails without the source change (expected 124 to be 50).

— Claude Opus 5

Comment thread src/binding/transaction_log/transaction_log_file_windows.cpp Outdated
kriszyp added a commit that referenced this pull request Jul 28, 2026
…rtial failure

Zero the end-of-entries marker at `newSize` with its own write before
the bulk zeroing loop. A reader stops at the first zero timestamp
regardless of what follows, so once that write lands, `newSize` is a
safe end-of-log position even if a later chunk fails partway through
the rest of the range — the caller can lower `size` to `newSize`
unconditionally instead of leaving it at `entriesEnd` while the
on-disk marker already sits earlier, which would put the next append
past where readers stop.

Addresses PR #723 review feedback from cb1kenobi.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@kriszyp

kriszyp commented Jul 28, 2026

Copy link
Copy Markdown
Member Author

Re cb1kenobi's Windows eraseTail partial-failure finding: fixed in 5265344.

eraseTail now writes the 8-byte end-of-entries marker at newSize as its own write before the bulk zeroing loop. A reader stops at the first zero timestamp regardless of what follows it, so once that write lands, newSize is safe to use unconditionally as the new size — even if a later chunk in the bulk zero fails partway through the rest of the range. That closes the gap where size stayed at entriesEnd while the on-disk marker had already moved to newSize, which would have put the next append past where readers stop.

— KrAIs (Claude Sonnet 5)

@kriszyp

kriszyp commented Jul 29, 2026

Copy link
Copy Markdown
Member Author

Fixed the Windows recovery-test failure in 05e2001.

The test now validates the platform-specific recovery contract: POSIX physically truncates to the last complete transaction, while Windows preserves the crash-time file length and zeroes the discarded range. Both paths also assert the parser's logical end is the completed-transaction boundary.

Local verification: pnpm check, focused crash-recovery tests (4/4), full JS suite (660 passed, 2 skipped), and native suite (110 passed). Final outside-model review found no blockers.

Comment thread src/binding/transaction_log/transaction_log_file.cpp Outdated
Comment thread src/binding/transaction_log/transaction_log_file.cpp Outdated
@cb1kenobi

Copy link
Copy Markdown
Member

Follow-up thought: the whole-file-read-then-scan pattern

Really clean recovery work here — the committed-watermark seed walk and the discardUnclosedTransaction reasoning (flag proof + single-timestamp run) are easy to follow, and the union-member logSequenceNumber gotcha comment is a great catch to leave for the next reader.

While reading through it, one efficiency pattern stood out that's worth capturing. It's mostly not blocking this PR — only the first row below is new here — but the new code follows an existing shape that's worth a second look.

Several spots allocate the entire file and read every byte, then hand the buffer to a scan that only ever touches the 13-byte entry headers and skips the bodies (pos += HEADER + length). None of these scans read payload bytes, so the full-file read pulls in all the values just to jump over them:

Site Reads Header-only scan it feeds In this PR?
scanForLastCompleteTransactionEnd whole file → std::vector<char> findLastCompleteTransactionEndscanTransactionLogForRecovery new here
recoverTail whole file → std::vector<char> scanTransactionLogForRecovery read pre-existing; fn modified here
countEntries whole file → std::vector<char> countTransactionLogEntries pre-existing
validateTransactionLogImage / …Store whole file → new char[] scanTransactionLogForRecovery + its own header walk pre-existing (separate file)

Suggestion

Swap the byte source, keep the framing logic byte-identical: a bounded reader that preads a 13-byte header, advances pos += 13 + length, and refills a ~64 KB window as needed — bounded by this->size. That gives O(buffer) memory instead of O(fileSize), and on logs with large blob values it skips faulting in the body pages entirely.

Since the scanners already take (const char* data, uint32_t fileSize), the cleanest shape is a small reader abstraction (or the fd) threaded in behind that interface — i.e. "pass the fd instead of the buffer." For the recovery sites this is low-friction because TransactionLogFile already owns an open fd and readFromFile is already a pread on it.

Wrinkles worth knowing before committing to it

  • The const char* interface is load-bearing for the native tests. The GoogleTests call these scanners ~30 times with in-memory LogImage buffers, so a reader/fd signature change ripples through them — you'd likely want a buffer-backed reader shim so the pure functions stay testable without a real file on disk.
  • validateTransactionLogImage isn't a pure forward pass. Its all-zero-tail check reads [validEnd, fileSize) after the framing walk, and it walks the headers a second time for per-entry anomalies. A pread/seek-capable reader handles both fine; a pure sequential stream would need to buffer or re-seek, and you'd probably want to fuse the two walks.
  • this->size is trustworthy as the bound at the recovery sites, which is what makes dropping the bytesRead != fileSize full-read check safe: both scans run single-threaded at load before the store is published (no concurrent appends), and on Windows openFile() corrects the pre-extended/zero-padded size down before recoverTail() runs. A short header read or a length past this->size is exactly the torn-tail case the scanner already handles.

Priority

Genuinely low for the recovery sites — they're cold, once-at-open, bounded by rotation size. The strongest case is validateTransactionLogStore, which reads every .txnlog in the directory on each verify-logs / backups.verify() call — but that's pre-existing and in a separate file, so it's best as its own PR.

Net: the whole-file read in the new scanForLastCompleteTransactionEnd (inline comment above) is the one worth a second look for this PR; the rest is an optional cleanup PR. Happy to prototype the bounded-reader helper if it'd be useful.

— Generated by Claude Opus 4.8

kriszyp and others added 10 commits August 10, 2026 17:35
The transaction log's committed watermark (lastCommittedPosition) is in-memory
state advanced by commitFinished(); it is never persisted. It was seeded on
load from txn.state -- the *flushed* position, i.e. how far RocksDB has already
absorbed the log -- which after an unclean exit sits behind the log's recovered
tail. Every committed read is bounded by that watermark, so entries that were
durable on disk (and that a consumer's boot replay re-applies via
readUncommitted) stayed invisible to committed readers until an unrelated later
commit advanced the watermark past the whole tail at once.

load() already recovers the true end: recoverTail() truncates any torn tail,
nextLogPosition is set to the last structurally valid entry, and it is inserted
as the write-head sentinel. commitFinished() defines the watermark as the front
of uncommittedTransactionPositions, which in a freshly loaded store is exactly
nextLogPosition -- so seeding it there only makes load() agree with the rule the
store already enforces.

Fixes HarperFast/harper#1949

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Cross-model review findings: the fixture's `ready` handshake went through an
async console.log to a pipe, which the following SIGKILL could drop; a self-kill
on Windows surfaces as exit code 1, not a signal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
findPositionByTimestamp()'s indexing loop checked `entryTimestamp == 0`
before checking whether the current position was the file header's
timestamp slot. A header timestamp of exactly 0 (e.g. an unset/epoch
value, as several test fixtures write) was therefore treated as the
zero-padding end-of-data marker and truncated this->size back into the
header itself -- below TRANSACTION_LOG_FILE_HEADER_SIZE.

This is normally invisible: only TransactionLogFile::openFile() on
Windows proactively calls findPositionByTimestamp() at open time (to
correct for mmap zero-padding), so the corruption only ever surfaced
there. It stayed latent until 13d57f7/f385ace8 started seeding
lastCommittedPosition from the recovered nextLogPosition on load,
which propagated the corrupted size into a value CI asserts on --
failing every Windows runtime (Node 22/24/26, Bun, Deno) on PR #723's
new/pre-existing "should return valid lastCommittedPosition after
purging earlier log files and reopening" case.

Root cause: the header-timestamp branch and the end-of-data check were
ordered so the latter could fire on a position that isn't an entry.
Reordering to always handle the header slot first (as the timestamp
index's designated position-0 entry) restores the invariant that the
end-of-data heuristic only ever applies to real entries.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds the regression test for b10e725. The indexing path is cross-platform
even though only Windows openFile() reaches it at open, so the test drives it
directly through _findPosition and fails without that fix on any platform.

Also tests the committed-watermark seed on logSequenceNumber rather than
fullPosition: that union member aliases the two uint32s as a double, so a
sequence number >= 0x7ff00000 reads back as NaN or negative and `> 0` would
silently skip the seed, falling back to the stale txn.state position this PR
exists to fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Windows has no real signals, so Node maps a self-kill to TerminateProcess(h, 1).
Accepting code 1 on every platform would let a POSIX run whose kill() threw
after the `ready` handshake pass with normal addon teardown, which is the very
thing the fixture must rule out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ction

Only a batch's final entry carries TRANSACTION_LOG_ENTRY_LAST_FLAG, so a crash
partway through a multi-entry transaction leaves whole, well-framed entries that
are merely a prefix of it. recoverTail() deliberately keeps those bytes -- they
are structurally valid and a readUncommitted replay still wants them -- but
seeding the committed watermark at the write head published them to committed
readers, exposing a transaction that never closed. The next transaction's flag
would then close the phantom group, so replication could see two source
transactions merged into one.

The scan now reports the offset just past the last flagged entry alongside
validEnd, computed in the same walk, and load() seeds there instead. Because
writeBatch() writes a batch across multiple log files when it crosses a
rotation, the prefix can span files, so the seed walks back through older
sequences until one ends on a real transaction boundary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Seeding the committed watermark at the last complete transaction keeps a crash
mid-batch out of committed reads, but only until the next commit: the leftover
entries stay in the file, the watermark jumps past them on the first
commitFinished(), and the next batch's TRANSACTION_LOG_ENTRY_LAST_FLAG then
closes the phantom group -- the same two-transactions-merged-into-one exposure,
one commit later.

recoverTail() now drops those entries so the file itself ends on a transaction
boundary. Nothing durable depends on them: writeBatch() completes before
Transaction::Commit() in every commit path and both commit-thread lanes preserve
dispatch order, so an interrupted log write is always the newest thing in the log
and its RocksDB commit never ran.

The discard is gated on proof that it is one interrupted batch of a flag-setting
writer -- a boundary earlier in the same file, plus a single timestamp across the
trailing run (writeEntriesV1 stamps every entry of a batch with the batch
timestamp, and getMonotonicTimestamp() never repeats). Without that proof the
bytes are kept and warned about, which is what protects a batch split across a
rotation and a log written before the flag existed; the watermark seed still
covers committed readers there.

POSIX truncates. Windows overwrites the range with zeros -- its files are
pre-extended and a zero timestamp is the end-of-entries marker, so a torn tail
needs no repair there but real entries do -- and drops the cached read-only
mapping, which is not coherent with WriteFile.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rtial failure

Zero the end-of-entries marker at `newSize` with its own write before
the bulk zeroing loop. A reader stops at the first zero timestamp
regardless of what follows, so once that write lands, `newSize` is a
safe end-of-log position even if a later chunk fails partway through
the rest of the range — the caller can lower `size` to `newSize`
unconditionally instead of leaving it at `entriesEnd` while the
on-disk marker already sits earlier, which would put the next append
past where readers stop.

Addresses PR #723 review feedback from cb1kenobi.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
@kriszyp
kriszyp force-pushed the kris/txnlog-committed-position-recovery branch from 9676862 to cea3330 Compare August 10, 2026 23:42
Comment thread src/binding/transaction_log/transaction_log_file_windows.cpp Outdated
kriszyp and others added 5 commits August 16, 2026 20:16
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Comment thread src/binding/transaction_log/transaction_log_store.cpp
Comment thread src/binding/transaction_log/transaction_log_file_windows.cpp
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
@kriszyp
kriszyp marked this pull request as draft August 19, 2026 23:55
kriszyp and others added 2 commits August 19, 2026 18:02
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
@kriszyp
kriszyp marked this pull request as ready for review August 20, 2026 15:20
cb1kenobi and others added 4 commits August 21, 2026 07:53
…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>
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants