Skip to content

Fix RocksDB @expiresAt eviction sweeps - #2155

Open
kylebernhardy wants to merge 43 commits into
mainfrom
codex/fix-rocks-expiration-eviction
Open

Fix RocksDB @expiresAt eviction sweeps#2155
kylebernhardy wants to merge 43 commits into
mainfrom
codex/fix-rocks-expiration-eviction

Conversation

@kylebernhardy

@kylebernhardy kylebernhardy commented Aug 13, 2026

Copy link
Copy Markdown
Member

Summary

  • restore exact RocksDB secondary-index enumeration and bounded composite cursor scans so native @expiresAt sweeps work
  • preserve the source/cache contract: source-owned TTL comes from context.expiresAt (or table fallback), never from a returned @expiresAt field
  • keep explicit write/context expiration ahead of record-field and table defaults while making indexed and full-scan expiration semantics agree
  • batch non-blob evictions with bounded concurrency, clean dangling and legacy index entries transactionally, and preserve concurrent writes with version guards
  • cancel and drain active sweeps before table/database teardown, including close, drop, and engine recreation paths

Fixes #1481. The broader native index-store work remains tracked in HarperFast/rocksdb-js#312.

Rollout behavior

This intentionally activates physical reclamation for expired RocksDB rows that reads already treat as absent. The prior Rocks sweep was effectively inert, so an upgraded node may have an existing backlog. The sweep drains that backlog without an audit or replication event, matching eviction semantics; it yields every 10 candidates and limits transaction commits to four in flight, but it does not impose the previously reviewed 10k/min ceiling. This behavior change needs explicit release-note and human rollout sign-off.

Verification

  • npm run build
  • npm run typecheck
  • npm run test:types
  • npm run lint:required
  • npm run format:check
  • RocksDB focused suite: 53 passing
  • LMDB focused suite: 3 passing
  • coverage includes explicit/context, record-field, and table-default precedence; source-context ownership; indexed/full-scan consistency; legacy index cleanup; exact/similar index values; pagination/counts; composite primary keys; 505-row continuation; conflict guards; commit-gated blob deletion; concurrent refresh; cancellation; close/drop races; tableless restore locking; and multi-root environment cleanup
  • final Gemini CLI review: no actionable findings
  • final Claude + Harper-domain review: merge recommendation; remaining first-sweep upgrade impact is release-note/human-signoff material

Comment generated by kAIle (GPT-5.6)

@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 optimizes the record expiration sweep by introducing RocksDB index value enumeration and batching the eviction process in groups of 100 records. It also adds a fallback mechanism to clean up dangling index entries when a record is already gone. The review feedback correctly identifies a redundant database read in this fallback path when primaryStore.ifVersion is undefined, suggesting a simplification to avoid unnecessary I/O overhead.

Comment thread resources/Table.ts Outdated
@claude

claude Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Found 1 blocker: failSchemaQuiesceFinalization (resources/databases.ts) never clears abortRequested after an interrupted reconcileSchemaQuiesce, so a transient failure permanently fails the terminal handler for that quiesce (see inline comment). One non-blocking suggestion also posted inline.

@kylebernhardy
kylebernhardy force-pushed the codex/fix-rocks-expiration-eviction branch from 9426905 to 9f7e742 Compare August 13, 2026 00:44
@kylebernhardy kylebernhardy added patch area:storage Storage engine, LMDB/RocksDB, compaction labels Aug 13, 2026
Comment thread resources/Table.ts Outdated
@github-actions

github-actions Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Patch cherry-pick: cancelled

The patch label was removed; cherry-pick branch cherry-pick/v5.1/pr-2155 was deleted.

@kylebernhardy
kylebernhardy force-pushed the codex/fix-rocks-expiration-eviction branch from 9f7e742 to c54d4b6 Compare August 13, 2026 00:52
@kriszyp

kriszyp commented Aug 13, 2026

Copy link
Copy Markdown
Member

Patch? Really?
And why the mixed authors?

@kylebernhardy

kylebernhardy commented Aug 13, 2026

Copy link
Copy Markdown
Member Author

Good catch. I removed the patch label; this should remain a main/v5.2+ fix, so the v5.1 cherry-pick was not appropriate.

Update: at Kyle’s request, I rewrote the five PR commits so both author and committer use Kyle Bernhardy <kyle@harperdb.io>, which GitHub maps to kylebernhardy. The commit contents are unchanged.

Comment generated by kAIle (GPT-5.6)

@kylebernhardy
kylebernhardy force-pushed the codex/fix-rocks-expiration-eviction branch from c54d4b6 to 409138c Compare August 13, 2026 15:55
Comment thread resources/Table.ts Outdated
Comment thread resources/Table.ts Outdated

@cb1kenobi cb1kenobi left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

About a year ago, I tried to add support for getValues() and we ended up abandoning it in rocksdb-js on June 25, 2025 due to "key sort order" challenges.

I created an IndexStore that extends a Store and would handle the encoding of the keys: https://github.com/HarperFast/rocksdb-js/blob/index-store/src/index-store.ts. I don't remember the specifics, but I remember naively creating an array as the key won't work. If you wanted "hello", it gave you "hello world" too. I tried adding markers around the values, but even those could cause matches. I don't think I tested fixed width keys, but that might yield some success.

We ended up dropping dupSort, getValues(), and getValuesCount() from rocksdb-js.

It's a year later now, the dust has settled, things have stabilized, LLMs have gotten smarter, and maybe this is solvable. I think the next step for this PR is to add way more tests, specifically with similar values.

@kriszyp kriszyp left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess additional concerns I have: I think http://github.com/HarperFast/rocksdb-js/issues/312 is the real solution here. This is would be kind of a temporary solution for... who? I thought the only place we had ever used this was for the EKV project that didn't go through and this received little attention after that. But sure, I guess there is an immediate need for this, we can keep going with this. I think the ticket is marked P1. We haven't been automatically prioritizing P1 tickets into 5.2 patch (while we still have open P0s, or at least P1s with active customer connections).
🤖 Reviewed with Claude

Comment thread resources/Table.ts Outdated
Comment thread resources/Table.ts Outdated
Comment thread resources/Table.ts Outdated
Comment thread resources/Table.ts Outdated
Comment thread resources/RocksIndexStore.ts Outdated
Comment thread unitTests/resources/expiresAtAttribute.test.js Outdated
Comment thread unitTests/resources/expiresAtAttribute.test.js Outdated
Comment thread resources/Table.ts Outdated
Comment thread resources/Table.ts Outdated
Comment thread resources/Table.ts Outdated
Comment thread resources/Table.ts Outdated
Comment thread resources/Table.ts Outdated
Comment thread resources/Table.ts
Comment thread resources/Table.ts Outdated
Comment thread resources/DESIGN.md
Comment thread resources/Table.ts
Comment thread resources/DatabaseTransaction.ts
@kriszyp kriszyp added this to the v5.3 milestone Aug 17, 2026
@kriszyp

kriszyp commented Aug 17, 2026

Copy link
Copy Markdown
Member

but engineering-metrics should not be presented as blocked on it.

Ok, so you cool with this being milestone 5.3, then? (I assigned that).

Comment thread resources/DatabaseTransaction.ts
@kylebernhardy

Copy link
Copy Markdown
Member Author

Yes, v5.3 timing is acceptable. Engineering Metrics is no longer blocked on this change, and keeping it in v5.3 gives the remaining transaction-lifecycle correction and rollout behavior the right review space. Thanks for moving the milestone.

Comment generated by kAIle (Codex GPT-5.6)

Comment thread resources/databases.ts Outdated
Comment thread server/itc/serverHandlers.js
Comment thread server/threads/manageThreads.js Outdated
Comment thread server/itc/serverHandlers.js
Comment thread resources/databases.ts
export function completeSchemaQuiesce(message: SchemaQuiesceMessage): void | Promise<void> {
recoveringSchemaQuiescences.delete(message.quiesceId);
const state = schemaQuiescence.get(message.quiesceId);
if (state && message.phase === 'reconcile-quiesce') return reconcileSchemaQuiesce(state);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium: a terminal reconcile that throws can never be retried — it permanently answers reconciled: false

Routing reconcile-quiesce into reconcileSchemaQuiesce() here is the right fix, but it changes the retry semantics of the terminal handler. reconcileSchemaQuiesce() sets state.abortRequested = true as its first statement and only clears the state at the very end, so anything that throws in between (resetDatabases(), resetQuiescedDatabase(), state.table.abortDropQuiesce()) leaves the state in the map with abortRequested set. applySchemaChange's catch then calls failSchemaQuiesceFinalization(), which resets finalizing and re-arms the lease but leaves abortRequested alone — and finishSchemaQuiesce() rejects on exactly that:

const state = schemaQuiescence.get(message.quiesceId);
if (!state || state.abortRequested) return false;

So every later attempt returns { reconciled: false }. completeSchemaChange()'s three retries can't recover, the local leg throws on the first failure, and dropTable()'s finally rejects with Could not reconciled schema quiesce … on every worker, masking the original drop error while the target stays fail-closed until the 120s lease backstop fires.

This is new in 879f243c: before it, the terminal reconcile never entered reconcileSchemaQuiesce(), so a transient resetDatabases() failure left abortRequested clear and the retry path worked (the behavior does not acknowledge finalization when the strict reset fails covers).

Suggested fix — in failSchemaQuiesceFinalization (line 1637), clear the flag the interrupted reconcile set. It is already false on the finalize path, so this is a no-op there:

Suggested change
if (state && message.phase === 'reconcile-quiesce') return reconcileSchemaQuiesce(state);
state.finalizing = false;
// A reconcile that threw mid-restore already set abortRequested; leaving it set makes
// finishSchemaQuiesce reject every retry of the very operation we are re-arming for.
state.abortRequested = false;

(apply at line 1637, not here)


Generated by Barber AI

await assert.rejects(() => abortSchemaQuiesce(message), /commit boundary/);
const terminal = { ...message, phase: 'reconcile-quiesce' };
assert.strictEqual(finishSchemaQuiesce(terminal), true);
await completeSchemaQuiesce(terminal);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: one more completeSchemaQuiesce() call site needs the await this line just gained — line 516

completeSchemaQuiesce() now returns a promise for the reconcile-quiesce phase, and this test correctly awaits it. The teardown in retains the origin unavailable fence when local terminal state is unresolved does not:

// line 516
if (terminalMessage) completeSchemaQuiesce({ ...terminalMessage, phase: 'reconcile-quiesce' });

That is a drop_schema state that is still present, so the reconcile branch is taken and its resetQuiescedDatabase()resetDatabases() runs unawaited into whichever test starts next. Given how much stabilization this suite has already needed, worth closing. The enclosing function is async, so await completeSchemaQuiesce(...) is a one-word fix.


Generated by Barber AI

Comment thread resources/databases.ts
const state = schemaQuiescence.get(message.quiesceId);
if (!state) return;
state.finalizing = false;
armSchemaQuiesceLease(state);

@claude claude Bot Aug 18, 2026

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.

What: failSchemaQuiesceFinalization clears state.finalizing and re-arms the lease, but never clears state.abortRequested. reconcileSchemaQuiesce (line 1467) sets abortRequested = true as its first statement, before any of resetDatabases(), resetQuiescedDatabase(), or state.table.abortDropQuiesce() run. If any of those throw, completeSchemaQuiesce's reconcile-quiesce branch (line 1638, return reconcileSchemaQuiesce(state)) rejects, and whatever caller routes that failure into failSchemaQuiesceFinalization leaves abortRequested permanently set.

Why it matters: finishSchemaQuiesce (line 1622) unconditionally rejects when state.abortRequested is true: if (!state || state.abortRequested) return false;. Every subsequent retry of the terminal handler for this quiesce now returns false forever — the operation can never complete locally again until the 120s lease backstop fires. This is a regression introduced by this PR (879f243c): before routing the terminal reconcile phase through reconcileSchemaQuiesce, a transient failure here left abortRequested clear and retries could still succeed.

Suggested fix: clear the flag the interrupted reconcile set, since it's already false on the normal finalize path (replacing this line):

Suggested change
armSchemaQuiesceLease(state);
state.abortRequested = false;
armSchemaQuiesceLease(state);

);
} finally {
signalling.finalizeSchemaChange = originalFinalize;
if (terminalMessage) completeSchemaQuiesce({ ...terminalMessage, phase: 'reconcile-quiesce' });

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.

Suggestion (non-blocking): completeSchemaQuiesce() now returns a promise for the reconcile-quiesce phase (other tests in this file already await it, e.g. line 422). This finally block doesn't, so resetQuiescedDatabase()/resetDatabases() can still be running when the next test starts. The enclosing function is async, so await completeSchemaQuiesce(...) closes the gap.

Suggested change
if (terminalMessage) completeSchemaQuiesce({ ...terminalMessage, phase: 'reconcile-quiesce' });
if (terminalMessage) await completeSchemaQuiesce({ ...terminalMessage, phase: 'reconcile-quiesce' });

Comment thread resources/databases.ts
if (state.committed) {
// A synchronous RocksDB drop can outlive the timer that its blocked origin would renew.
// A connected owner may still be destructing storage, so peers must remain fail-closed.
if (!state.localOwner && manageThreads.isThreadConnected(state.message.originator)) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium: this fence bypasses the committed-recovery attempt cap, so a wedged peer never recovers

The guard returns before recoverCommittedSchemaQuiesce() runs, so it never consumes a recoveryAttempts slot — MAX_COMMITTED_QUIESCE_RECOVERY_ATTEMPTS no longer bounds anything here, and armSchemaQuiesceLease() re-arms every 120s for as long as the origin thread exists. Harper worker threads are long-lived, so "the origin thread is alive" is not a bound on "the drop is still running".

The concrete consequence is that it makes the still-open finding at line 1638 permanent instead of self-healing. reconcileSchemaQuiesce() sets state.abortRequested as its first statement, so once the terminal reconcile throws once, finishSchemaQuiesce() rejects every retry and completeSchemaQuiesce() never clears the state. Before this commit the 120s lease was the escape hatch; now there isn't one.

Executed against this head (peer drop_table, injected throw in the terminal reconcile's restore work, then repeated lease expiry):

  • originator = a connected thread id — still fail-closed after 5 expiries, table still missing from databases[schema].
  • originator = a disconnected id (control) — recovers on the first expiry.

Suggested fix: bound the fence so it tracks the operation rather than the thread's lifetime — consume a recovery attempt (or a separate max-hold counter) on each origin-live re-arm, and fall through to recoverCommittedSchemaQuiesce() once it is exhausted.


Generated by Barber AI

function expireSchemaWorkerBarrierLease(message) {
// The origin can legitimately block its event loop during synchronous RocksDB destruction.
// Keep replacement workers fenced until that owner disconnects or sends a terminal message.
if (isThreadConnected(message.originator)) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low: this recheck loop has no cap, so an unreleased barrier can block worker replacement for the life of the process

expireSchemaWorkerBarrierLease() re-arms a 1s timer indefinitely while the origin thread is connected. holdWorkerStartsForSchema() gates startAfterSchemaWorkerBarrier(), which is how a crashed worker's replacement is started (server/threads/manageThreads.js:670) — so an origin that stays alive but never sends release-worker-starts (stuck in a synchronous drop, or a releaseSchemaWorkerBarrier() broadcast that never lands; both failures are warn-only in utility/signalling.ts) permanently prevents worker replacement. The previous unconditional expiry bounded that at leaseUntil.

Suggested fix: cap the rechecks the way MAX_COMMITTED_QUIESCE_RECOVERY_ATTEMPTS caps the quiesce lease, and log loudly when the cap is hit, so a lost release degrades to a bounded window instead of a permanent one.


Generated by Barber AI

}

function isThreadConnected(ownerThreadId) {
if (ownerThreadId === threadId || ownerThreadId === 0) return true;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low: a second thread-liveness predicate with weaker semantics than the isThreadRunning() already in this file

isThreadRunning() (line 1188) has the identical first line and is the authoritative check: it queries the main thread for thread info and awaits any in-flight process-group termination before reporting an owner gone — an invariant that unitTests/server/threads/fixtures/processGroupReclaimHarness.js explicitly pins. isThreadConnected() instead reads the local connectedPorts array, which lags REMOVE_PORT and knows nothing about process-group teardown, so the two now answer the same question differently depending on which one a caller reaches for.

Separately, originator is optional on SchemaQuiesceMessage, and an undefined owner id falls through to connectedPorts.some(...) and returns false — releasing the fence. That is the opposite of the fail-closed default the new comment states, and the release path republishes the target while the origin may still be destructing storage.

Suggested fix: reuse isThreadRunning() if the callers can afford its 1s query; otherwise document why the local view is deliberate here, and make an unknown owner id hold the fence rather than release it.


Generated by Barber AI

leaseUntil: Date.now(),
};
await schema_handler({ type: 'schema', message });
await waitForSchemaWorkerStarts();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this test hangs instead of failing when the release regresses

.mocharc.json sets timeout: 0, so a regression that keeps the barrier held makes this await block forever and the whole unit-test job time out rather than reporting a failed assertion. Verified: with isThreadConnected() forced to return true, this test hung and only reported a failure once run with an explicit --timeout.

Suggested fix: race the wait against a short deadline and assert on the winner, e.g. assert.strictEqual(await Promise.race([waitForSchemaWorkerStarts().then(() => 'released'), new Promise((r) => setTimeout(() => r('held'), 2000))]), 'released');


Generated by Barber AI

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:storage Storage engine, LMDB/RocksDB, compaction

Projects

None yet

Development

Successfully merging this pull request may close these issues.

@expiresAt per-record TTL never evicts on RocksDB (default engine) — index.getValues() is LMDB-only

3 participants