Skip to content

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

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

Release a purged transaction-log segment's mapping instead of refusing to read it#820
kriszyp wants to merge 12 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

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. Independent review converged at round 25 on 770b69b8 (codex + gemini + harper-domain). Three findings were fixed in that round (refused-unlink coverage, the unlocked lastRemoveError read, purge-warning delivery). Two are deliberately left for you: the reader-gap policy in item 1, and writeFlushedPosition() verifying txn.state by pathname on every flush including the unchanged-position case — that is the invariant-23 hardening as designed, but it costs an open()/read()/close() per quiescent bound store on RocksDB's flush thread, and moving it behind positionChanged is a one-line reversal if you would rather not pay it.

Verification

  • pnpm check
  • pnpm test — 939 passed, 9 skipped
  • pnpm test:native — 203 passed
  • node --expose-gc ./node_modules/vitest/vitest.mjs test/transaction-log.test.ts — 101 passed
  • Earlier head: targeted Deno type-tag and missing-readable-extent cases — 2 passed; the full Deno run was not clean because the unrelated lingering-txn-shutdown child aborted (918 passed, 27 skipped before it). Bun is not installed locally.
  • Windows, Bun and Deno are covered by CI on this head.

Refs HarperFast/harper#2337

Complexity: moderate

Generated by GPT-5 Codex; maintained by Claude Opus 5

Review-Coverage: authored=claude; ran=codex,gemini; adjudicated=domain; declined=cursor-grok,cursor-composer; rounds=25 @ 770b69b

Human-Review-Need: 4 (decisions: reader-gap-policy, txn-state-verify-every-flush, chmod-forced-unlink-test, weakref-current-log-buffer, extent-carried-on-mapping, store-wide-flush-generation, invariant-23-scope) @ 770b69b

@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.34K ops/sec 41.08 39.59 492.442 0.117 121,712
🥈 rocksdb 2 10.93K ops/sec 91.46 87.93 24,087.25 0.975 54,670

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

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 lmdb 1 28.75K ops/sec 34.79 33.73 1,569.22 0.117 143,735
🥈 rocksdb 2 10.18K ops/sec 98.25 93.81 606.464 0.051 50,891

ranges.bench.ts

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

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 lmdb 1 25.76K ops/sec 38.82 35.65 1,717.226 0.297 128,809
🥈 rocksdb 2 15.62K ops/sec 64.00 56.03 1,061.983 0.115 78,123

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 357.78 ops/sec 2,795.001 67.85 34,136.075 11.30 716
🥈 lmdb 2 26.45 ops/sec 37,811.218 428.767 1,191,848.768 137.009 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 34.73K ops/sec 28.79 13.14 20,976.129 0.852 173,665
🥈 lmdb 2 446.11 ops/sec 2,241.586 133.299 23,842.271 1.47 2,231

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 603.93K ops/sec 1.66 1.39 4,833.057 0.203 3,019,669
🥈 lmdb 2 469.20K ops/sec 2.13 1.06 8,694.156 0.514 2,345,987

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 827.34 ops/sec 1,208.69 1,020.369 2,066.049 0.377 1,655
🥈 lmdb 2 1.16 ops/sec 861,295.71 827,824.336 911,027.592 2.17 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 23.03K ops/sec 43.43 29.59 20,519.554 2.09 46,056
🥈 lmdb 2 827.22 ops/sec 1,208.864 94.93 9,959.561 5.18 1,655

Results from commit 89fb394

@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 and others added 4 commits September 9, 2026 15:25
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
@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 and others added 8 commits September 9, 2026 17:30
…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
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