Repository navigation
Apply audit retention continuously to RocksDB transaction logs - #2338
Conversation
There was a problem hiding this comment.
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.
|
Reviewer routing: @cb1kenobi is the RocksDB responsible expert and highest-affinity reviewer; @heskew reported #846 and can validate the original retention failure mode. — Codex |
|
Reviewed; no blockers found. |
…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>
…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>
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>
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>
…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>
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>
|
Reviewed Verified independently: the last-worker re-arm conjunct is genuinely unreachable through both arming paths (confirmed by tracing One data point worth having for item 3 in the human-review list: I benchmarked the native The six Human-Review-Need items are product/escalation calls, not defects — no comment on those. The PR body's own trailer says — |
…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>
…tion # Conflicts: # resources/Table.ts
`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>
|
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. — |
#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>
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>
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>
#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>
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 atDEFAULT_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 insideawait 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 ofTable.dropTable()await it.closeDatabase()and branchclose()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-checksrootStore.status, plus the fact that their production callers reach them only for RocksDB stores, whose pass is one synchronouspurgeLogs()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()assignsrootStore.auditStoreandmakeTable()captures the store it was handed — so the call was a no-op against a dead reference.closeDatabaseanddropDatabasealso deregister storage reclamation now: onlycloseBranchHandlesdid, 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 samewarnContained()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-js2.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 compareslogFile->sizewithout resolving the extent: a lazily-registered segment reads 0, so0 > positionInLogFileis false, the guard passes, and the segment is deleted with a tail that never reached RocksDB. 2.8.0 callsensureExtent()there and refuses to purge when the extent cannot be resolved; itsshould not purge a log file whose tail is past the flushed positionregression 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
msgpackrmoves to 2.0.6 alongside it. rocksdb-js 2.8.0 declares that version exactly, which an exact2.0.5root pin cannot satisfy, so the install nested a second msgpackr undernode_modules/@harperfast/rocksdb-js— two module instances, each with its own extension registry and its ownmsgpackr-extractnative addon. That release is a single commit, "Reject array and map lengths that exceed the remaining source data".For the human reviewer
Retention window doubles as the replication safety window, and this is the one finding rated major. Rocks segment purge is driven by
logging.auditRetentionwith no floor for the slowest consumer. A peer offline longer than that window resumes, reaches the purged prefix, and getsdonefromTransactionLog.query()— indistinguishable from being caught up, so it is recorded as caught up while permanently missing that range.txnlogReplayGapBytesis 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.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 theensureExtentcommit.purgeLogs()is synchronous and unbounded on the last worker, which also serves requests. The LMDB path below caps itself atMAX_DELETES_PER_CLEANUPand yields throughsetImmediate; 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 loweringlogging.auditRetentionfrom 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 theunlinks 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, andResourceBridge's operation), and it re-opens the pin this PR just settled. The cheaper half is additive and composes with either answer:PurgeLogsOptionstakes no limit today, so give it one, and let the caller re-arm — that is exactly whatMAX_DELETES_PER_CLEANUPalready 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.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.Cadence formula:
retention/10floored at 10s, pressure shortening cutoff and cadence together, versus keying the backoff on the purge counters rocksdb-js now exposes. One expression to change.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:onStorageReclamationregisters only on the last worker (it takes noskipThreadCheck) 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 keepsmain's unconditional re-arm.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.Two changes here are not about retention. The merge of
mainconflicted onresources/Table.ts's reclamation import: this branch added path-wideremoveStorageReclamation()for database-level teardown and the legacy per-table drop (which unlinks the path outright), whilemainadded per-handlerremoveStorageReclamationHandler()forTable.cleanup(), because RocksDB column families share a path. They are complementary, so the resolution keeps both. Separately, one commit runs Prettier overunitTests/resources/query-array-scoping.test.js:mainhas been red on Format Check since Pin element-scoping semantics of queries over array-valued properties #2437 landed it unformatted, and every branch that mergesmaininherits that failure.Verification
Unit:
test:unit:resources1936 passing / 28 pending under RocksDB and 1591 passing / 203 pending underHARPER_STORAGE_ENGINE=lmdb;test:unit:dataLayer260 passing / 111 pending.test:unit:mainis 5163 passing with two failures that are this sandbox, not the branch —nonInteractiveSpawn git credential scoping(the agent environment exportsGIT_CONFIG_GLOBAL/GIT_EDITOR) and agetDomainSocketPathLengthWarningcase that needs a rootPath under 107 bytes, which a worktree this deep cannot give it. Prettier,oxlint --quietandtsc --noEmitclean.Every guard added here has a regression that fails when that guard alone is reverted, verified by reverting it, rebuilding (
#src/*resolves todist/, so a source-only edit does not reach the tests) and re-running:stopAuditCleanup()returnsPromise.resolve()instead of the active passstopAuditCleanup()reports the pass retiredstoreClosing()dropDatabase()stops awaiting the barrierdropDatabasewait for an in-flight cleanup pass before it closes the storesprimaryStore.auditStoreharperLogger.warndirectlyThe legacy-drop regression loads a genuine legacy table through
readMetaDb(path, table, schema, auditPath, true)— real.mdbroot, real audit environment, realinitStores/makeTable— and asserts the drop reaches the table's own audit store and waits on it beforeclose()/unlinkSync(). The only synthetic part is thestopAuditCleanupspy on that real store; legacy audit stores are opened with a plainopen()rather thanopenAuditStore(), 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 onorigin/mainwithRocksDB transaction-log cleanup did not advance past purgeRuns=0, which is #2140's exact symptom.purgeRunscounts scans rather than deletions —transaction_log_store.cppincrements 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-RocksDatabaseunit test asserting only3.txnlogsurvives.Two retention regressions used to assert the cadence by replacing the process-global
setTimeoutwith a stub that captured every timer under a second. That global is shared by every audit store in the process: underHARPER_STORAGE_ENGINE=lmdbthe 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 ownfinallyawaiting a pass whose timer the stub had swallowed..mocharc.jsonsetstimeout: 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