Conversation
There was a problem hiding this comment.
Code Review
This pull request implements a robust cache revalidation mechanism for JS memory-map caches against the native store's purge epoch, preventing stale reads of purged transaction-log segments. It also hardens databaseFlushed() to verify the physical existence of txn.state on disk rather than relying solely on the stream's open status. The feedback recommends using performance.now() instead of Date.now() in tests to ensure a monotonic clock, and replacing new Uint32Array with readUInt32LE on read buffers to avoid potential alignment errors.
📊 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 8c2b320 |
6855e4b to
9398f6b
Compare
7751c8f to
20d25cf
Compare
93facff to
112c6cf
Compare
770b69b to
162cf81
Compare
cb1kenobi
left a comment
There was a problem hiding this comment.
The new commit only renumbers AGENTS.md invariants after the rebase onto main. Prior blocking issues remain fixed: readers walk registered successors instead of jumping past a survivor, read probes cannot recreate a deleted segment, purged reads use the mapping-carried append-owned extent, and destroy invalidates stale flush correlations. No new blocking defect showed up on the changed lines.
—
Reviewed 162cf81
cb1kenobi
left a comment
There was a problem hiding this comment.
The merge of main onto this head does not reintroduce the fixed purge bugs or add a new one. Readers still walk registered successors, read probes still cannot recreate a deleted segment, and flushed-state writes still drop stale pre-purge correlations. No blocking defect showed up on the changed lines.
—
Reviewed eee417f
5396e00 to
96eceb3
Compare
A purge unlinks the segment, which removes one link to an inode whose bytes a retired segment never changes again; a reader's MemoryMap is the other link, so the entries it mapped are still exactly the committed history and stay readable. The bug in HarperFast/harper#2337 was never that those bytes were served — it was that the mapping was never released, so the purge reclaimed no space: 16 MiB of a deleted .txnlog resident until restart. TransactionLog._currentLogBuffer, the fast path over the already-weak _logBuffers cache, held a strong reference and is only refreshed by query(), so a reader that calls query() once and next() forever (harper's audit subscription) froze it on whatever segment was current then. It is now a WeakRef: the mapping goes at the next GC once the iterator holding it moves on, with no purge-time invalidation and no cross-handle signalling. Also here, because they are the same reclaim path: - nextReadableLogBuffer() skips a run retention deleted when an iterator advances. Stopping at the hole stopped the iterator permanently, since every later poll stopped in the same place. _findPosition(0) names the oldest survivor, so a purged prefix costs one native call rather than a probe per segment, and only a run the store no longer has is skipped: a segment it still knows is merely unmappable for now, so iteration stops and retries. - readableExtent() bounds a read of a purged segment by its mapping, since the store reports no size for a segment it has forgotten. The 0 it reports dropped every entry the reader had not reached yet — including entries appended after it last polled, which the writer's overlay extension made visible in that same mapping. - removeFile() uses the non-throwing std::filesystem::remove overloads on both platforms; a Windows sharing violation used to unwind a C++ exception through the N-API purge boundary. - A segment that vanished between the purge's scan and its unlink is forgotten from sequenceFiles the way the scan forgets an already-missing one, and a segment that could not be deleted for a real reason is reported once per purge run via log.warn instead of silently stalling retention. Refs HarperFast/harper#2337 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…is_open()
databaseFlushed() keeps the flushed-state stream open across flushes, and a
stream describes a descriptor, not a pathname: once txn.state (or the whole
store directory) is unlinked, every write lands in the orphaned inode while
getLastFlushedPosition(), which reads by path, returns the {0,0} sentinel and
retention never advances.
The pathname is now checked before the unchanged-position shortcut, since a
flush resolving to the already-recorded position must still restore a missing
file. The directory is recreated the way getLogFile() does, isClosing is
re-checked under flushedStateMutex so a concurrent destroy cannot be
resurrected, and the reopen is in-place (in | out) rather than truncating: the
8-byte record is overwritten whole, and a truncating reopen after a failed write
would erase the last durable position before a retry that can fail again. The
creating open is taken only after the file is verified absent.
This runs on RocksDB's flush thread, where an escaping exception ends the
process, so the whole rewrite sits behind a catch-all and every failure is
reported once via log.warn, leaves lastWrittenFlushedPosition untouched, and is
retried on the next flush.
Hardening rather than a live bug: only purgeLogs({ destroy: true }) removes the
directory today, and Harper does not call it in production.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
AGENTS.md said oxfmt formats "TS/JS/JSON only" and does "not touch C++ or Markdown". It does format Markdown, including AGENTS.md — that file is what the failing `Check` job named, and renumbering its ordered invariant list is what oxfmt objected to. Believing the doc is why the failure was first read as a hand-fixable numbering slip. Also record the trap the doc hid: a `pull_request` build formats the merge commit, so a branch whose own `fmt:check` is green fails CI whenever it and main have each appended an invariant and the numbers collide. Verified: oxfmt scans .md and skips .cpp entirely, renumbers `19, 19, 20` to `19, 20, 21`, and leaves lazy `1.` numbering alone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GePs22dNhggr8XKDc1DThG
…ating one
Two independent defects in the purge-advance path, both found by the pre-push
review of this branch.
nextReadableLogBuffer() asked _findPosition(0) for "what comes after N". That
walks backward from the current sequence and stops at the first gap, so it
names the bottom of the contiguous run ending at the current segment. That is
the oldest survivor only when the deletions form a single prefix. With a
survivor between two holes - a purge({all}) that continued past a segment it
could not unlink, or segments deleted out of band and registered that way at
load - it lands past the survivor, and that segment's committed entries are
never yielded to the reader. Silently: the skip has no signal.
_nextLogId() (TransactionLogStore::nextSequenceAfter, sequenceFiles.upper_bound)
answers the question actually being asked, in one O(log n) lookup. The probe is
a bounded loop rather than one hop, because a registered successor can be absent
too - unlinked out of band, or by another process's retention, before this
process's purge run forgets it - and stopping at the first one that will not map
is the same permanent wedge. It terminates because _nextLogId() strictly
increases and is capped at the latest sequence.
Separately, resolving a registered-but-closed segment opened it, and
TransactionLogFile::open() creates (O_RDWR | O_CREAT, and OPEN_ALWAYS on
Windows). Probing a segment that discovery registered but that is no longer on
disk therefore recreated it as a header-only ghost that the next startup
registers again. openIfPresent() skips a definite absence, using the same
reasoning ensureExtent() already documents - only a definite absence skips,
since a stat that errors leaves the extent unresolved - and is meaningful
because dataSetsMutex is held across the check and the open. All three paths
that resolve a segment go through it: getLogFileSize(), getMemoryMap(), and
findPositionByTimestamp()'s backward walk, which skips to the previous sequence
instead of opening. The walk's outcome is unchanged, since an absent file
yielded position 0 and continued anyway; only the ghost goes away.
An already-open segment is unaffected by any of this: its handle still describes
the file, and on POSIX its unlinked inode is still exactly the committed history
this branch exists to keep serving.
The regression covers the successor lookup against a two-hole registry, a
registered successor that is itself absent, and the absence of resurrection
across all three resolve paths. It deliberately does not drive an iterator end
to end: findPositionByTimestamp() keeps the backward-walk shape, so after a
restart with holes no entry point positions a reader below one - measured
_findPosition(0) = 5 and startFromLastFlushed = 5 on a 1/3/5 layout - and that
shape is only reachable for an iterator already live when the holes appear.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GePs22dNhggr8XKDc1DThG
…ositioning
Two follow-ups approved after review, both regressions this branch introduced or
left behind.
The resync bound at src/transaction-log-reader.ts:133 fell back to the raw
mapping length when the store reports no extent for a purged segment. Invariant
11 forbids exactly that, and prior behaviour was dataEnd = 0 (no scan), so this
branch introduced the failure rather than inheriting it: findResyncPosition
tries every start offset, so a mapped-capacity bound byte-scans the whole
pre-extended map on the JS thread, finds nothing (zeros never satisfy
frameFits), and returns undefined - reporting a recoverable mid-log break as a
torn tail, the harper#2016 amputation the invariant exists to prevent. It now
uses the cached extent or readableExtent(), which walks the frames to the
end-of-entries marker when the store has forgotten the segment.
That does not cover every case, and the gap is worth naming: endOfEntries()
deliberately returns the whole mapping when framing is broken, so that it never
becomes the thing that decides a corrupt frame ends the log. For a purged
segment whose framing is broken - the case that reaches corruptFrame in the
first place - the bound is therefore still the mapped capacity. Every other
purged-segment case is fixed and none is made worse, but choosing a bound when
no authoritative extent exists (the store has forgotten the segment and the
frame walk is defeated by the break) is a design question left open.
findPositionByTimestamp() carried the same defect just fixed in
nextReadableLogBuffer(), and a worse consequence. It stepped with
sequenceFiles.find(--sequenceNumber), so the walk ended at the first missing
sequence: after out-of-band deletion a reader asking for timestamp 0 silently
received only the newest contiguous run while every older survivor sat
registered, on disk, with a valid extent. It now descends by map order, and
tracks the next registered sequence above the entry being examined so the two
"the timestamp belongs further up" exits name a segment that exists rather than
sequenceNumber + 1, which a hole may have removed. Only a segment that actually
opened becomes that tracked sequence: both exits hand it back as a position to
read from, so a registered-but-absent one would send the reader to a file that
is not on disk.
Fixing positioning is what makes the end-to-end case testable at all: before it,
no entry point could put a reader below a hole, which is why the earlier
regression asserted the successor primitive instead. The test now covers a
three-wide gap - 2 and 4 never registered, 3 registered but absent - asserting
query({start: 0}) yields [1, 5] where the old lookup started at 5 and yielded
[5], plus a timestamp past every segment so the other exit is exercised rather
than only the position-zero path. Its read order is load-bearing and says so:
reading a segment's extent opens it, and an open handle keeps reporting the real
size after an unlink (invariant 20), so nothing may touch a segment before it is
meant to be gone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GePs22dNhggr8XKDc1DThG
Carry the append-owned readable extent with retained memory maps and invalidate stale flushed-state correlations when destructive purge empties a live store. 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>
Use atomic no-create opens for read probes and verify flushed-state writes through their pathname so purge cannot leave ghost segments or stale retention state. Co-Authored-By: GPT-5 Codex <noreply@openai.com>
The Windows-only "converge on a purge refused by a live mapping" test asserted that a live reader mapping refuses the unlink there. Windows CI showed both outcomes for that test across this branch's heads (refused at 9a8606a, removed at c8e790a) with no change to the mapping's lifetime in between, so the outcome of any single purge run is not something to assert on that platform. Replace it with a cross-platform test of the contract the code actually implements: the reader keeps every entry it mapped, and retention reclaims the segment once nothing maps it — immediately where the unlink lands, on the next run where it did not. It reads the mapping reference after the purge so V8 cannot collect it first, which is what made the old test's premise unverifiable. The three POSIX-only tests that assert a first-run deletion now say why they are skipped on Windows. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CVAGTbmYzkKxvuH4HQjXKd
The purge's refused-unlink branch (segment stays registered, one warn line per run, next run reclaims it) had no test that reached it: POSIX always unlinks and the Windows outcome is not predictable. An unwritable store directory fails the unlink with EACCES, which is the same branch a sharing violation takes, so the contract — including end-to-end delivery of the `log.warn` line — is now covered deterministically where permissions apply. `lastRemoveError` is a plain std::error_code written under fileMutex; the purge read it unlocked, which can tear its value/category pair against a concurrent retirement of the same file. Read it through a locked accessor. Also trims the `nextReadableLogBuffer` header to the two constraints the code cannot state itself; the rest restated invariant 22 verbatim. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CVAGTbmYzkKxvuH4HQjXKd
The rebase onto main moved this branch's purge invariant from 22 to 23
(main appended its own 22, "queued unlock callback"), which collided
with this branch's existing invariant 23 ("databaseFlushed persists and
verifies txn.state by pathname"). Bump the latter to 24.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Three comments still cited "invariant 22" for the purge/read-coherence rule after the rebase renumbered it to 23 (main's own new invariant 22, queued unlock callback, now occupies that number). Caught by the independent pre-push review on the rebased head. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
main's own rebase-picked invariant 23 (deferred column-family drops, #850) now occupies the number this branch's purge invariant held. Move purge to 24 and databaseFlushed to 25, and fix the two source comments and the one AGENTS.md self-reference that cited the old number 23 for the purge invariant. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Dispatch-Task: pr-maint-68bd037170be49b2a01e194607ecae40
…a hot-path refcount
Pre-push review (round 30, codex+cursor-grok+gemini+harper-domain) found
two more places this PR's own non-creating-open fix missed:
- TransactionLogStore::load() opened both the surviving current segment
and each older segment scanned for a recovery boundary with the
creating open(). A file deleted out of band between the directory
scan and that open (this same load()) would silently come back as a
header-only ghost, exactly the resurrection this PR closed on the
read path (openIfPresent/openExisting). Both call sites now use
openExisting() and treat a genuine absence like any other
non-fatal open failure already handled there.
- TransactionLogFile::publishReadableExtentLocked() runs on every
append (the commit hot path) and copied a shared_ptr<MemoryMap> to
read one field, paying two atomic refcount ops for no ownership
need. Now takes memoryMap.get() and only falls back to
frozenMapCache.lock() (which has no raw-pointer equivalent) when
there is no live memoryMap.
The refused-unlink-drops-current-segment-mapping finding from the same
round is real (three independent legs converged on it) but reachable
only through purgeLogs({ destroy: true }), which Harper does not run
in production, and touches the destroy path this PR has deliberately
left alone since the September 2 scope correction — recorded in the PR
body for the human reviewer instead of fixed here.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Dispatch-Task: pr-maint-68bd037170be49b2a01e194607ecae40
…omments
README claimed native code holds every file's map until purge/close;
frozen (rotated) files are only weakly cached (frozenMapCache) and
survive solely through JS Buffer references, independent of purge or
close. Also corrects stats.memory.activeMaps: it counts only the
current file's strongly-held map, not a frozen one kept alive by JS.
Trims three comments added by the last commit that narrated this PR's
own history ("this PR closed on the read path") instead of stating the
invariant itself.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Dispatch-Task: pr-maint-68bd037170be49b2a01e194607ecae40
…24 refs Rebasing onto main (which landed its own new invariant 24, the two process-wide clocks note) collided with this branch's purge invariant, also numbered 24. Resolved by keeping main's 24 and bumping the purge invariant to 25 — but that collided with this branch's own databaseFlushed invariant, already numbered 25 from an earlier rebase's renumbering. Bumped databaseFlushed to 26 and fixed the three "invariant 24" cross-references (AGENTS.md, transaction-log-reader.ts, transaction_log_store.cpp) that meant the purge invariant. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Dispatch-Task: pr-maint-68bd037170be49b2a01e194607ecae40
The README's memory-map section said a frozen (rotated/purged) file's map is weakly held and excluded from stats.memory.activeMaps unconditionally. That's only true on POSIX. On Windows, TransactionLogFile::getMemoryMapLocked() never applies the weak-for- frozen optimization -- every frozen read re-creates the mapping and re-pins it strongly for the file's life (by design, per the comment above that function), and getStats() counts memoryMap into activeMaps regardless of platform. Surfaced by this rebase's pre-push review. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Dispatch-Task: pr-maint-68bd037170be49b2a01e194607ecae40
…ebase main's own #787 landed invariants 26-28 in the same slot this branch's purge invariant occupied (25), so the rebase conflict resolution moved purge to 29 and its sibling databaseFlushed invariant (which collided with main's new 26) to 30. Fixed the three stale in-code "invariant 25" cross-references this displaced (AGENTS.md, transaction-log-reader.ts, transaction_log_store.cpp), following the whole-tree re-grep lesson already recorded in this PR's Findings. Dispatch-Task: pr-maint-68bd037170be49b2a01e194607ecae40 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Pre-push review round 1 (codex) caught two factually wrong references that predate this rebase and were never corrected by earlier renumbering passes: - test/transaction-log.test.ts:3112 cited "invariant 20" (now the transaction-timestamp invariant) for a property that invariant 29 (the purge/mapping invariant) actually documents. - src/transaction-log-reader.ts's corruptFrame() docstring named `getLogFileSize` as the store-mutex-taking call being avoided, but the caller resolves the extent via `readableExtent()` (a lock-free atomic accessor), not `getLogFileSize`. Dispatch-Task: pr-maint-68bd037170be49b2a01e194607ecae40 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Pre-push delta review caught that my prior fix (404b19ef) misattributed the readableExtent() resolution to "the caller" -- corruptFrame() itself calls it (conditionally, on a logBuffer.size cache miss) at line 134 for the readUncommitted branch. Describe the actual cache-then-native-fallback mechanism instead. Dispatch-Task: pr-maint-68bd037170be49b2a01e194607ecae40 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…econd rebase main advanced again mid-review (PR #868, "Keep user shared buffers for the life of the column family") and landed its own new invariant 29, colliding with this branch's purge invariant. Kept both (main's 29 stays, purge moves to 30, databaseFlushed to 31) and re-grepped the whole tree for stale "invariant 29" cross-references left behind by the collision, per the lesson already recorded in this PR's Findings. Dispatch-Task: pr-maint-68bd037170be49b2a01e194607ecae40 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Round 3 (domain) flagged the docstring as restating the cache-then- native-fallback pattern already visible in the code two lines below (logBuffer.size ?? readableExtent(logBuffer)) plus the existing inline comment above it. Cut it down to the one-line purpose statement. Dispatch-Task: pr-maint-68bd037170be49b2a01e194607ecae40 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
ed8527d to
3a6242e
Compare
purgeLogs()removes a retired segment's pathname, but an iterator that already holds its immutable mapping can safely finish reading that committed history. This PR keeps that behavior while making the mapping collectible and ensuring readers resume at the next real survivor after a purged run.What changed
TransactionLog._currentLogBufferis weak, matching the per-segment cache, so a long-lived iterator no longer pins a deleted mapping after it advances.nextReadableLogBuffer()walks the registered successor set instead of deriving a jump from the newest contiguous run. It crosses multiple holes without skipping an intervening survivor, and stops rather than skipping a registered segment that is only temporarily unmappable.TransactionLogFile::openExisting(), backed byO_RDWRwithoutO_CREATon POSIX andOPEN_EXISTINGon Windows. A segment removed between discovery and the OS open therefore remains absent instead of being recreated as a header-only ghost.TransactionLogStore::load()'s two startup opens (activating the surviving current segment, and the backward recovery-boundary scan) now use the same non-creating open, closing the two remaining ghost-recreate paths this round's review found.readableExtent()requires that exact bound, so purge cannot make a reader infer authority from frame-shaped bytes left by a failed append or from unused mapped capacity.publishReadableExtentLocked(), which runs on every append, now reads the live map through a raw pointer instead of copying theshared_ptr, so the commit hot path pays no refcount traffic in the common case.writeFlushedPosition()reopens and verifiestxn.stateby pathname, preventing a pre-purge callback or an unlinked/replaced stream from publishing stale retention state.log.warnline is asserted end to end through the public global-event API, and the next run reclaims it.lastRemoveErroris read through afileMutex-held accessor rather than unlocked.README.md's memory-map section describes the actual ownership split: only the current file's map is held natively; a frozen (rotated) file's map is weakly cached on POSIX and survives solely through JSBufferreferences, sostats.memory.activeMapscounts only the current file there. On Windows the weak-for-frozen optimization does not apply — every frozen read re-pins the mapping strongly for the file's life, andactiveMaps/mappedBytescount it.For the human reviewer
log.warn, no marker on the resumed entry — so a follower that lost segments 6–8 believes its history is contiguous. Stopping instead is the wedge this PR fixes, so the choice is between silent skip and a new reader-visible signal Harper would have to handle; it is cheaper to decide now than to retrofit.purgeLogs({ destroy: true })can restart segment numbering while a JavaScript buffer cache from the prior store generation survives. That pre-existing cache-identity problem is outside this PR; Harper does not use destructive log-store purge in production.9a8606a8and failed on all three (both attempts) atc8e790a0, with nothing in between changing how long the mapping lives. Only the convergence is asserted cross-platform now; the first-run deletion is asserted on POSIX only. Worth a word if you know which Windows rule applies here.removeFileLocked()drops its strongmemoryMapand closes the fd before it knows whether the unlink succeeded. IfpurgeLogs({ destroy: true })fails to delete the current segment (EACCES, or a Windows sharing violation) while a bound transaction keeps the store alive, the next append reopens the file and builds a newMemoryMap; a reader still holding the old buffer never sees the new entries, because its extent and overlay stop advancing. Confirmed independently by codex, cursor-grok and the harper-domain adjudicator across five rounds now (all "Scope: in-scope"), but reachable only throughpurgeLogs({ destroy: true }), which Harper does not run in production, and squarely inside thedestroypath this PR has deliberately left untouched since an earlier scope correction — every attempt at hardening that path in earlier rounds kept surfacing a new destroy-path race and was reverted. Filing this as a separate issue rather than reopening that path here.txn.state's 8-byte record is overwritten in place with no fsync, no temp-then-rename, and no checksum. A crash mid-write can leave a tornLogPosition(e.g. a newer sequence number paired with an older offset); retention would then trust it as the flushed boundary and could delete a segment whose tail never reached RocksDB. This predates this PR (the old code overwrote the same record the same way) and the practical exposure is narrow — a torn write inside one 8-byte range is rare on filesystems with ordered/journaled metadata — but it is a real gap in the hardening this PR added aroundtxn.state. Reviewed and left alone rather than restructuring that file's durability inside this PR; a proper fix is write-temp-then-atomic-rename.purgeLogs({ destroy: true })can leave a stale flushed-state generation authoritative. The generation bump, correlation-ring reset, andtxn.stateclear only run when the destructive purge emptiessequenceFilesentirely. If one segment's unlink is refused (the sameEACCES/Windows-sharing-violation case as item 5) while a later, already-flushed segment is successfully deleted, the registered set stays non-empty and that whole block is skipped —txn.statesurvives naming the deleted segment's sequence number. After a restart the refused segment becomes current, a later append can reuse the now-vacant sequence number the staletxn.statestill names, and retention can then delete that reused segment as "already flushed" before it reaches RocksDB. Confirmed independently by codex and the harper-domain adjudicator (severity settled at minor after two rounds: the trigger needs a destroy, a refused unlink below the flushed segment, continued writes, and a restart before the next flush — narrow, and againdestroy: true-only). The fix is a one-branch policy choice — stop the destructive purge at the first refused unlink (restoring main's effective behavior, since main threw there), or invalidate the generation whenever the segmenttxn.statenames is removed regardless of whether the whole set emptied — and belongs with item 5 in the same follow-up rather than reopening thedestroypath here.transaction-log-reader.ts:419-429cacheslogBuffer.sizeafter an advance without re-checkinglatestLogId > logBuffer.logId, unlike its sibling at:289-296. If the advance lands on the still-current segment, aHEADER_SIZE-sized snapshot is stamped onto the shared_logBuffersentry and every entry later written to that segment is skipped once it rotates. This is pre-existing —mainhas the identical shape withgetLogFileSize()in place ofreadableExtent()— so it is left for a separate issue rather than widened into this PR; flagging it here because it is the same silent-entry-loss class this PR is about.memory.overlayBytesover-reports after a rotated segment's map is weakly released.collectStats()sums every registered file'slastOverlaySizeregardless of whether native still holds the strong map, whilemappedBytes/activeMaps(the adjacent counters) are correctly gated on it. OncedowngradeMapToFrozen()drops the strong reference (pre-existing onmain), the overlay watermark can describe an already-unmapped region until retention unregisters the segment. This branch's weak_currentLogBuffermakes the staleness surface more often in practice. Stats/diagnostics only — no data-safety impact. The domain adjudicator ruled it pre-existing and out of this diff's scope; left for separate follow-up rather than fixed here.Rebase / maintenance history (this session)
origin/mainadvanced twice during this maintenance pass, each time landing its own newAGENTS.mdinvariant that collided with this branch's purge invariant number — the same renumbering dance as every prior rebase round:origin/mainhad merged PR Serialize database destruction with concurrent opens #787 ("Serialize database destruction with concurrent opens") among 15 other commits, adding invariants 26–28. Rebased cleanly (git merge-tree --write-tree origin/main HEADmatchedHEAD^{tree}); the purge invariant moved 25→29,databaseFlushed26→30. Re-grepped the whole tree afterward per this PR's own recorded lesson and fixed three stale in-code "invariant 25" cross-references the collision left behind.origin/mainadvanced again (PR Keep user shared buffers alive for the life of the column family instead of freeing them on garbage collection #868, "Keep user shared buffers for the life of the column family") and added its own invariant 29, colliding a second time. Rebased cleanly again; purge moved 29→30,databaseFlushed30→31. Same whole-tree re-grep, same fix pattern.No semantic conflict either time —
git merge-tree --write-tree origin/main HEADequalsHEAD^{tree}at the final head;origin/mainis a full ancestor.Independent review (this session)
Four rounds: round 1 (full, on the first rebase) — codex+gemini+cursor-grok+harper-domain re-raised the standing findings (items 5, 6, 8 above) plus two genuinely new items from a fresh read: item 7 above (partial-destroy stale flushed-state generation, new this session) and two factually stale in-code comments a prior rebase's renumbering had left behind (a test comment citing the wrong invariant number entirely, and a
corruptFrame()docstring naming a function the code no longer calls) — both fixed. Round 2 (delta) confirmed both fixes and caught a mis-attribution in my own fix, which I corrected. The second rebase forced round 3 (full, per this PR's standing rule that a rebase re-review is always full): re-confirmed the same standing set with no new actionable findings; a Gemini "blocker" (unlockedshared_ptrread) was refuted directly from source (fileMutexis held by every caller). Round 4 (delta, on a trailing comment trim) converged: no new findings, cursor-grok's own leg failed on a formatting error in its response (not a coverage gap — codex+gemini+domain all completed). Receiptindependent=true,reviewers=codex,gemini,harper-domain, covers the pushed head exactly.Verification
pnpm buildclean;pnpm check(type-check, oxlint, oxfmt) clean throughoutpnpm test:native— 243 passednode --expose-gc vitest run(transaction-log, backup-transaction-logs, destroy, transaction-log-crash-recovery) — 162 passed, 0 failed, on the final pushed headpnpm test— 1059 passed / 10 skipped / 0 failed (71 files), on the final pushed headorigin/mainis an ancestor ofHEAD;git merge-tree --write-tree origin/main HEADequalsHEAD^{tree}at the pushed headindependent=true, receipt keyed exactly to the pushed headRefs HarperFast/harper#2337
Complexity: moderate
Generated by GPT-5 Codex; maintained by Claude Sonnet 5
Origin — the dispatch brief this PR was written from
Release a purged transaction-log segment's mapping instead of refusing to read it
Maintain #820 (Release a purged transaction-log segment's mapping instead of refusing to read it) on branch fix/txnlog-purge-read-coherence. Read the current PR head, mergeability, and latest check runs before acting; the dispatch observation may be stale. First read the remote head for refs/heads/fix/txnlog-purge-read-coherence without updating local refs and require it to equal this task's observed head 5396e00; if it differs, stop with needs-input because remote history changed after dispatch. Then fetch only refs/heads/main from origin into refs/remotes/origin/main. If Git reports a paused rebase for this task's exact generation branch, require that rebase's recorded original head to equal 5396e00 and its recorded onto commit to equal the fetched origin/main tip; stop with needs-input on either mismatch. Only then resume it without repeating the local-HEAD ancestry check or starting another rebase. Otherwise record the remote SHA, verify it is an ancestor of local HEAD, and rebase onto the fetched base. If Context carries companion instructions, follow them exactly for every named gitlink; they override ordinary conflict handling. Preserve both sides' intent for every other conflict. Stop with needs-input on semantic conflicts. Force-with-lease is authorized only for this rebase. Immediately before pushing, re-read the PR's base ref and body plus every companion PR head/state named in Context. The base must still be main, and the declarations and submodule bindings must still match Context. If an open companion advanced, update its named gitlink to the new exact head and rerun relevant tests. If it merged, point the named gitlink at the companion repository's current default-branch tip after verifying that tip contains the merge, then rerun relevant tests. If it closed without merging, stop with needs-input and do not publish its abandoned head. If the base, declaration, binding or any other companion state changed ambiguously, stop with needs-input. Then push the rebased head with
git push --force-with-lease=refs/heads/fix/txnlog-purge-read-coherence:EXPECTED_HEAD_SHA origin HEAD:refs/heads/fix/txnlog-purge-read-coherence, substituting the recorded SHA. If the remote head changes, stop with needs-input; never overwrite intervening remote work. Do this before evaluating CI. Then inspect CI for the resulting current PR head, not old-head failures. Wait for relevant pending checks, diagnose remaining failures, fix them, run relevant tests, and push in THIS task. Do not create a separate CI-fix task. Stop with needs-input for judgment calls or an unavailable CI result; report exactly what was verified. No merge is authorized.Dispatch: task
pr-maint-68bd037170be49b2a01e194607ecae40· queued by automation · ran by claude/sonnet/xhigh · worker kzyp-xps-1Review-Coverage: authored=claude; ran=gemini,claude,codex,cursor-grok; adjudicated=domain; blocked=claude(fallback)(out-of-budget); declined=cursor-composer; rounds=39; full=2 @ 3a6242e
Human-Review-Need: 4 (decisions: destroy-refusal-policy, mapping-carried-extent, windows-first-run-unexplained, txn-state-reopen-per-flush, gc-driven-map-release) @ 3a6242e