Repository navigation
Keep transaction-log retention on a durable sequence floor - #799
Conversation
There was a problem hiding this comment.
Code Review
This pull request refactors the transaction log store to ensure that only closed stores can lose their directory or flush watermark, preventing issues where active stores are deleted during ordinary retention purges. Key changes include updating close logic, rewriting timestamp-based position lookups, writing the flush state via a local stream, and handling watermark resumption on startup. Additionally, the transaction log reader is updated to skip missing segments. Feedback on the reader implementation highlights two potential bugs where empty log segments (containing only the header) could cause the iterator or reader to prematurely terminate; using while loops instead of if conditions is recommended to continuously advance past empty segments.
📊 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 fc27768 |
Directory iteration order is unspecified, so registerLogFile() promoted any segment that briefly held the highest sequence: it marker-enabled and opened that file, and nothing ever demoted it. Every superseded segment kept an append-boundary marker it will never use and an open handle for the life of the store. On Windows the handle is opened without FILE_SHARE_DELETE, so it also made those segments undeletable from outside the process — which is what failed the new already-missing-prefix purge test on all five Windows runtimes (EBUSY on unlink). Discovery now registers without opening; load() marker-enables and opens the file that is still current once the whole directory has been scanned. A current segment that cannot be opened degrades as before (append starts at a fresh sequence) rather than failing the load, while an append-boundary violation stays fatal. Co-Authored-By: Claude Opus <noreply@anthropic.com>
The marker assertion alone can pass against the old code when directory enumeration yields the highest sequence first. Add an open-descriptor check via procfs (Linux) so the assertion does not depend on iteration order, and widen the fixture to six segments. Also drops two comments that narrated PR history rather than the invariant, and corrects the AGENTS.md wording: only the active segment may have a marker created, but a retired segment keeps the one it already earned. Co-Authored-By: Claude Opus <noreply@anthropic.com>
This comment has been minimized.
This comment has been minimized.
`durableFloorSequence` described only the primary branch: the floor is the `txn.state` sequence when a flush position is persisted, but falls back to the newest file, which is retained because it is live rather than because anything under it is durable. Rename both locals to `retentionFloorSequence` — it names the purge boundary itself — and match the README, `docs/stats.md` and `docs/transaction-log.md` phrasing, spelling out the fallback where the docs previously attributed the floor to `txn.state` unconditionally. Also scopes the README's retention bound: `transactionLogMaxSize` bounds the extra floor file an idle store keeps, but not the suffix above the floor that a lagging `txn.state` retains. Points at `purge.retainedUnflushedFiles` for that case. No behavior change. Co-Authored-By: Claude Opus <noreply@anthropic.com>
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
cb1kenobi
left a comment
There was a problem hiding this comment.
Barbarian reviewed a7a967b and found no blocking issues. No confirmed blocking defects were found on the changed lines. Existing discussion was checked, and previously raised or non-blocking concerns were not repeated.
—
Generated by Barber AI
Ordinary and startup transaction-log retention now delete only an eligible contiguous oldest prefix below the persisted flush floor. The sequence file named by
txn.stateand every newer file remain, sotxn.state, its cached file handle, the writer, and the existing contiguous reader stay in the same sequence space.This deliberately does not change transaction-log reader behavior or add JavaScript/native cache-generation APIs.
destroy: trueremains the existing separate destructive path; Harper does not call it in production.An idle store may retain one segment past the age cutoff until a later write rotates and flushes it; that extra segment is bounded by
transactionLogMaxSize, except when a single transaction exceeds that target. The bound covers the floor segment only — a store whosetxn.statelags also retains everything above the floor, whichpurge.retainedUnflushedFilesreports.Windows CI on this branch failed the new
should detach an already-missing prefix entry without wedging retentiontest withEBUSYon all five runtimes, because the segment the test unlinks was still open. The cause is startup discovery, not retention:registerLogFile()promoted whatever segment currently held the highest sequence — opening it and enabling its append-boundary marker — and nothing demoted it when a higher one turned up. Directory iteration order is unspecified, so on a sorted-enumeration filesystem every superseded segment kept an unused marker and an open handle for the life of the store; on Windows that handle is taken withoutFILE_SHARE_DELETE, so those segments were also undeletable from outside the process. Discovery now registers without opening, andload()marker-enables and opens the one file that is still current after the whole directory has been scanned. An unopenable current segment degrades as before — appends resume at a fresh sequence — while an append-boundary violation stays fatal.Terminology follow-up (review feedback): the purge boundary local is now
retentionFloorSequence, notdurableFloorSequence. "Durable" described only the primary branch — with no persisted flush position the floor falls back to the newest file, which is kept because it is live, not because anything below it is durable. The README,docs/stats.mdanddocs/transaction-log.mdsay "retention floor" and now spell out that fallback instead of attributing the floor totxn.stateunconditionally. The same round also scoped the README's disk bound:transactionLogMaxSizebounds the one extra floor file an idle store keeps, but not the suffix above the floor that a laggingtxn.stateretains —purge.retainedUnflushedFilesis what reports that case. No behavior change.Refs HarperFast/harper#846 — Transaction-log retention has no config-driven time/size control (and the storageReclamation hardening isn't on v5.1).
For the human reviewer
directory_iteratoryields the highest sequence first it also leaves exactly one open fd and one marker and the test passes against the code it was written to catch. Deterministic on the sorted-enumeration NTFS runners where this failed; on hash-ordered ext4 it is a coin flip. A real fix drives the enumeration order or asserts that a marker for a lower sequence never appears.getLastWriteTime()failurebreaks the whole scan with only aDEBUG_LOG, which release builds omit. Thebreakis right — the silence is not: one EACCES/EIO segment wedges retention permanently whilepurgeRunsandlastPurgeMskeep advancing. AwriteFailuresbump or atransactionLog:warningwould surface it.!allpath (the floor breaks first), so it is live only fordestroy: true— and the fix(txnlog): erase a failed append's partial bytes instead of baking a framing break into the log #751 test pair that says it "covers the guard itself" no longer reaches it.purgeableFilesapplies the floor but not the contiguous-prefix stop, so it over-reports when a lower sequence has a newer mtime than a higher one (restore copies, an operator'sutimes). Gauge-only, and already documented in place as skew.purgeLogs({ before })is silently subject to the floor and returns[]with no reason code, so a disk-pressure runbook cannot tell "nothing old enough" from "blocked by an unadvancingtxn.state".load()has no read-only signal. Pre-existing and strictly narrowed by this diff (main's discovery-time expiry had no flush guard at all), so it wants its own issue.DiscoverStoresre-runsTransactionLogStore::load()over an already-loaded directory whenever a second descriptor opens the same path (DBKeyis{path, readOnly}; the log registry entry is keyed by path alone). The throwaway store runsrecoverTail()against the segment the live handle is appending to. Worth an issue on its own.Verification
origin/main: an aged current segment is deleted.git diff --checkpassed.1.txnlog.boundarypresent) and passes after.pnpm checkclean.pnpm build:binding,pnpm check, native suite 159 passed, andtransaction-log+transaction-log-stats94 passed.Complexity: moderate
Review-Coverage: authored=codex; ran=claude,gemini; declined=cursor-grok,cursor-composer,domain; rounds=2 @ a7a967b
Human-Review-Need: 3 @ a7a967b