Skip to content

fix(txnlog): erase a failed append's partial bytes instead of baking a framing break into the log - #751

Open
kriszyp wants to merge 16 commits into
kris/txnlog-committed-position-recoveryfrom
kris/txnlog-append-boundary
Open

fix(txnlog): erase a failed append's partial bytes instead of baking a framing break into the log#751
kriszyp wants to merge 16 commits into
kris/txnlog-committed-position-recoveryfrom
kris/txnlog-append-boundary

Conversation

@kriszyp

@kriszyp kriszyp commented Aug 5, 2026

Copy link
Copy Markdown
Member

Fixes #748.

A transaction-log append that fails part-way through — a full disk, an exhausted quota, a short
write on a failing volume — left the bytes it had already written on disk, unaccounted for. The log
fd is opened O_APPEND, so the next successful append landed after those bytes: a partial entry
permanently embedded mid-file with valid entries on both sides. That is the one shape open-time
recovery deliberately refuses to repair (truncating there would discard committed, replicated
transactions), so every reader stops at the break and everything written after it becomes
unreachable — the reader-side consequence seen in HarperFast/harper#2016 and HarperFast/harper#2063.

writeBatchToFile() now reports how many bytes reached the file through a bytesLanded out-param
instead of collapsing a hard error to -1, and writeEntriesV1() erases that range before
throwing. Erasing is safe: writeEntriesV1 holds fileMutex across the whole append, size never
advanced over the bytes, and the commit throws — nothing acknowledged them. The erase reuses
eraseTail() from #723. Both platforms truncate back to size (the zero-fill this description
originally claimed for Windows was replaced by a physical truncate in #723) — with the caveat that
the Windows truncate refuses while another owner holds the memory map, so a failed append there
under a live JS reader retires the file instead of erasing it.

Three failure edges around that, each from a review round:

  • The erase itself fails (ENOSPC then EIO). The file is retired rather than warned about: an
    appendBoundaryLost flag makes writeEntriesV1 return without writing, which the store already
    reads as "no progress" and answers by rotating to the next sequence. The orphaned bytes stay the
    trailing partial that recoverTail() can truncate, instead of becoming the mid-file break it
    must leave intact. The double fault degrades to a repairable torn tail. Retirement is set before
    the erase and lifted only on success, so no way out of the erase short of success — a refusal, a
    failed truncate, a throw — can leave the file appendable.
  • The landed extent is unreportable. Windows takes it from the file pointer rather than
    lpNumberOfBytesWritten, which WriteFile does not promise to set on failure; if that query also
    fails, the extent is reported as TRANSACTION_LOG_BYTES_LANDED_UNKNOWN and the file is retired
    rather than erased against a guess. A file pointer behind the write origin is nonsense for a
    write and is reported as unknown too, not as "nothing landed" — reporting 0 there would skip
    both the erase and the retirement and leave the file appendable over the orphan. A figure larger than the bytes handed to the OS is treated
    the same way — an over-large erase would cut into committed entries.
  • A short header write. open() initialized a new file's header with three unchecked writes; a
    header that only partly landed still set size to the full header length, framing the first
    append from the wrong offset for the life of the file. It is now one checked write, and a short
    one removes the file — a size in (0, HEADER_SIZE) fails open()'s "too small" check on every
    future open, and freeing disk space would not heal it. (removeFile() splits into a
    fileMutex-acquiring wrapper and a per-platform removeFileLocked() body, since open() already
    holds the lock.)
  • A throw during cleanup. batch.currentEntryIndex is rolled back before the erase and the
    warning emit, both of which allocate; a bad_alloc there must not leave the batch claiming
    entries that never reached disk.

Stacked on #723

This branches from kris/txnlog-committed-position-recovery (#723), which introduces eraseTail().
Review/merge that one first; the diff here is only this change.

Where to look

  • writeEntriesV1()'s error branch — the erase range (committedSize .. committedSize + bytesLanded) has to match where the bytes actually landed on both platforms: POSIX appends at
    physical EOF (which equals size), Windows seeks to size before writing and its file is
    pre-extended to maxFileSize, so size there is the logical end of entries, not the physical
    one. Both truncate back to size; a Windows orphan is overwritten by the next append, but a
    shorter next batch would leave its stale bytes reading as an entry, so the rule is the same.
  • The retirement path and its contract with TransactionLogStore::writeBatch — an unchanged size
    is the existing "no progress" signal that triggers rotation, the same mechanism a
    max-size file uses. The flag is deliberately sticky for the object's life: it can only
    over-retire, never under-retire, and a fresh process rebuilds the object with it clear after
    recovery has had its chance at the trailing partial.
  • bytesLanded accounting in both writeBatchToFile() implementations.

Verification

Native GoogleTest, POSIX (pnpm test:native). Failure injection uses the existing macro-seam
pattern (ROCKSDB_JS_WRITEV, plus new ROCKSDB_JS_WRITE and ROCKSDB_JS_FTRUNCATE), defined only
in the rocksdb-js-native-tests gyp target — production builds resolve to the bare syscalls.

  • FailedAppendLeavesTheLogOnAnEntryBoundaryfails on base with exactly the reported shape:
    file size 43 vs committed 37 (6 orphaned bytes), and after the next append the recovery scan
    returns MidFileCorruption with the entry count truncated at the break. Passes with the fix
    (scan Clean, both entries readable).
  • UnerasableOrphanRetiresTheFile — ENOSPC + failing ftruncate: the retired file takes no further
    entries and the scan still classifies it TruncateTail.
  • ShortHeaderWriteDiscardsTheFileInsteadOfBrickingItfails on base: the 5-byte file survives
    and the reopen throws "File is too small to be a valid transaction log file". Passes with the fix.
  • UnerasableExtent/Unknown and UnerasableExtent/OverReported — both fail on base with the
    guard removed: the file is either appended past or erased against an untrustworthy range.
  • AppendThatWritesNothingLeavesTheFileUntouched, plus two WriteBatchToFile tests for
    bytesLanded on a hard error and on a nothing-written error.

Full gates on macOS: pnpm test:native 120 passed (3 madvise tests skipped — Linux-only),
pnpm test 728 passed / 1 skipped, pnpm check clean.

Open concerns from the pre-push review

Carried here rather than resolved, so a reviewer does not have to rediscover them:

  • Coverage stops at TransactionLogFile. Nothing exercises the retire → rotate → retry contract
    through TransactionLogStore::writeBatch; the native-test target does not link the store (it
    needs RocksDB), and inducing a real ENOSPC from the JS suite would need a filled volume. The
    store side was verified by reading it (transaction_log_store.cpp:831 rotates on unchanged size),
    not by a test.
  • Windows has no fault injection. Its bytesLanded accounting and truncating erase are
    exercised only by fix(txnlog): recover the committed watermark from the log tail on load #723's own eraseTail coverage (the arithmetic that turns a file pointer into a
    landed extent is now factored into landedBytesFromFilePointer() and unit-tested on every
    platform, but the WriteFile/SetFilePointerEx calls around it are not); there is no WriteFile seam, and I cannot build Windows
    locally — CI is the only verification for that backend. The decision logic those values feed
    (retire-vs-erase) is shared, and a test-only forcedBytesLandedForTests override drives it from
    the POSIX build.
  • Pre-existing, not introduced here: writeBatch's open-retry loop is unbounded, so a
    persistently failing volume spins through sequence numbers. A short header write is a new way to
    reach that loop, but openFile() already reached it under the same ENOSPC condition. Worth its
    own issue rather than widening this one.

Verified false and dismissed along the way: removeFileLocked() closes the file handle before
unlinking (no Windows sharing violation), and bytesWritten is zero-initialized before every
WriteFile, so it could never have contained garbage — the repeated "uninitialized stack memory"
framing of the Windows finding was wrong, even though the underlying under-reporting risk was real
and is now fixed.

One unrelated commit

test(block-cache): await the second read so teardown cannot abort it fixes a pre-existing flake
that was blocking this PR's CI, not anything introduced here. test/block-cache.test.ts asserted
db.get() returns a Promise without awaiting it; when dbRunner closed the database first, the
in-flight read was aborted and the unhandled rejection failed the run with every test passing. It
took out the Deno and Node 24 macOS jobs on this branch while the same commit passed everywhere
else. Identical code is on main, and it is the only unawaited get-promise assertion in the suite.

Reviewed by codex + gemini + harper-domain across four rounds. Generated by Claude Opus 5.


Review-feedback pass (2026-08-19). Addressing the open threads on this PR:

  • Windows writeBatchToFile reports a negative file-pointer delta as unknown, not 0 — the case
    that previously skipped both the erase and the retirement. The derivation is extracted into
    landedBytesFromFilePointer() with GoogleTest coverage of every branch on all platforms.
  • snapshotForBackup() seeds the extent of a registered-but-never-opened log file before reading
    it. size is only meaningful after open(), so on the directory-iteration orders that left an
    older segment lazy, a backup silently omitted it. Done under dataSetsMutex (which purge()
    also holds) and the borrowed handle is closed again, so no fd or mapping is pinned.
  • POSIX truncateFile() logs a truncation below the MAP_SHARED overlay boundary — the documented
    SIGBUS case — and lowers the overlay bookkeeping afterwards.
  • Comment and AGENTS.md corrections: the Windows erase is a truncate, not an allocating zero-fill,
    and invariant 5's "size is the physical extent" clause is POSIX-only.

Review-Coverage: authored=claude; ran=gemini; blocked=codex(quota); declined=cursor-grok,cursor-composer,domain; rounds=4 @ ea72583

Human-Review-Need: 4 @ ea72583

@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 addresses issues with failed appends leaving orphaned bytes on disk, which can cause framing breaks that prevent recovery. It ensures that partial writes are erased on failure, and if the erase itself fails, the file is retired from further appends. Additionally, short header writes during initialization now result in the file being discarded. Feedback was provided regarding the Windows implementation of writeBatchToFile, pointing out that lpNumberOfBytesWritten is unreliable on synchronous WriteFile failures and suggesting the use of SetFilePointer to accurately calculate the number of bytes that landed on disk.

Comment thread src/binding/transaction_log/transaction_log_file_windows.cpp Outdated
kriszyp added a commit that referenced this pull request Aug 5, 2026
WriteFile does not promise to set lpNumberOfBytesWritten when a
synchronous write fails. It is zero-initialized here, so the value can
never be garbage, but it can be left at 0 after a partial write — and the
caller erases exactly the range this reports, so under-reporting strands
the bytes it missed and reopens the framing break.

The file pointer is authoritative: the batch begins by seeking to `size`,
so the distance from there is what actually reached the file. Falls back
to the accumulated count if the query fails.

Addresses PR #751 review feedback from gemini-code-assist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
kriszyp added a commit that referenced this pull request Aug 5, 2026
The previous commit's fallback undid its own reasoning: if
SetFilePointerEx failed it dropped back to lpNumberOfBytesWritten, the
value it had just established WriteFile does not promise to set. Erasing
a range derived from it can strand exactly the bytes the erase exists to
remove.

An unreportable extent is now TRANSACTION_LOG_BYTES_LANDED_UNKNOWN, and
writeEntriesV1 retires the file rather than erasing a range it cannot
bound. A figure larger than the bytes handed to the OS is treated the
same way — nothing can have landed beyond what was attempted, so a larger
number means the platform mis-reported, and an over-large erase would cut
into committed entries.

Also fail-safe the retirement itself: it is set before the erase and
lifted only once the erase has succeeded. The Windows erase allocates, so
a throw from inside it would otherwise leave the file appendable with
orphaned bytes on disk — the same escape the batch-index restore was
moved to avoid.

Both branches are Windows-only in production, so a test-only
forcedBytesLandedForTests override drives them from the POSIX build,
where the deciding code is shared. Both tests fail with the guard removed.

Addresses PR #751 review feedback from codex and gemini-code-assist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@kriszyp
kriszyp marked this pull request as ready for review August 5, 2026 04:43
kriszyp added a commit that referenced this pull request Aug 7, 2026
WriteFile does not promise to set lpNumberOfBytesWritten when a
synchronous write fails. It is zero-initialized here, so the value can
never be garbage, but it can be left at 0 after a partial write — and the
caller erases exactly the range this reports, so under-reporting strands
the bytes it missed and reopens the framing break.

The file pointer is authoritative: the batch begins by seeking to `size`,
so the distance from there is what actually reached the file. Falls back
to the accumulated count if the query fails.

Addresses PR #751 review feedback from gemini-code-assist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
kriszyp added a commit that referenced this pull request Aug 7, 2026
The previous commit's fallback undid its own reasoning: if
SetFilePointerEx failed it dropped back to lpNumberOfBytesWritten, the
value it had just established WriteFile does not promise to set. Erasing
a range derived from it can strand exactly the bytes the erase exists to
remove.

An unreportable extent is now TRANSACTION_LOG_BYTES_LANDED_UNKNOWN, and
writeEntriesV1 retires the file rather than erasing a range it cannot
bound. A figure larger than the bytes handed to the OS is treated the
same way — nothing can have landed beyond what was attempted, so a larger
number means the platform mis-reported, and an over-large erase would cut
into committed entries.

Also fail-safe the retirement itself: it is set before the erase and
lifted only once the erase has succeeded. The Windows erase allocates, so
a throw from inside it would otherwise leave the file appendable with
orphaned bytes on disk — the same escape the batch-index restore was
moved to avoid.

Both branches are Windows-only in production, so a test-only
forcedBytesLandedForTests override drives them from the POSIX build,
where the deciding code is shared. Both tests fail with the guard removed.

Addresses PR #751 review feedback from codex and gemini-code-assist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@kriszyp
kriszyp force-pushed the kris/txnlog-append-boundary branch from 9e74708 to 8fe614a Compare August 7, 2026 22:42
@kriszyp
kriszyp force-pushed the kris/txnlog-committed-position-recovery branch from 9676862 to cea3330 Compare August 10, 2026 23:42
kriszyp and others added 7 commits August 18, 2026 20:44
…ng them

A transaction-log append that fails part-way through (ENOSPC, a short
write on a full volume) left the bytes it had already written on disk.
The fd is opened O_APPEND, so the next successful append landed *after*
them: the log ended up with a partial entry embedded mid-file and valid
entries on both sides — the one shape recoverTail() deliberately refuses
to repair, since truncating there would discard committed transactions.
Every reader stops at the break, so entries written after it become
unreachable (HarperFast/harper#2016, HarperFast/harper#2063).

writeBatchToFile() now reports how many bytes reached the file through a
`bytesLanded` out-param instead of collapsing a hard error to -1, and
writeEntriesV1() erases that range before throwing. It runs under
fileMutex, `size` never advanced over the bytes, and the commit throws,
so nothing acknowledged them. Reuses eraseTail(): POSIX truncates back
to `size`; Windows zero-fills the range to restore its end-of-entries
marker. If the erase itself fails, the resulting break is surfaced as a
`log.warn` rather than left silent.

The batch's currentEntryIndex is also restored, since none of its
entries are on disk.

Related hardening in open(): the three unchecked writes that initialize
a new file's header are now a single checked write. A header that only
partly landed still set `size` to the full header length, so the first
append would be framed from the wrong offset for the life of the file —
the same defect shape from the same full-disk incident.

Fixes #748

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pre-push review (codex + gemini + harper-domain) found the initialization
half of the same defect: open() now detects a partial header write, but
left the bytes on disk. A file of 0 < size < HEADER_SIZE fails the "too
small to be a valid transaction log file" check on every future open, and
freeing disk space does not heal it — the segment is un-openable forever.
Remove the file before throwing, so the path re-initializes cleanly once
the write can complete.

removeFile() splits into a fileMutex-acquiring wrapper and a
removeFileLocked() body per platform, since open() already holds the lock.

Covered by a new ROCKSDB_JS_WRITE test seam (the same pattern as
ROCKSDB_JS_WRITEV/ROCKSDB_JS_MADVISE) that caps the header write; on base
the test leaves a 5-byte file behind and the reopen throws.

Also from review: trim comment narration and note that the Windows
bytesLanded accounting relies on synchronous WriteFile, where
lpNumberOfBytesWritten is populated even on failure.

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

Round-2 pre-push review (codex + gemini + harper-domain) caught the erase
failing on its own failure branch: when the append failed AND eraseTail
could not remove what landed, the code warned and threw but left the file
open O_APPEND. The next successful commit would then land past the orphan
and recreate exactly the mid-file break this change exists to prevent —
recoverTail() would refuse to repair it and every entry after it would be
lost.

A file in that state is now retired: writeEntriesV1 returns without
writing, which the store already reads as "no progress" and answers by
rotating to the next sequence. The orphaned bytes stay the trailing
partial that open-time recovery can truncate, so the double fault
degrades to a repairable torn tail instead of permanent data loss.

Covered by a ROCKSDB_JS_FTRUNCATE seam driving the ENOSPC + EIO double
fault; the test asserts the retired file takes no further entries and
that the recovery scan still classifies it TruncateTail.

Also from review: pid-qualify the test temp paths and drop two comments
that narrated adjacent code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Round-3 review: the currentEntryIndex restore ran after the erase and
warn-emit block, both of which allocate. A bad_alloc there would skip the
restore and replace the DBException, so a caller that catches and retries
the batch would resume past entries that never reached disk — silently
dropping them from the log while their RocksDB commit still runs. The
restore has no dependency on the erase, so it moves first.

Comment trims, third pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Unrelated to this PR's fix, but it blocks its CI: the late-column-family
test asserted db.get() returns a Promise without ever awaiting it. When
dbRunner closes the database first, that in-flight read is aborted
("Database closed during get operation") and the rejection is unhandled,
so vitest exits non-zero with every test passing. Timing-dependent, and
macOS runners lose the race often — it failed the Deno and Node 24 macOS
jobs on this PR while the same commit passed elsewhere.

Pre-existing on main; this is the only unawaited get-promise assertion in
the suite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
WriteFile does not promise to set lpNumberOfBytesWritten when a
synchronous write fails. It is zero-initialized here, so the value can
never be garbage, but it can be left at 0 after a partial write — and the
caller erases exactly the range this reports, so under-reporting strands
the bytes it missed and reopens the framing break.

The file pointer is authoritative: the batch begins by seeking to `size`,
so the distance from there is what actually reached the file. Falls back
to the accumulated count if the query fails.

Addresses PR #751 review feedback from gemini-code-assist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous commit's fallback undid its own reasoning: if
SetFilePointerEx failed it dropped back to lpNumberOfBytesWritten, the
value it had just established WriteFile does not promise to set. Erasing
a range derived from it can strand exactly the bytes the erase exists to
remove.

An unreportable extent is now TRANSACTION_LOG_BYTES_LANDED_UNKNOWN, and
writeEntriesV1 retires the file rather than erasing a range it cannot
bound. A figure larger than the bytes handed to the OS is treated the
same way — nothing can have landed beyond what was attempted, so a larger
number means the platform mis-reported, and an over-large erase would cut
into committed entries.

Also fail-safe the retirement itself: it is set before the erase and
lifted only once the erase has succeeded. The Windows erase allocates, so
a throw from inside it would otherwise leave the file appendable with
orphaned bytes on disk — the same escape the batch-index restore was
moved to avoid.

Both branches are Windows-only in production, so a test-only
forcedBytesLandedForTests override drives them from the POSIX build,
where the deciding code is shared. Both tests fail with the guard removed.

Addresses PR #751 review feedback from codex and gemini-code-assist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@kriszyp
kriszyp force-pushed the kris/txnlog-append-boundary branch from 8fe614a to 3965572 Compare August 19, 2026 03:04
Comment thread src/binding/transaction_log/transaction_log_file_windows.cpp Outdated
Comment thread src/binding/transaction_log/transaction_log_file.cpp Outdated
Comment thread src/binding/transaction_log/transaction_log_file_windows.cpp
Comment thread AGENTS.md Outdated
Comment thread src/binding/transaction_log/transaction_log_file_posix.cpp
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Comment thread src/binding/transaction_log/transaction_log_store.cpp
kriszyp and others added 4 commits August 18, 2026 22:41
Address review feedback on #751.

- Windows writeBatchToFile: a file-pointer delta behind the seek origin is
  reported as UNKNOWN rather than 0. Reporting 0 made the caller skip both
  the erase and the retire, leaving the file appendable over the orphan —
  exactly the framing break this PR exists to close.
- snapshotForBackup: open a registered-but-never-opened log file before
  reading its extent. `size` is only meaningful after open(), so on the
  directory-iteration orders that leave an older file lazy, a rotated
  segment was silently omitted from the backup.
- POSIX truncateFile: log a truncation below the MAP_SHARED overlay
  boundary (the SIGBUS case) and lower the overlay bookkeeping afterwards,
  pinning an invariant that was previously caller discipline only.
- Correct stale rationale comments: post-rebase the Windows erase is a
  truncate, not an allocating zero-fill, and it degrades to a retire while
  a JS reader holds the map. Scope AGENTS.md invariant 5 per platform —
  `size` is the physical extent on POSIX only; Windows pre-extends.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pre-push review found that opening every never-touched rotated segment to
learn its extent (a) pinned an fd — and on Windows a mapping — per segment
for the life of the process, so the first backup on a store with months of
retained logs could exhaust the fd limit, and (b) ran outside dataSetsMutex,
where a concurrent purge could unlink the file between the isOpen() check and
open(), whose O_CREAT resurrected a header-only ghost segment.

Seed the extent inside the same locked snapshot block purge() serializes
against, skip a path that no longer exists, and close again anything that was
closed before — the store's read paths hold dataSetsMutex while they use a
file, and JS-held mappings survive close() on their own refcount.

Also extract the Windows landed-extent derivation into
landedBytesFromFilePointer() so its negative-delta branch is covered by the
GoogleTest suite on every platform, not only on a Windows runner.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A stat that errors is not a definite absence, and skipping the open there
left byteLimit at 0 — silently omitting the segment from the backup, which
is the failure this seeding exists to prevent. Skip only when the path is
positively gone; the worst case of opening a since-deleted path is a
header stub the next startup rescan purges.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread src/binding/transaction_log/transaction_log_store.cpp Outdated
Comment thread src/binding/transaction_log/transaction_log_store.cpp Outdated
Comment thread src/binding/transaction_log/transaction_log_file.h Outdated
kriszyp and others added 4 commits August 19, 2026 01:01
Pre-push review found that the backup snapshot was not the only consumer of
TransactionLogFile::size that acts on a registered-but-never-opened file's
zero. doPurge()'s flushed-position guard reads the same field, and its
equality branch (`size > positionInLogFile`) is the "this file still has
unflushed entries, keep it" test — so a lazy segment's 0 makes the guard
false and deletes a file whose tail never reached RocksDB. Silent, and data
loss rather than a skipped backup.

Lift the seeding into a private ensureExtent(), documented with its
dataSetsMutex precondition, and call it from doPurge() as well, so the next
consumer of `size` inherits it instead of rediscovering it. An unresolvable
extent refuses the purge rather than deleting unproven bytes.

Also release the borrowed handle when open() throws (bad token, unsupported
version, short file, failed header read) — otherwise the one path where the
file is known bad is the one that pins an fd, and on Windows a mapping, for
the life of the process.

Move the landedBytesFromFilePointer tests from the writev suite, which is
wrapped in `#ifndef _WIN32`, to transaction_log_validation_test.cc so the
header's "covered on every platform" claim is actually true.

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

Pre-push review (Gemini + Harper domain) on the extent-seeding commit:

- TransactionLog::FindPosition has no try/catch, and findPositionByTimestamp
  opens lazily-registered segments — so a DBException from a bad header
  (token, version, short file, failed header read) escapes an N-API callback
  and aborts the process. Catch it and throw to JS, matching AddEntry.

- snapshotForBackup() let the same throw out to JS. A single unopenable
  legacy segment would then fail every future backup of an otherwise healthy
  database. Warn and exclude it instead — it has no readable entries, so
  there is nothing to copy.

Also adds two purgeLogs() cases covering doPurge()'s flushed-position guard
(retain a file with an unflushed tail, purge one entirely before the frontier),
and drops the header comment naming the test file that covers
landedBytesFromFilePointer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Round-2 pre-push review found the same leak in a second caller:
findPositionByTimestamp() opens lazily-registered segments, and open() throws
after openFile() has already succeeded (bad token, unsupported version, short
file, failed header read), so each attempt against a file that can never open
leaks an fd — and, on Windows, the mapping the index scan created.

Fixing it per call site was the bandaid; the invariant is open()'s: a file it
rejects is not open. Split the platform close() bodies into closeLocked() (the
existing removeFileLocked() pattern) and run the rest of open() through a
catch that calls it before rethrowing. ensureExtent()'s own catch, added for
the backup caller, is now redundant and drops out.

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

The Windows Node 24 CI job fast-failed (0xC0000409) in "should write to same
log from multiple workers" — the test that purges in a tight loop while a
worker appends. doPurge() resolved every segment's extent before the
flushed-position check, so it reached the *current* file during the window
between getLogFile() creating it and the first append: size 0 and not yet
open. ensureExtent() then opened it and closed it again, dropping a handle —
and on Windows the mapping with it — that the next append expects to hold.

Two changes, both narrowing to what the guard actually needs:

- ensureExtent() refuses the active segment outright. Its handle belongs to
  the write path, and the only state in which it looks lazy is the one where
  size 0 is the truth. This also covers the backup caller.
- doPurge() resolves the extent only for the segment at the flush frontier —
  the one file whose comparison reads `size` at all. Every other file is
  decided by sequence number alone, so the previous placement opened and
  closed every segment on every purge pass for nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
try {
this->ensureExtent(file);
} catch (const std::exception& e) {
// A segment whose header will not open has no readable entries, so

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.

Medium: this catch cannot tell "the header is corrupt" from "I could not open it right now", and silently drops a healthy segment from the backup

The comment states the premise — a segment whose header will not open has no readable entries — but ensureExtent() -> open() throws a bare DBException for two very different classes of failure, and DBException is final with no code or type to separate them (core/exception.h:23):

Genuinely unreadable (the premise holds): bad token, unsupported version, "File is too small" — transaction_log_file.cpp:137,142,163.

Transient / environmental (the premise does not hold — the segment may be intact and full of committed transactions):

  • "Failed to open sequence file for read/write"::open failed: EMFILE/ENFILE, EACCES, EIO (transaction_log_file_posix.cpp:92)
  • "Failed to get file size"fstat failed (:99)
  • "Failed to read version from file" / "Failed to read file timestamp from file"readFromFile() returned -1, i.e. a pread EIO on a bad sector (transaction_log_file.cpp:147,157,168)
  • "Failed to create parent directory" (transaction_log_file_posix.cpp:81)

fd exhaustion is the one to worry about: RocksDB holds a lot of fds, and this loop opens every lazily-registered segment in one pass. One EMFILE and the backup drops that segment, returns success, and the gap is invisible until a restore needs it — which is the exact failure mode argued against on the earlier thread ("an unopenable segment hard-fails the backup rather than silently omitting itself"). log.warn is out-of-band; nothing in the return value tells the caller the backup is incomplete.

Note doPurge() gets this right for free — an unresolvable extent there means "refuse to purge", so both classes fail safe. It is only the backup where the two need separating.

Suggested fix: make the distinction at the throw site rather than at the catch, since that is the only place that still knows which it was — e.g. a bool malformed on DBException (or a small TransactionLogFormatException) set only by the token/version/too-small throws. Then snapshotForBackup() keeps the continue for a malformed header and rethrows everything else, preserving the previous hard-fail for the case where the segment might be fine.

Secondary: this catch is catch (const std::exception&) only, while the matching one in doPurge() (:697) also has a catch (...). Worth matching them.


Generated by Barber AI (Claude Opus 5)

NAPI_STATUS_THROWS(::napi_get_value_double(env, argv[0], &timestamp));
LogPosition position = (*txnLogHandle)->findPosition(timestamp);
// findPosition() opens lazily-registered segments, and open() throws on a
// bad header — a C++ exception escaping an N-API callback aborts the process.

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.

Medium: the same unguarded open() throw escapes two neighbouring N-API callbacks in this file

The reasoning in this comment is right, and it applies verbatim two functions up. Both of these call store methods that open lazily-registered segments with no try/catch anywhere on the path:

  • GetLogFileSize (:174) -> TransactionLogStore::getLogFileSize() (transaction_log_store.cpp:251 and :260) — opens the named file, or every file when no sequence number is given.
  • GetMemoryMapOfFile (:221) -> TransactionLogStore::getMemoryMap() (transaction_log_store.cpp:228).

Same open(), same DBException on a bad token/version/too-small/short-read, same escape out of a napi_callback. GetLogFileSize is the more exposed of the two: transaction-log-reader.ts calls it from six sites including the tailing loop (:250, :272, :283, :409, :471), and :471 asks for nextLogId — a segment that may well still be lazy.

Pre-existing rather than introduced here, but 1229e855 is specifically "keep a bad log segment from aborting FindPosition", and one corrupt .txnlog still aborts the process through either sibling. Wrapping both the same way would finish the commit's own goal.


Generated by Barber AI (Claude Opus 5)

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