Skip to content

Release a purged transaction-log segment's mapping instead of refusing to read it - #820

Draft
kriszyp wants to merge 25 commits into
mainfrom
fix/txnlog-purge-read-coherence
Draft

kriszyp wants to merge 25 commits into
mainfrom
fix/txnlog-purge-read-coherence

Conversation

@kriszyp

@kriszyp kriszyp commented Sep 3, 2026

Copy link
Copy Markdown
Member

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._currentLogBuffer is 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.
  • Read probes use TransactionLogFile::openExisting(), backed by O_RDWR without O_CREAT on POSIX and OPEN_EXISTING on 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.
  • A mapping carries the append-owned readable extent, exposed through a type-tagged N-API getter. 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 the shared_ptr, so the commit hot path pays no refcount traffic in the common case.
  • An all-segment purge advances a store generation and clears old flush correlations. writeFlushedPosition() reopens and verifies txn.state by pathname, preventing a pre-purge callback or an unlinked/replaced stream from publishing stale retention state.
  • Purge-vs-mapping is now tested as a contract rather than a platform outcome: a reader keeps every entry it mapped and retention reclaims the segment once nothing maps it, and an unwritable store directory forces the refused-unlink branch — the segment stays registered, the log.warn line is asserted end to end through the public global-event API, and the next run reclaims it. lastRemoveError is read through a fileMutex-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 JS Buffer references, so stats.memory.activeMaps counts 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, and activeMaps/mappedBytes count it.

For the human reviewer

  1. A purged segment intentionally remains readable through a mapping acquired before purge. The mapping is immutable committed history; the fix releases it after the iterator advances rather than invalidating it mid-read. The open decision underneath it is the reader-gap policy: a subscription that falls behind retention now skips the deleted run and keeps yielding with no signal — no throw, no 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.
  2. The hole policy intentionally distinguishes absence from temporary unavailability: absent registered paths are skipped through the successor lookup, while a segment that still has a durable extent but cannot currently map causes the iterator to stop and retry later.
  3. 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.
  4. Windows purge-vs-mapping is unexplained, and is no longer asserted. A Windows-only test here claimed a live reader mapping refuses the unlink; it passed on all three Windows Node jobs at 9a8606a8 and failed on all three (both attempts) at c8e790a0, 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.
  5. Real, deliberately not fixed here — a refused unlink of the current segment strands a reader on a stale mapping. removeFileLocked() drops its strong memoryMap and closes the fd before it knows whether the unlink succeeded. If purgeLogs({ 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 new MemoryMap; 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 through purgeLogs({ destroy: true }), which Harper does not run in production, and squarely inside the destroy path 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.
  6. Pre-existing, narrow — 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 torn LogPosition (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 around txn.state. Reviewed and left alone rather than restructuring that file's durability inside this PR; a proper fix is write-temp-then-atomic-rename.
  7. Real, new this round, deliberately not fixed here — a partial purgeLogs({ destroy: true }) can leave a stale flushed-state generation authoritative. The generation bump, correlation-ring reset, and txn.state clear only run when the destructive purge empties sequenceFiles entirely. If one segment's unlink is refused (the same EACCES/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.state survives 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 stale txn.state still 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 again destroy: 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 segment txn.state names is removed regardless of whether the whole set emptied — and belongs with item 5 in the same follow-up rather than reopening the destroy path here.
  8. transaction-log-reader.ts:419-429 caches logBuffer.size after an advance without re-checking latestLogId > logBuffer.logId, unlike its sibling at :289-296. If the advance lands on the still-current segment, a HEADER_SIZE-sized snapshot is stamped onto the shared _logBuffers entry and every entry later written to that segment is skipped once it rotates. This is pre-existingmain has the identical shape with getLogFileSize() in place of readableExtent() — 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.
  9. Pre-existing, stats-only — memory.overlayBytes over-reports after a rotated segment's map is weakly released. collectStats() sums every registered file's lastOverlaySize regardless of whether native still holds the strong map, while mappedBytes/activeMaps (the adjacent counters) are correctly gated on it. Once downgradeMapToFrozen() drops the strong reference (pre-existing on main), the overlay watermark can describe an already-unmapped region until retention unregisters the segment. This branch's weak _currentLogBuffer makes 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/main advanced twice during this maintenance pass, each time landing its own new AGENTS.md invariant that collided with this branch's purge invariant number — the same renumbering dance as every prior rebase round:

  • First rebase: origin/main had 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 HEAD matched HEAD^{tree}); the purge invariant moved 25→29, databaseFlushed 26→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.
  • Second rebase, mid-review: origin/main advanced 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, databaseFlushed 30→31. Same whole-tree re-grep, same fix pattern.

No semantic conflict either time — git merge-tree --write-tree origin/main HEAD equals HEAD^{tree} at the final head; origin/main is 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" (unlocked shared_ptr read) was refuted directly from source (fileMutex is 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). Receipt independent=true, reviewers=codex,gemini,harper-domain, covers the pushed head exactly.

Verification

  • pnpm build clean; pnpm check (type-check, oxlint, oxfmt) clean throughout
  • pnpm test:native — 243 passed
  • node --expose-gc vitest run (transaction-log, backup-transaction-logs, destroy, transaction-log-crash-recovery) — 162 passed, 0 failed, on the final pushed head
  • Full pnpm test — 1059 passed / 10 skipped / 0 failed (71 files), on the final pushed head
  • Merge integrity: origin/main is an ancestor of HEAD; git merge-tree --write-tree origin/main HEAD equals HEAD^{tree} at the pushed head
  • Independent review: 4 rounds this session (codex + gemini + cursor-grok + harper-domain), independent=true, receipt keyed exactly to the pushed head

Refs 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-1

Review-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

@kriszyp
kriszyp requested a review from cb1kenobi September 3, 2026 01:08

@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 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.

Comment thread test/transaction-log.test.ts Outdated
Comment thread test/transaction-log.test.ts Outdated
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

get-sync.bench.ts

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

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 lmdb 1 24.17K ops/sec 41.37 39.83 606.92 0.112 120,863
🥈 rocksdb 2 11.54K ops/sec 86.65 83.52 23,942.922 0.967 57,707

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

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 lmdb 1 28.49K ops/sec 35.09 33.91 548.999 0.107 142,471
🥈 rocksdb 2 11.75K ops/sec 85.09 83.27 555.685 0.048 58,761

ranges.bench.ts

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

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 lmdb 1 22.62K ops/sec 44.20 38.63 1,811.706 0.280 113,119
🥈 rocksdb 2 15.60K ops/sec 64.11 56.50 1,089.161 0.121 77,985

realistic-load.bench.ts

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

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 rocksdb 1 348.43 ops/sec 2,870.008 68.30 67,406.572 14.23 698
🥈 lmdb 2 26.88 ops/sec 37,197.622 460.996 1,168,454.918 136.315 64.00

transaction-log.bench.ts

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

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 rocksdb 1 36.09K ops/sec 27.71 11.62 20,513.668 0.843 180,439
🥈 lmdb 2 444.17 ops/sec 2,251.415 113.189 25,470.298 1.54 2,221

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

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 rocksdb 1 616.66K ops/sec 1.62 1.40 4,942.504 0.204 3,083,276
🥈 lmdb 2 460.73K ops/sec 2.17 1.21 9,972.754 0.572 2,303,650

worker-put-sync.bench.ts

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

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 rocksdb 1 829.25 ops/sec 1,205.915 1,047.263 2,064.763 0.302 1,659
🥈 lmdb 2 1.16 ops/sec 861,597.44 785,294.281 924,747.607 3.43 10.00

worker-transaction-log.bench.ts

Transaction log with workers > write log with 100 byte records

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 rocksdb 1 22.10K ops/sec 45.25 29.19 20,422.185 2.08 44,201
🥈 lmdb 2 816.95 ops/sec 1,224.067 103.125 14,296.427 5.54 1,634

Results from commit 8c2b320

@kriszyp
kriszyp force-pushed the fix/txnlog-purge-read-coherence branch 2 times, most recently from 6855e4b to 9398f6b Compare September 3, 2026 06:08
@kriszyp kriszyp changed the title Stop serving a purged transaction-log segment from a stale memory map Release a purged transaction-log segment's mapping instead of refusing to read it Sep 3, 2026
@kriszyp
kriszyp marked this pull request as ready for review September 4, 2026 05:03
Comment thread test/transaction-log.test.ts Outdated
@kriszyp
kriszyp force-pushed the fix/txnlog-purge-read-coherence branch 2 times, most recently from 7751c8f to 20d25cf Compare September 9, 2026 14:33
Comment thread src/transaction-log-reader.ts Outdated
@kriszyp
kriszyp force-pushed the fix/txnlog-purge-read-coherence branch from 93facff to 112c6cf Compare September 9, 2026 21:53
Comment thread src/transaction-log-reader.ts Outdated
Comment thread src/transaction-log-reader.ts Outdated
Comment thread src/binding/transaction_log/transaction_log_store.cpp Outdated
@kriszyp
kriszyp force-pushed the fix/txnlog-purge-read-coherence branch from 770b69b to 162cf81 Compare September 14, 2026 13:14

@cb1kenobi cb1kenobi left a comment

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.

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 cb1kenobi left a comment

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.

The new commit only corrects three invariant references in comments after the rebase. It introduces no blocking defect on changed lines.


Reviewed 8216e2a

@cb1kenobi cb1kenobi left a comment

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.

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

@kriszyp
kriszyp marked this pull request as draft September 14, 2026 21:50
@kriszyp
kriszyp force-pushed the fix/txnlog-purge-read-coherence branch 3 times, most recently from 5396e00 to 96eceb3 Compare September 21, 2026 14:06
kriszyp and others added 9 commits September 21, 2026 14:50
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>
kriszyp and others added 16 commits September 21, 2026 14:51
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>
@kriszyp
kriszyp force-pushed the fix/txnlog-purge-read-coherence branch from ed8527d to 3a6242e Compare September 21, 2026 21:37
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