fix(txnlog): erase a failed append's partial bytes instead of baking a framing break into the log - #751
Conversation
There was a problem hiding this comment.
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.
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>
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>
9e74708 to
8fe614a
Compare
9676862 to
cea3330
Compare
…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>
8fe614a to
3965572
Compare
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
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>
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 |
There was a problem hiding this comment.
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"—::openfailed: EMFILE/ENFILE, EACCES, EIO (transaction_log_file_posix.cpp:92)"Failed to get file size"—fstatfailed (:99)"Failed to read version from file"/"Failed to read file timestamp from file"—readFromFile()returned-1, i.e. apreadEIO 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], ×tamp)); | ||
| 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. |
There was a problem hiding this comment.
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:251and: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)
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 entrypermanently 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 abytesLandedout-paraminstead of collapsing a hard error to
-1, andwriteEntriesV1()erases that range beforethrowing. Erasing is safe:
writeEntriesV1holdsfileMutexacross the whole append,sizeneveradvanced over the bytes, and the commit throws — nothing acknowledged them. The erase reuses
eraseTail()from #723. Both platforms truncate back tosize(the zero-fill this descriptionoriginally 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:
appendBoundaryLostflag makeswriteEntriesV1return without writing, which the store alreadyreads 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 itmust 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.
lpNumberOfBytesWritten, whichWriteFiledoes not promise to set on failure; if that query alsofails, the extent is reported as
TRANSACTION_LOG_BYTES_LANDED_UNKNOWNand the file is retiredrather 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
0there would skipboth 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.
open()initialized a new file's header with three unchecked writes; aheader that only partly landed still set
sizeto the full header length, framing the firstappend 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)failsopen()'s "too small" check on everyfuture open, and freeing disk space would not heal it. (
removeFile()splits into afileMutex-acquiring wrapper and a per-platformremoveFileLocked()body, sinceopen()alreadyholds the lock.)
batch.currentEntryIndexis rolled back before the erase and thewarning emit, both of which allocate; a
bad_allocthere must not leave the batch claimingentries that never reached disk.
Stacked on #723
This branches from
kris/txnlog-committed-position-recovery(#723), which introduceseraseTail().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 atphysical EOF (which equals
size), Windows seeks tosizebefore writing and its file ispre-extended to
maxFileSize, sosizethere is the logical end of entries, not the physicalone. Both truncate back to
size; a Windows orphan is overwritten by the next append, but ashorter next batch would leave its stale bytes reading as an entry, so the rule is the same.
TransactionLogStore::writeBatch— an unchangedsizeis 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.
bytesLandedaccounting in bothwriteBatchToFile()implementations.Verification
Native GoogleTest, POSIX (
pnpm test:native). Failure injection uses the existing macro-seampattern (
ROCKSDB_JS_WRITEV, plus newROCKSDB_JS_WRITEandROCKSDB_JS_FTRUNCATE), defined onlyin the
rocksdb-js-native-testsgyp target — production builds resolve to the bare syscalls.FailedAppendLeavesTheLogOnAnEntryBoundary— fails 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
MidFileCorruptionwith the entry count truncated at the break. Passes with the fix(scan
Clean, both entries readable).UnerasableOrphanRetiresTheFile— ENOSPC + failingftruncate: the retired file takes no furtherentries and the scan still classifies it
TruncateTail.ShortHeaderWriteDiscardsTheFileInsteadOfBrickingIt— fails on base: the 5-byte file survivesand the reopen throws "File is too small to be a valid transaction log file". Passes with the fix.
UnerasableExtent/UnknownandUnerasableExtent/OverReported— both fail on base with theguard removed: the file is either appended past or erased against an untrustworthy range.
AppendThatWritesNothingLeavesTheFileUntouched, plus twoWriteBatchToFiletests forbytesLandedon a hard error and on a nothing-written error.Full gates on macOS:
pnpm test:native120 passed (3 madvise tests skipped — Linux-only),pnpm test728 passed / 1 skipped,pnpm checkclean.Open concerns from the pre-push review
Carried here rather than resolved, so a reviewer does not have to rediscover them:
TransactionLogFile. Nothing exercises the retire → rotate → retry contractthrough
TransactionLogStore::writeBatch; the native-test target does not link the store (itneeds RocksDB), and inducing a real
ENOSPCfrom the JS suite would need a filled volume. Thestore side was verified by reading it (
transaction_log_store.cpp:831rotates on unchanged size),not by a test.
bytesLandedaccounting and truncating erase areexercised only by fix(txnlog): recover the committed watermark from the log tail on load #723's own
eraseTailcoverage (the arithmetic that turns a file pointer into alanded extent is now factored into
landedBytesFromFilePointer()and unit-tested on everyplatform, but the
WriteFile/SetFilePointerExcalls around it are not); there is noWriteFileseam, and I cannot build Windowslocally — CI is the only verification for that backend. The decision logic those values feed
(retire-vs-erase) is shared, and a test-only
forcedBytesLandedForTestsoverride drives it fromthe POSIX build.
writeBatch's open-retry loop is unbounded, so apersistently 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 itsown issue rather than widening this one.
Verified false and dismissed along the way:
removeFileLocked()closes the file handle beforeunlinking (no Windows sharing violation), and
bytesWrittenis zero-initialized before everyWriteFile, 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 itfixes a pre-existing flakethat was blocking this PR's CI, not anything introduced here.
test/block-cache.test.tsasserteddb.get()returns a Promise without awaiting it; whendbRunnerclosed the database first, thein-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:
writeBatchToFilereports a negative file-pointer delta as unknown, not0— the casethat 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 readingit.
sizeis only meaningful afteropen(), so on the directory-iteration orders that left anolder segment lazy, a backup silently omitted it. Done under
dataSetsMutex(whichpurge()also holds) and the borrowed handle is closed again, so no fd or mapping is pinned.
truncateFile()logs a truncation below theMAP_SHAREDoverlay boundary — the documentedSIGBUS case — and lowers the overlay bookkeeping afterwards.
and invariant 5's "
sizeis 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