Repository navigation
Read every entry of a transaction-log segment that one transaction pushed past transactionLogMaxSize - #890
Conversation
…p readers it outgrew A batch written to an empty segment may exceed transactionLogMaxSize, but the current segment was mapped at exactly that size, so every entry past it was unreachable: the frame straddling the mapping's end read as a torn tail (CorruptFrameError "declared length N overruns the log") and Harper's replication sender stopped there, re-throwing on every poll. A frame ending exactly on the mapping's end was silently skipped along with the whole tail. Native: getMemoryMapLocked() raises every requested capacity to the append-owned size under fileMutex (not at the store's call sites, where a size snapshot can predate an append that lands before the file lock), so a handout always covers the file and the reuse check replaces a map the file outgrew. The mapping's readableExtent is published unclamped, and superseded mappings keep receiving it, so a reader can tell its buffer no longer covers the segment. Unlimited stores (maxFileSize 0) map the current segment at twice its size instead of 0 bytes. JS reader: a buffer whose readableExtent exceeds its length is remapped at the cache hit, both segment transitions and a frame that does not fit; a remap that is not possible while the segment is registered stops the iterator for the next poll instead of skipping the tail. Fixes #889 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vs1zLr2bcoRdQKFDBt4r7E Dispatch-Task: rocksdb-js-oversized-current-log-map
…rt an index-walk mapping failure
Round-1 review: replacing a map before it was outgrown (the unlimited-mode
headroom policy) left anonymous zero pages inside a superseded POSIX map's
length, remapped on every query, and extended the file under a live Windows
view. The requested capacity now only sizes a new mapping; a live mapping
that still covers the file is reused, so a map is replaced only after the
append that outgrew it published an extent past its end and completed its
overlay. The superseded-map tracking is gone with it, and unlimited stores
map the current segment at its size and remap as it grows.
findPositionByTimestamp throws when a nonempty file has no mapping instead
of returning the past-every-entry sentinel, which started query({ start })
at the segment's end. coveringLogBuffer takes the caller's committed size so
a readUncommitted reader is not refused a remap while the extent publish
lags; a remapped buffer no longer than the old one means size over-reports
the file. The current-buffer fast path checks the segment start resolved.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vs1zLr2bcoRdQKFDBt4r7E
Dispatch-Task: rocksdb-js-oversized-current-log-map
…t the in-loop transition Windows maps an existing segment at its physical size during open and the reuse rule keeps that view, so the 13-byte header-only fixture gave Windows a 13-byte pre-append mapping where POSIX had maxFileSize. Writing the fixture pre-extended with zero padding (the Windows active-segment layout; POSIX recovery truncates it to the header) maps it at the target on both. The filtered-entry transition handed coveringLogBuffer the latest segment's committed offset as the hint for an older segment's buffer, which evicted and remapped a segment the reader had already finished. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vs1zLr2bcoRdQKFDBt4r7E Dispatch-Task: rocksdb-js-oversized-current-log-map
An iterator's size is a snapshot. A filtered entry ending exactly on it, followed by a rotation, made the in-loop transition jump to the newer segment without re-reading how far the current one now extends, so entries appended after the snapshot were never yielded; the top-of-next() transition already re-read it. Pre-existing, surfaced by review of the rewritten transition. Also covers the unpadded header-only segment (the 13-byte open-time view Windows keeps until the batch outgrows it) and drops a comment that claimed a race the file lock rules out. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vs1zLr2bcoRdQKFDBt4r7E Dispatch-Task: rocksdb-js-oversized-current-log-map
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vs1zLr2bcoRdQKFDBt4r7E Dispatch-Task: rocksdb-js-oversized-current-log-map
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vs1zLr2bcoRdQKFDBt4r7E Dispatch-Task: rocksdb-js-oversized-current-log-map
There was a problem hiding this comment.
Code Review
This pull request addresses an issue where a transaction-log mapping handed to a reader does not cover the segment's committed extent (e.g., when a single transaction batch exceeds transactionLogMaxSize). It ensures that mappings are raised to the append-owned size under fileMutex on both POSIX and Windows, and updates the JS reader to detect outgrown buffers and remap them instead of reporting false corrupt frame errors or prematurely stepping over unread tails. Additionally, it throws an exception on mapping failures during index walks rather than returning a sentinel, and adds comprehensive unit and integration tests to verify these behaviors. I have no feedback to provide as there are no review comments.
📊 Benchmark Resultsget-sync.bench.tsgetSync() > random keys - small key size (100 records)
getSync() > sequential keys - small key size (100 records)
ranges.bench.tsgetRange() > small range (100 records, 50 range)
realistic-load.bench.tsRealistic write load with workers > write variable records with transaction log
transaction-log.bench.tsTransaction log > read 100 iterators while write log with 100 byte records
Transaction log > read one entry from random position from log with 1000 100 byte records
worker-put-sync.bench.tsputSync() > random keys - small key size (100 records, 10 workers)
worker-transaction-log.bench.tsTransaction log with workers > write log with 100 byte records
Results from commit cbb80be |
cb1kenobi
left a comment
There was a problem hiding this comment.
@kriszyp changing the behavior of transactionLogMaxSize to assume the default (16MB) when the value is 0 is technically a breaking change.
The oversized-transaction reader fix makes sense, but I don’t think we should redefine transactionLogMaxSize: 0 as part of it.
Harper doesn't appear to currently set a transactionLogMaxSize. Omitting the option already uses the 16MB default, which is how Harper uses it. Changing explicit 0 from unlimited segment size to the default changes existing configurations and can make previously retained history eligible for deletion.
Can we preserve 0 while fixing oversized-transaction reads? The description explains how fixed-capacity mappings simplify the implementation, but doesn’t establish why unlimited segment size must be removed.
If preserving unlimited growth is impractical, then maybe a value of 0 should throw an error.
0 used to mean unlimited segment size. A reader's mapping now has a fixed capacity, so an unbounded segment cannot be read without remapping; quietly treating 0 as the 16 MiB default would instead start size rotation and let retention purge history an existing store had kept. Fail the open and tell the caller to omit the option or pick a size. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: fix-kriszyp_rocksdb-js_890-482b4830
The seam fired inside openLocked(), which the previous separate open() also ran under fileMutex, so the test passed whether or not header creation and the first append shared one lock. Fire it in writeEntries() between the lazy open and the append instead; releasing the lock there now fails the test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: fix-kriszyp_rocksdb-js_890-482b4830
A mapped header-only retired segment was already skipped, but one whose mapping failed stopped the iterator because its 13-byte size counted as history. Only stop for a segment that holds entries. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: fix-kriszyp_rocksdb-js_890-482b4830
But I think the key design invariant (and how the overflow was designed) is that we should be able to create a memory map, hand it to JS, and it should never grow (it either starts larger than max size, or never extends beyond). And naturally you can't allocate an "unlimited" size memory map (you can allocate a pretty big one though!) Ironically I actually thought
Sounds good to me 👍 . |
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: fix-kriszyp_rocksdb-js_890-482b4830
|
@cb1kenobi Thanks for catching this — agreed, quietly redefining Why
What
Migration
— Claude Opus 5.5 |
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: pr-maint-3ab1f185fda4e7b79e9fb3c9a52f5293
Transaction-log readers now read every entry of an oversized transaction through a fixed-capacity mapping. The native writer closes the header/first-append gap and rotates before any append could outgrow a buffer already handed to a reader; JavaScript no longer needs to remap growing segments.
Breaking option change:
transactionLogMaxSize: 0(formerly unlimited segment size) now failsopen()with a migration hint (Database::Open). Omitting the option keeps the 16 MB default.For the human reviewer
Adopted planning alternative. The planning review returned
Framing-Verdict: better-alternative-exists: withholding mappings for recovered empty files would stall frozen history and disturb tail positioning. We instead keep those files readable and enforce their exposed capacity at append admission. This fulfills the requested native invariant without changing the reader's initial position.Requirement and compatibility: reject
0rather than redefine it. Transactions remain whole, including a first transaction larger than the configured target. An earlier revision silently treatedtransactionLogMaxSize: 0as the 16 MiB default. Per @cb1kenobi's review, a store configured with0must not start rotating and purging without being told, soDatabase::Opennow throws with a migration hint, and the README states the retention effect of choosing a size.0cannot stay unlimited: a reader's buffer has a fixed capacity, so an unbounded segment needs either reader remapping (the complexity this PR removes) or, on Windows, pre-extending the active file to the whole reservation on disk.0did on main: the writer never rotated by size, but readers mapped the current segment at capacity 0 (getMemoryMap→fileSize == 0returns null), soquery()read nothing from it. A segment reaching 4 GiB failed every later append (maximum supported file size). Age rotation after a write-idle gap (transactionLogMaxAgeThreshold) still rotated and purged.128).Review-depth decision: continue with the replacement design. The branch's third full pass was the first to inspect the native admission mechanism; it accepted one latent Windows normalization hazard, one inherited reader-cache defect, and small diagnostics/documentation repairs in about 27 minutes. The diff grew from 745 to 860 changed lines, mainly regression coverage. Three fresh full passes have now reviewed the replacement approach. The final pass found no new correctness defect after the Windows open and committed-watermark repairs. Stop further correctness review: the native capacity invariant survived all three passes, the accepted failures have discriminating regressions, and the remaining metadata race predates this diff.
Review-feedback round (Claude Opus 5.5): one closing full pass and three delta passes covered the
0rejection, a first-append test seam moved into the open-to-append gap, a reader skip fix, and the README retention note. The final verdict was LGTM.Declined from that round, with reasons:
hasAppendedSinceOpenafter a refused batch. The ordering predates this PR, Windows open normalizessizefirst, and a refused segment rotates away.Pre-existing concern left outside this diff: the native external-buffer wrapper reads mutable
MemoryMap::fileSizeafter the file lock is released. The review identifies a C++ metadata race across worker handouts; this change does not claim to repair it.CI maintenance round (Claude Opus 5.5): the red checks at
fe4362a7were GitHub runner failures, not test failures — 11 jobs never acquired a runner, and three runners died mid-test (no log uploaded, or "runner has received a shutdown signal" after every test had passed). On re-run all 24 checks passed, plus the 2 skipped draft-review workflows.75f27c4dfixes a stale registry config comment that still said0selects the default limit, per @cb1kenobi's inline suggestion; delta review LGTM.Product and architecture tour
Establish capacity before readers can see the segment
Header creation and the first batch share one lock
A reader racing creation waits until the complete first batch has established the written extent.
sequenceDiagram participant Writer participant File participant Reader Writer->>File: Acquire fileMutex Writer->>File: Open and write header Reader->>File: Request mapping and wait Writer->>File: Append complete first batch Writer->>File: Release fileMutex File-->>Reader: Map capacity covering the batchA crash or a known zero-byte append failure can still leave an empty segment. If a reader already holds its normal-capacity mapping, an oversized batch moves to a fresh segment. The empty mapping stays valid, and iteration crosses it to reach the new transaction. Capacity is retained after readers release their buffers so a later append cannot invalidate a previously exposed view.
Changes
fileMutexthrough lazy open, header initialization and append; cleans up a rejected open; checks whole-batch admission against the exposed capacity; and publishes a bounded readable extent. Timestamp indexing raises mapping capacity under the same lock and throws on a nonempty mapping failure instead of fabricating EOF.0using the shared default; src/binding/options/db_options.h shares the default constant with the store and includes the statistics declaration needed by that header's existingStatsLeveldefault. src/binding/transaction_log/transaction_log_store.h and src/binding/transaction_log/transaction_log_store_registry.h update their configuration contracts.transactionLogMaxSize: 0at open; the store constructor still maps an internal0to the default for native callers.0is rejected, the retention effect of migrating to a size, and the oversized mapping's memory accounting. src/load-binding.ts documents the binding option; src/store.ts repeats the default and the0rejection on the publicStoreOptionsproperty shown by editors.Verification
Route: public transaction-log integration tests plus deterministic native concurrency and failure tests. The Harper replication sender itself is not exercised.
0rejection. The recovered-empty and default-capacity assertions failed against the previous PR binding before the native changes. Crossing an unmappable empty retired segment returns 1 of 201 entries without the reader fix.writeEntries()releasesfileMutexbetween lazy open and append), a zero-byte failed first append can rotate safely, and a rejected lazy open releases its handle and extent.60d5bd93:pnpm test1,118 passed, 10 skipped; native binary 309 passed. At859d6302: transaction-log suite 122 passed;pnpm checkclean.pnpm test1,116 passed, 10 skipped, zero failures before the final bound repair. The complete transaction-log suite was rerun after that repair (120 passed). Full native binary: 309 passed.STRESS_MODE=essential pnpm test:stress: 8 passed, 1 skipped.pnpm checkand both native and bundle builds passed on Linux. The two final ahead-segment regressions also passed under the Deno test runner. CI at223531fe: 24 checks passed and the two draft review workflows were skipped. Native tests and Node 22/24/26, Bun, and Deno integration passed on Linux, macOS, and Windows. Windows native CI passed all 279 tests, includingMappingFailureCannotAppendPastZeroPadding.Fixes #889
Origin — the dispatch brief this PR was written from
Read every entry of a transaction-log segment that one transaction pushed past transactionLogMaxSize
LIVE CONVERSATION about #890.
You are answering a person, in a thread, one turn at a time. Every turn:
Each turn arrives as ASK (answer it, change nothing) or PERFORM (do it, then say what you did) — the person chose which when they sent it, and the run's own prompt tells you which one this is. Never infer it from the wording: an unrequested commit in the middle of a discussion and a polite description of work that was supposed to happen are the two failures this exists to prevent.
Never mark a PR ready and never merge from this conversation.
Dispatch: task
chat-pr-rocksdb-js-890-kriszyp· queued by unknown · ran by codex/gpt-6-astra/low · worker kzyp-xps-1Related PRs: #545 overlaps, #742 independent, #767 overlaps, #842 independent, #897 independent, #900 independent, #901 independent, #902 overlaps, #74 independent
Complexity: complicated
Review-Coverage: authored=claude; ran=cursor-composer,codex,gemini; adjudicated=domain; declined=cursor-grok,cursor-kimi,cursor-muse; rounds=14; full=6 @ 75f27c4
Review-Attention: deep ~40m (critical: transaction_log_file.cpp, transaction_log_file.h +6; decisions: reject-zero-max-size, writer-side-capacity, capacity-never-released, map-failure-throws, ahead-segment-hidden, do-less-alternative) @ 75f27c4