Skip to content

Apply audit retention continuously to RocksDB transaction logs - #2338

Merged
kriszyp merged 20 commits into
mainfrom
kris/846-txnlog-retention
Sep 2, 2026
Merged

kriszyp merged 20 commits into
mainfrom
kris/846-txnlog-retention

Conversation

@kriszyp

@kriszyp kriszyp commented Aug 26, 2026 •

Copy link
Copy Markdown
Member

RocksDB audit retention now runs on the existing self-rearming cleanup cadence instead of depending on startup or disk-pressure signals, so eligible transaction-log segments are reclaimed continuously according to logging.auditRetention. The Rocks delay is a pure function of the pressure-adjusted retention window (a tenth of it, floored at DEFAULT_AUDIT_CLEANUP_DELAY) rather than LMDB's adaptive backoff: segment eligibility only changes on rotation/flush, so a per-entry delete count would only make it rescan the same files.

Fixes #846 — Transaction-log retention has no config-driven time/size control (and the storageReclamation hardening isn't on v5.1). Also fixes #2140, closed as a duplicate.

Retiring the loop is two things, and teardown needs both. stopAuditCleanup() latches the loop closed and returns a drain barrier — the promise that settles once the pass already running has finished. The barrier is what makes closing safe: lmdb-js stamps the DBI number into its write instruction synchronously and the native writer consumes it later, so a pass suspended inside await removeAuditEntry() still has a delete pending against the primary and audit DBIs, and LMDB forbids closing a DBI an open transaction has modified. dropDatabase() and the legacy arm of Table.dropTable() await it. closeDatabase() and branch close() are synchronous and cannot; what covers them is that every environment touch remaining in a resumed pass — cursor advance, cursor release, marker write, re-arm — re-checks rootStore.status, plus the fact that their production callers reach them only for RocksDB stores, whose pass is one synchronous purgeLogs() call with nothing suspended mid-removal. resetDatabases() closes LMDB roots with no retirement call at all, so that re-check is a routine path rather than a defensive one.

The legacy per-table drop retires the audit store the table actually holds. It called primaryStore.auditStore?.stopAuditCleanup?.(), which nothing assigns — openAuditStore() assigns rootStore.auditStore and makeTable() captures the store it was handed — so the call was a no-op against a dead reference. closeDatabase and dropDatabase also deregister storage reclamation now: only closeBranchHandles did, so a closed or dropped store left a handler, and the closure pinning the store, registered for the life of the process, with every re-open of the same path appending another.

The last-removed marker is retained until it commits. It used to be a fire-and-forget put(), and a pass that deletes nothing never reaches the write again, so one transient failure would leave the recorded boundary permanently behind the entries already removed. It is now awaited inside error containment and carried to the next pass. The write that initializes a new audit store has no downstream owner at all — openAuditStore() is synchronous and returns the store — so its rejection is contained at the call site, through the same warnContained() helper as the pass's outermost backstop, because a failing log sink would otherwise turn one contained rejection into an uncontained one.

The pin moves to @harperfast/rocksdb-js 2.8.0, which is a prerequisite rather than hygiene. The previously pinned 2.7.1 already refuses to purge above the durable flush floor, but at the boundary segment it compares logFile->size without resolving the extent: a lazily-registered segment reads 0, so 0 > positionInLogFile is false, the guard passes, and the segment is deleted with a tail that never reached RocksDB. 2.8.0 calls ensureExtent() there and refuses to purge when the extent cannot be resolved; its should not purge a log file whose tail is past the flushed position regression covers exactly that case and does not exist in 2.7.1. One purge per boot rarely lands on whichever segment sits at the floor — a retention-derived cadence takes a fresh shot at it every pass, which is what turns a latent hole into a prerequisite. This supersedes the earlier claim that the change was blocked on rocksdb-js #799; that PR remains a safety improvement on top, restricting deletion to a contiguous oldest prefix so a non-contiguous purge cannot leave a hole that silently truncates a reader, but it is not required here.

Root msgpackr moves to 2.0.6 alongside it. rocksdb-js 2.8.0 declares that version exactly, which an exact 2.0.5 root pin cannot satisfy, so the install nested a second msgpackr under node_modules/@harperfast/rocksdb-js — two module instances, each with its own extension registry and its own msgpackr-extract native addon. That release is a single commit, "Reject array and map lengths that exceed the remaining source data".

For the human reviewer

  1. Retention window doubles as the replication safety window, and this is the one finding rated major. Rocks segment purge is driven by logging.auditRetention with no floor for the slowest consumer. A peer offline longer than that window resumes, reaches the purged prefix, and gets done from TransactionLog.query() — indistinguishable from being caught up, so it is recorded as caught up while permanently missing that range. txnlogReplayGapBytes is an analytics gauge with no consumer that forces a base copy. Continuous retention is what moves this from unreachable-in-steady-state to routine. The escalation signal belongs in the reader, filed as HarperFast/rocksdb-js#805; the alternative is gating purge here on the oldest live consumer position, a semantics change operators would feel on a knob they tune for disk. Decide whether Create withHarper() Next.js config utility #805 lands before this ships enabled-by-default, and confirm whether harper-pro has an escalation path core cannot see — if it does, the DESIGN.md paragraph overstates the risk and should say so.

  2. The 2.8.0 bump carries far more than the retention fix — 186 commits, 55 files, +4606/-593 in src/, including transaction-lifecycle work (Hash long URLs into primary key for cache table storage efficiency #741 close-pending-txns-with-owner, HTTP Logs showing up in system (non external) logs #744 bounding the coordinated-retry park, txn-registry weakptr, deferred orphan cleanup). Local suites are clean across that surface; CI is the authoritative gate. The narrower alternative is asking upstream for a 2.7.2 carrying only the ensureExtent commit.

  3. purgeLogs() is synchronous and unbounded on the last worker, which also serves requests. The LMDB path below caps itself at MAX_DELETES_PER_CLEANUP and yields through setImmediate; the Rocks branch has no analogue. @cb1kenobi benchmarked the native call directly against the same 2.8.0 binary: ~0.12–0.17ms per eligible segment with no yielding, so a 20,000-segment backlog blocks the calling worker for about 3 seconds in one call. A steady-state pass has a handful of eligible segments; the case that hurts is an operator lowering logging.auditRetention from 30d to 1d, where the next pass stats and unlinks the whole accumulated backlog at once.

    Should purgeLogs() become async in rocksdb-js? My read: making it return a promise is the right end state but not sufficient on its own, and not this PR. An napi async worker moves the scan and the unlinks off the JS thread, which is what the 3-second number actually calls for — but it also changes a published API with three call sites in core (the cleanup loop, RocksTransactionLogStore's explicit purge, and ResourceBridge's operation), and it re-opens the pin this PR just settled. The cheaper half is additive and composes with either answer: PurgeLogsOptions takes no limit today, so give it one, and let the caller re-arm — that is exactly what MAX_DELETES_PER_CLEANUP already does for LMDB four lines below, and it bounds the block without touching the return type. My recommendation is to file both against rocksdb-js (bound first, async second) and keep this PR pinned to 2.8.0.

  4. The retained watermark lives only in the cleanup closure. If the marker write fails transiently and the store is then closed and reopened, the retry is lost and getLastRemoved() reports a boundary behind the entries already deleted. Not built here: it self-heals on the next pass that deletes anything, and persisting it would need a second durable write on the path the first one just failed on. Called out because the review raised it and the "retained until it commits" claim above is scoped to the live loop.

  5. Cadence formula: retention/10 floored at 10s, pressure shortening cutoff and cadence together, versus keying the backoff on the purge counters rocksdb-js now exposes. One expression to change.

  6. The last-worker conjunct on the re-arm is unreachable through both arming paths and kept deliberately as a backstop for a direct caller of the exported scheduleAuditCleanup: onStorageReclamation registers only on the last worker (it takes no skipThreadCheck) and the store-open arm gates on the same index. A reviewer may prefer it deleted as dead code or promoted to the sole ownership check. Both re-arm guards are Rocks-only; the LMDB arm keeps main's unconditional re-arm.

  7. stopAuditCleanup() is irreversible — it latches per audit-store instance, relying on reopen constructing a fresh store. Correct for today's callers; a future caller reusing a store object would get silent permanent retention loss.

  8. Two changes here are not about retention. The merge of main conflicted on resources/Table.ts's reclamation import: this branch added path-wide removeStorageReclamation() for database-level teardown and the legacy per-table drop (which unlinks the path outright), while main added per-handler removeStorageReclamationHandler() for Table.cleanup(), because RocksDB column families share a path. They are complementary, so the resolution keeps both. Separately, one commit runs Prettier over unitTests/resources/query-array-scoping.test.js: main has been red on Format Check since Pin element-scoping semantics of queries over array-valued properties #2437 landed it unformatted, and every branch that merges main inherits that failure.

Verification

Unit: test:unit:resources 1936 passing / 28 pending under RocksDB and 1591 passing / 203 pending under HARPER_STORAGE_ENGINE=lmdb; test:unit:dataLayer 260 passing / 111 pending. test:unit:main is 5163 passing with two failures that are this sandbox, not the branch — nonInteractiveSpawn git credential scoping (the agent environment exports GIT_CONFIG_GLOBAL/GIT_EDITOR) and a getDomainSocketPathLengthWarning case that needs a rootPath under 107 bytes, which a worktree this deep cannot give it. Prettier, oxlint --quiet and tsc --noEmit clean.

Every guard added here has a regression that fails when that guard alone is reverted, verified by reverting it, rebuilding (#src/* resolves to dist/, so a source-only edit does not reach the tests) and re-running:

revert test that goes red
stopAuditCleanup() returns Promise.resolve() instead of the active pass drains the in-flight removal before stopAuditCleanup() reports the pass retired
cursor release no longer checks storeClosing() touches nothing further once the root store closes under a suspended pass
dropDatabase() stops awaiting the barrier makes dropDatabase wait for an in-flight cleanup pass before it closes the stores
the legacy drop goes back to primaryStore.auditStore retires the legacy per-table drop against the table's own audit store
the init marker write logs through harperLogger.warn directly contains a rejected last-removed initialization write (failure logging throws)
the priority term leaves the Rocks cadence formula shortens the Rocks cadence while disk pressure is reported (100 vs 20)
the Rocks branch falls through to the LMDB backoff holds Rocks cleanup at the retention-derived cadence whatever a pass purges

The legacy-drop regression loads a genuine legacy table through readMetaDb(path, table, schema, auditPath, true) — real .mdb root, real audit environment, real initStores/makeTable — and asserts the drop reaches the table's own audit store and waits on it before close()/unlinkSync(). The only synthetic part is the stopAuditCleanup spy on that real store; legacy audit stores are opened with a plain open() rather than openAuditStore(), so they arm no loop today.

The end-to-end route is a live integration regression, audit-retention-rocks.test.ts. It and the LMDB suite pass 5/5 in ~27s. It fails on origin/main with RocksDB transaction-log cleanup did not advance past purgeRuns=0, which is #2140's exact symptom. purgeRuns counts scans rather than deletions — transaction_log_store.cpp increments it before any eligibility check, marked observability-only — so an unproductive pass still advances it and the oracle is valid for the re-arm claim. Deletion itself is proved by the real-RocksDatabase unit test asserting only 3.txnlog survives.

Two retention regressions used to assert the cadence by replacing the process-global setTimeout with a stub that captured every timer under a second. That global is shared by every audit store in the process: under HARPER_STORAGE_ENGINE=lmdb the shared fixture's own loop is still re-arming at a sub-second cadence by the time they run, so its timer landed in the stub's queue, the assertion saw two timers, and the failing test then hung in its own finally awaiting a pass whose timer the stub had swallowed. .mocharc.json sets timeout: 0, so that wedged the whole LMDB unit run rather than reporting a failure — it reproduces on the branch as pushed, and stays hidden under RocksDB, where the shared store's cadence never drops below a second. The cadence now lives on the audit store and both tests drive real passes and read it, with no fake timers.

Three gaps stated rather than papered over: the last-worker gate has no multi-worker test, because asserting "no second loop exists" needs timing-based assertions; the live regression proves re-arm rather than on-disk deletion, which rocksdb-js's own suite covers; and it skips win32, where deleting a mapped segment raises a sharing violation instead of unlinking, so a Windows purge can warn-log and make no progress while a consumer holds a mapping.

Complexity: complicated

Review-Coverage: authored=claude; ran=codex; declined=gemini,cursor-grok,cursor-composer,domain; rounds=5 @ c9debf0

Human-Review-Need: 4 @ c9debf0

@kriszyp
kriszyp requested review from cb1kenobi and heskew August 26, 2026 04:53

@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 unifies the audit retention cleanup lifecycle for both LMDB and RocksDB storage engines, establishing a self-rearming, engine-independent process. RocksDB now shares the same scheduling, error containment, and backoff logic as LMDB, purging native transaction-log segments during its cleanup passes. Feedback on the changes points out a high-severity issue where the backoff delay for RocksDB is never decreased or reset when logs are actively being purged, which could cause the cleanup loop to run at an extremely slow frequency even under active write traffic.

Comment thread resources/auditStore.ts Outdated
@kriszyp

kriszyp commented Aug 26, 2026

Copy link
Copy Markdown
Member Author

Reviewer routing: @cb1kenobi is the RocksDB responsible expert and highest-affinity reviewer; @heskew reported #846 and can validate the original retention failure mode.

— Codex

Comment thread resources/auditStore.ts Outdated
Comment thread resources/auditStore.ts Outdated
@claude

claude Bot commented Aug 26, 2026 •

Copy link
Copy Markdown
Contributor

Reviewed; no blockers found.

@kriszyp
kriszyp marked this pull request as ready for review August 26, 2026 13:04
Comment thread resources/auditStore.ts Outdated
Comment thread resources/auditStore.ts Outdated
@kriszyp
kriszyp marked this pull request as draft August 26, 2026 22:46
@kriszyp
kriszyp marked this pull request as ready for review August 27, 2026 15:05
@kriszyp
kriszyp marked this pull request as draft August 27, 2026 15:09
@kriszyp
kriszyp marked this pull request as ready for review August 27, 2026 15:13
Comment thread resources/auditStore.ts Outdated
…op on close

The Rocks cleanup delay is now a pure function of the pressure-adjusted retention
window rather than the LMDB per-entry backoff: segment eligibility only changes on
rotation/flush, so reacting to a delete count just rescanned the same files. Rocks
re-arms on the last worker only, so a reclamation signal received elsewhere stays
one-shot instead of starting a second store-wide purge loop.

stopAuditCleanup() retires the loop from the database close, drop, and branch-close
paths. The in-pass status check already declined to re-arm on a closing store, but a
pass already on the timer had to fire first to reach it.

Adds a live end-to-end regression that fails on main with purgeRuns stuck at 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
kriszyp and others added 2 commits August 28, 2026 06:47
…pressure cadence

Only closeBranchHandles dropped its reclamation registrations; closeDatabase and
dropDatabase never did, so a closed or dropped store left a handler — and the closure
pinning the store — registered for the life of the process, with every re-open of the
same path appending another. That hazard is what removeStorageReclamation's own
docstring describes; the close and drop paths now match the branch path.

Adds the pressure-cadence regression the earlier commit left to inspection: it drives a
real reclamation signal through a path-scoped ratio getter and pins the shortened delay,
so dropping the priority term from the cadence fails rather than passing silently.

Widens the live regression's waits, whose cadence floor makes two passes ~20s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…s store

The re-arm's last-worker conjunct cannot be reached through either arming path:
onStorageReclamation registers its handler only on the last worker (it takes no
skipThreadCheck) and the store-open arm gates on the same index. The comment and
DESIGN.md said a reclamation signal arrives on an arbitrary worker and this guard
absorbs it, which is false — ownership sits at the arming sites, and the conjunct is
a backstop for a direct caller of the exported scheduleAuditCleanup. The guard stays;
only the claim about why changes.

The cadence test now opens its own store. Patching global.setTimeout process-wide while
the shared fixture's Rocks loop is live on a real timer let a stray pass land in
`scheduled` and break the per-pass assertions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…timer capture

Round-2 review findings. closeDatabase closed each table's primaryStore and indices
before retiring the audit loop, so an LMDB pass inside removeAuditEntry could fire a
delete-type tombstone callback against an already-closed primary store; the stop pass is
now hoisted above the table closes.

The cadence test's own store did not actually isolate it: the shared fixture's audit loop
stays armed on a real 10s timer, and its re-arm landed in `scheduled` through the patched
global, so `scheduled.length` could exceed 1. The capture now takes only this test's own
sub-second delays, and the comment claiming isolation is gone.

Covers the drop path's deregistration, names rocksdb-js#805 as the reader-gap tracker,
and records that both re-arm guards are Rocks-only rather than engine-independent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread resources/auditStore.ts
Continuous retention needs 2.8.0, not the unreleased #799. The pinned 2.7.1 already
refuses to purge above the durable flush floor, but at the boundary segment it compares
logFile->size without resolving the extent: a lazily-registered segment reads 0, so
`0 > positionInLogFile` is false, the guard passes, and the segment is deleted with a tail
that never reached RocksDB. 2.8.0 calls ensureExtent() there and refuses to purge when the
extent cannot be resolved; its new `should not purge a log file whose tail is past the
flushed position` regression covers exactly that case and does not exist in 2.7.1.

One purge per boot rarely lands on whichever segment sits at the floor. A retention-derived
cadence takes a fresh shot at it every pass, which is what makes this a prerequisite for
the cadence change rather than routine dependency hygiene.

rocksdb-js #799 remains a safety improvement on top: it restricts deletion to a contiguous
oldest prefix, so a non-contiguous purge cannot leave a hole that silently truncates a
reader (HarperFast/rocksdb-js#805).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread resources/auditStore.ts
cb1kenobi and others added 2 commits August 28, 2026 11:23
rocksdb-js 2.7.1 declared msgpackr 2.0.5, matching this package's exact root pin, so npm
deduped them. 2.8.0 declares 2.0.6, which the exact 2.0.5 pin cannot satisfy, so the install
nested a second copy under node_modules/@harperfast/rocksdb-js — two module instances, each
with its own extension registry and its own msgpackr-extract native addon.

Moving the root pin to 2.0.6 restores a single instance. The release is one commit,
"Reject array and map lengths that exceed the remaining source data", which is a decode
bounds fix worth having on its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The three teardown paths in databases.ts stop the cleanup loop and deregister reclamation
before releasing a root store, but Table.ts's legacy per-table drop branch closed and
unlinked its primaryStore with neither call, so an armed pass could fire against a store
that is closed and deleted. Reachable on LMDB only; RocksDB never takes this branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread resources/Table.ts
…a dead stub

stopAuditCleanup() retired a scheduled pass but not one already suspended at
`await removeAuditEntry` or `await new Promise(setImmediate)`. closeDatabase sets the flag
and then closes the primary and root stores in the same tick, so the resumed iteration drove
a live lmdb-js cursor and getEntry()/remove() against a closed env. The loop now re-checks
the flag each iteration, which is what the teardown comment already claimed.

Resolving definedRoot before the hoisted stop pass collapses closeDatabase's two stop loops
into one.

The cadence and pressure tests replaced global.setTimeout wholesale, so any timer another
subsystem armed inside the window got a stub that never fires and whose clearTimeout was
swallowed — a dead replication retry surfacing as an unrelated flake in a later file. They
now delegate anything above their own sub-second delays to the real timer.

DESIGN.md's mapped-segment reasoning is POSIX-only; on Windows deleting a mapped segment
raises a sharing violation, so the purge throws, re-arms and makes no progress while a
consumer holds the mapping. The continuous cadence makes that a steady state, and the Rocks
retention test skips win32, so nothing covers it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread resources/auditStore.ts Outdated
The per-iteration guard sat in a for-of body, which runs after the iterator has already
produced its record — so a close landing mid-pass still advanced an lmdb-js cursor over a
closed env before the check could stop it. The loop is now driven explicitly so the guard
precedes every next(), and it releases the cursor on the way out, which for-of had been
doing for it.

The trailing updateLastRemoved() is skipped for the same reason: it writes to the audit
store, which closeDatabase has already closed by the time a stopped pass reaches it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread resources/auditStore.ts Outdated
@cb1kenobi

Copy link
Copy Markdown
Member

Reviewed b997b444 — no new issues. The prior Medium (merge-ready framing vs. blocked-on-#799 body) is resolved: the body now correctly names rocksdb-js 2.8.0's ensureExtent() fix as the real prerequisite, and I independently verified that fix in rocksdb-js's own source and regression test at the v2.8.0 tag. The prior Low (un-unref'd Rocks cleanup timer) is unchanged in substance — the timer is .unref()'d — so nothing outstanding there either.

Verified independently: the last-worker re-arm conjunct is genuinely unreachable through both arming paths (confirmed by tracing onStorageReclamation's own last-worker gate); stopAuditCleanup()'s four logical teardown callers all destroy/close the store immediately after, so no reuse-after-stop path exists; the live integration test proves re-arm via purgeRuns (a scan counter, confirmed incremented unconditionally in transaction_log_store.cpp) while the real-RocksDatabase unit test independently proves on-disk deletion (asserts only 3.txnlog survives) — the two together prove what the PR claims, and a mutation test against stopAuditCleanup()'s irreversibility latch is caught by the dedicated unit test for it.

One data point worth having for item 3 in the human-review list: I benchmarked the native purgeLogs() call directly (synthetic segments, same rocksdb-js 2.8.0 binary) — it scales linearly at ~0.12–0.17ms per eligible segment with no yielding, so a 20,000-segment backlog blocks the calling worker for about 3 seconds in one synchronous call. That's a real number for whoever weighs the retention-lowering risk.

The six Human-Review-Need items are product/escalation calls, not defects — no comment on those. The PR body's own trailer says Human-Review-Need: 4 but lists 6 decision names; likely just a stale count, worth a one-line fix whenever convenient.

—
Generated by Barber AI

Comment thread resources/auditStore.ts
kriszyp and others added 6 commits September 1, 2026 16:13
…he legacy drop actually holds

Review follow-ups on #2338.

The legacy per-table drop retired `primaryStore.auditStore`, which nothing
assigns: `openAuditStore()` assigns `rootStore.auditStore` and `makeTable()`
captures the store it was handed. The drop now retires that capture.

`stopAuditCleanup()` only latched the loop closed; it could not say anything
about a pass already suspended inside `await removeAuditEntry()`. lmdb-js
stamps the DBI number into its write instruction synchronously and the native
writer consumes it later, so that pass still has a delete pending against the
primary and audit DBIs while teardown closes them. It now returns a drain
barrier, awaited by `dropDatabase()` and the legacy drop. `closeDatabase()`
and branch `close()` stay synchronous; every environment touch left in a
resumed pass — cursor advance, cursor release, marker write, re-arm — re-checks
the root's status instead, which is also what covers `resetDatabases()`,
where roots close with no retirement call at all.

The last-removed marker is now awaited inside error containment and retained
until it commits: a pass that deletes nothing never reaches the write again, so
one transient failure would leave the recorded boundary permanently behind the
entries already removed. The pass's own timer callback gets an outermost
backstop so a failing log sink cannot reject it outward or retire the loop.

Co-Authored-By: Claude Opus <noreply@anthropic.com>
…l setTimeout

Two audit-retention regressions asserted the cadence by swapping `global.setTimeout`
for a stub that captured every timer armed with a delay under a second. That global
is shared by every audit store in the process: under `HARPER_STORAGE_ENGINE=lmdb` the
shared fixture's own cleanup loop is still re-arming at a sub-second cadence by the
time these tests run, so its timer landed in the stub's queue, the "a pressure signal
should arm a cleanup pass" assertion saw two timers, and the failing test then hung
forever in its own `finally` — `runReclamationHandlers()` was still awaiting a pass
whose timer the stub had swallowed and would never fire. `.mocharc.json` sets
`timeout: 0`, so that wedged the whole LMDB unit run rather than reporting a failure.
It reproduces on the branch as pushed; it stays hidden on RocksDB, where the shared
store's cadence never drops below a second.

The cadence now lives on the audit store, which is the loop's own observable state,
and both tests drive real passes and read it. No fake timers, no global patching, and
the pressure test is a real 100ms pass rather than a synthetic one. Both still fail
when their formula term is removed.

Also adds the regression for the marker write that initializes a new audit store:
`openAuditStore()` is synchronous and returns the store, so nothing downstream can
own that write's rejection, and only the cleanup-pass caller was covered.

Co-Authored-By: Claude Opus <noreply@anthropic.com>
…rim narration

Pre-push review follow-up. The `.catch` on the audit store's initializing last-removed
write called the logger directly, so a failing sink turned one contained rejection into
an uncontained one — the same double failure the cleanup pass's outermost backstop
already guarded. Both now go through one `warnContained()` helper, and the
initialization regression runs against a throwing sink as well as a working one.

Also drops comment narration the review flagged: review history and restatements of
the statements below them.

Co-Authored-By: Claude Opus <noreply@anthropic.com>
Co-Authored-By: Claude Opus <noreply@anthropic.com>
`main` has been red on Format Check since #2437 landed
unitTests/resources/query-array-scoping.test.js unformatted, so every branch that
merges main inherits the failure. Unrelated to this PR; here only so this branch's
Format Check is green.

Co-Authored-By: Claude Opus <noreply@anthropic.com>
@cb1kenobi

Copy link
Copy Markdown
Member

Reviewed c9debf0 and found no blocking issues. No new blocking findings were confirmed on changed lines. Previously raised issues were not repeated, and the remaining disclosed risks are tracked or require product decisions.

—
Generated by Barber AI

@kriszyp
kriszyp marked this pull request as draft September 2, 2026 01:01
@kriszyp
kriszyp marked this pull request as ready for review September 2, 2026 02:40
@kriszyp
kriszyp merged commit d1eae3d into main Sep 2, 2026
53 checks passed
@kriszyp
kriszyp deleted the kris/846-txnlog-retention branch September 2, 2026 13:57
dawsontoth added a commit that referenced this pull request Sep 2, 2026
#2338 ("Apply audit retention continuously to RocksDB transaction logs") landed
on main and reworked the same function this branch changes, so the conflicts in
resources/auditStore.ts were semantic rather than textual. Resolved by taking
main's version as the base and re-applying the floor work onto it, so none of
#2338's structure is lost: cleanupStopped/storeClosing guards, the explicit
iterator with its cursor release, stopAuditCleanup(), the per-pass cleanup
priority, the retention-derived RocksDB cadence, and the pendingLastRemoved
retry-carry.

Three things that changed as a result:

- **The `last-removed` marker stays.** This branch had retired it as broken and
  unconsumed. #2338 hardened its write path and added five tests around it
  (retry-on-failure, no write from a retired pass, contained initialization
  failure), so deleting it in a merge would silently revert that work. The two
  markers now coexist: `last-removed` as #2338 left it, `audit-floor` as the
  write-ahead, verified, monotonic primitive the accessor reads. The key doc
  says why they are separate.
- **The retention loop raises the floor off its first eligible entry** instead of
  a separate probe range. One cursor, so an idle database still writes no floor,
  and nothing else observing that range sees an extra advance — which is what
  #2338's gated-pass fixture asserts on. Strictly before any removal, unchanged.
- **Floor writes go through `put` inside the write transaction, not `putSync`.**
  lmdb's `putSync` is `put(...) === SYNC_PROMISE_SUCCESS`, so it discards
  whatever put returns; a put that hands back a real rejection — which #2338's
  marker-failure fixtures install — then leaks with no owner. Inside a write
  transaction put is synchronous and returns an already-resolved sentinel, so the
  floor is still visible immediately and the failure case now has an owner.

Also fixes a latent bug the merge surfaced: the eligibility probe relied on the
range honoring `limit: 1` and had no `break`. Against a getRange that ignores it
— the RocksDB log store does, and #2338's fixture installs a synthetic infinite
range — it would have walked the whole log or never returned. Moot now that the
scan's own iterator answers the question.

Verified: main's 37 audit-log tests pass exactly as on pristine main (confirmed
against a clean origin/main worktree), test:unit:resources is 1965/0 on RocksDB
and 1622/0 on LMDB, and copyDbIntegrity still round-trips the audit log.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
dawsontoth added a commit that referenced this pull request Sep 2, 2026
Caught in review on #2458. The merge with #2338 kept the `last-removed` marker
and said so in its own message and in the `AUDIT_FLOOR_KEY` comment, but this
prose was not updated with them. DESIGN.md is the section index AGENTS.md points
contributors at, so calling an actively written and actively tested marker
"retired" is exactly the sentence that would get it deleted — silently reverting
#2338's hardened write path and its five tests.

Says what is true instead: both markers are live, why they coexist, and why the
floor needed its own key rather than reusing that one. Also splits the
trust-marker bullet, which had grown two unrelated claims.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
dawsontoth added a commit that referenced this pull request Sep 2, 2026
All three describe behavior the merge changed, which is the same staleness that
put "retired" in DESIGN.md:

- the RocksDB skip said the Rocks branch "runs on demand, not on a loop", which
  #2338 made untrue — it now re-arms on a retention-derived cadence. It says why
  the probe assertion does not apply there instead.
- a test named the floor "the audit store's only key", which it stopped being
  once the merge kept last-removed's initialization alongside it.
- one comment restated the fixture value on the line above it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
kriszyp added a commit that referenced this pull request Sep 8, 2026
#2508)

Cherry-pick of #2338 onto v5.2. The Rocks audit cleanup loop
re-arms on a retention-derived cadence instead of running once per boot, and
every teardown path retires the loop and deregisters storage reclamation before
releasing the stores it holds.

Adapted for v5.2, which does not have main's branch-database feature or its
storageReclamation deregistration helpers:

- `server/storageReclamation.ts` gains `removeStorageReclamation()`, copied
  verbatim from main (added there by #2285/#2381, not by #2338). Nothing on v5.2
  called it, so this is a pure addition.
- The `closeBranchHandles()` retirement hunk and the `openBranchDatabase` test
  additions are dropped: neither the function nor the suite exists on v5.2. The
  two reclamation-deregistration tests #2338 placed inside that suite test
  `closeDatabase`/`dropDatabase`, not branches, so they move to their own
  describe.
- `Table.ts`'s import keeps only what v5.2 uses:
  `removeStorageReclamationHandler` is main-only and unreferenced here.
- DESIGN.md gains only #2338's new retention section; the removal-loop section it
  sat below is main-only. Its one sentence about branch `close()` is trimmed.
- The `query-array-scoping.test.js` formatting fix is dropped — it repaired a
  main-only Format Check break and that file does not exist on v5.2.

rocksdb-js stays at 2.8.0, which v5.2 already pins (6ca418d); #2338's own bump
was to the same version.

(cherry picked from commit d1eae3d)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants