Skip to content

Stop RocksDB eviction from deleting records renewed under the same version, or the blobs of records an abandoned batch keeps - #3575

Merged
kriszyp merged 4 commits into
mainfrom
fix/evict-version-reused-guards
Oct 27, 2026
Merged

kriszyp merged 4 commits into
mainfrom
fix/evict-version-reused-guards

Conversation

@kriszyp

@kriszyp kriszyp commented Oct 26, 2026 •

Copy link
Copy Markdown
Member
Kris pulls a freshly tagged crate off the conveyor before the recycling chute

⊙ Problem

On RocksDB, some writes keep a record's version: an out-of-order write, a revalidation whose source reports the same version, a replica store. They set VERSION_REUSED and usually renew the expiry. Table.evict() and the cleanup scan's eviction batch read "same version" as "unchanged", so an eviction that had read the expired copy deleted the renewed record, its index entries and its blob files. A batch that lost its commit could also unlink a kept record's blob.

🤔 Is this the right scope?

As specified, yes. It answers #3384's open question (the merged record does not keep the old expiry) and closes the #3413 blob deferral. An older out-of-order write overwriting the newer record's expiry is a separate defect and stays out.

💡 Solution

Fixes #3384

⚖️ Alternatives

  • Give same-version writes a new version — the RocksDB version is also the replicated ordering token.
  • Keep the older expiry on same-version writes — a revalidation renews legitimately, and the blob case remains.
  • On a reused version, evict only if expired — pressure eviction targets unexpired records. A cache revalidating under a stable source version could not shed load; certificate verdicts without expiry would never be evicted.

🤔 Should an omitted expiry block eviction of any reused version that has one?

No: omitting the expiry keeps version-only eviction. Callers that omit it evict what they just read (tests, one integration fixture). A stricter default would silently make them no-ops. Every internal caller with a gap between read and eviction passes the expiry. Changing this is one comparison.

🔧 Changes

🤔 Should the @expiresAt sweep compare the field it decides on?

It passes the metadata expiry used to hide expired records from reads. Checking the field belongs with enabling that sweep on RocksDB (#1481): it stops before evict() there, and only RocksDB sets VERSION_REUSED.

✅ Verification

  • 8 new RocksDB tests in unitTests/resources/evictionBlobs.test.js fail on a fresh build of origin/main sources and pass with the fix (5 runs in a row). They drive the production callers and the scan → batch → commit path, injecting only the race.
  • RocksDB resources 5025 passing on the current head; LMDB resources 3709 and integration 2760 before the rebase (failures below).
  • End-to-end: not observable without injecting the race between a caller's read and the eviction.

🤔 Are the test helpers' comments worth keeping?

Yes. Each states a fact the body does not show: resequence must add a key the record lacks (older writes to existing keys are dropped); scanReadsBefore feeds only the next pass.

New tests and gate details

New tests (RocksDB; LMDB has no reused versions and skips them):

Gate failures, none in code this PR touches:

PR QA: Do renewed cache records survive eviction while several workers read and revalidate them? — queued @ 381e3b3, qa-pr-harper-3575-381e3b3
Docs: none needed — evict()'s new argument is internal and undocumented.

🤖 Generated by Claude Code (Claude Opus 5.5); posted via @kriszyp.

Release target: v5.4 — builds on the eviction fixes in #3413 and #3471, both milestoned v5.4 and not on v5.3
Related PRs: #2155 overlaps (also changes the evict() signature and the @expiresAt sweep call), 32 others independent
Complexity: complicated

Deferred findings

  • 121445d3b552eb4f nit data-integrity — resources/Table.ts — @expiresAt sweep decides on value[expiresAtName] but passes metadata entry.expiresAt as observedExpiresAt — NOT FILED
  • 12a199780137ad7b nit data-integrity — resources/Table.ts — omitted observedExpiresAt keeps version-only eviction for callers that await between read and evict — NOT FILED

Late Grok findings

3 surviving (highest minor) — Grok reviewed 8344eb9
  • minor — resources/Table.ts:11011 — The @expiresAt index sweep decides to evict from the record's field (value[expiresAtName], :11011), but it passes metadata entry.expiresAt as observedExpiresAt (:11018). Failure case: a resequenced (out-of-order merged) patch keeps the same version, sets VERSION_REUSED, moves the field
  • nit — unitTests/resources/evictionBlobs.test.js:240 — Added test comments at :213, :223, :240, :399, :421, :430. Most explain facts the code does not show (the retry count, how re-reference is cleared), but :213/:240 restate the helper's purpose. Trim to the ones that carry non-obvious facts
  • nit — unitTests/resources/evictionBlobs.test.js:181 — Every new test produces VERSION_REUSED through a resequenced patch; none goes through the 304-revalidation or replica-store producer. The guard does not care which producer set the flag, so this is a suggestion, not a missing-branch gap

Not standing: 1 dropped-factually-wrong, 1 merged. Grok ran 21m (21m from launch to report); the fold adjudication (cursor-grok@8344eb95d326-c0761ddd, claude) took 1m.

Origin — the dispatch brief this PR was written from

Fixes #3384 (answers its 'Not proven' item) plus the #3413 blob deferral. Table.evict checks only entry.version !== existingVersion, and createEvictionBatcher.stageInto checks only version and value == null, then calls removeEntry at staging time without a HAS_BLOBS guard (the scan filters HAS_BLOBS|REPLICA only at scan time). An out-of-order write at the scanned version reuses the version (VERSION_REUSED) and _writeUpdate stamps a fresh future expiresAt, so eviction deletes a live record. In evict and stageInto re-read the entry in the eviction's own transaction and re-check the entry's own expiresAt against the threshold the scan used (skip a renewed record); in stageInto skip a record that now has HAS_BLOBS, or move blob reclamation to after commit as evict does. Leave the separate 'out-of-order write overwrites the head's expiresAt' defect to its own issue.

Acceptance

Tests on rocksdb: out-of-order write at the scanned version onto an expired record, then evict(id, R1, v1) and a batcher drain -> record, its index entry and its blob file survive; a blob appearing between scan and drain with a forced ERR_BUSY -> blob file survives; normal expiry eviction unchanged. Show each new test failing on origin/main sources (forced rebuild) before the fix.

Dispatch: task harper-evict-version-reused-guards · queued by finding-triage/wave-40 · ran by claude/opus/xhigh · worker kzyp-xps-1

Review-Coverage: authored=claude; ran=cursor-composer,gemini,codex,cursor-grok; late=cursor-grok:adjudicated; adjudicated=domain; declined=muse; rounds=5; full=1 @ c41f97f

Review-Attention: study ~20m (critical: Table.ts; sensitive: verificationTables.ts; decisions: expiry-as-reuse-discriminator, evict-tristate-observed-expiry, batch-skip-not-reroute, do-less-alternative) @ c41f97f

@kriszyp kriszyp added this to the v5.4 milestone Oct 26, 2026

@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 updates the evict method and related eviction logic to correctly handle record renewals that occur between an eviction read and its commit. By introducing an observedExpiresAt parameter, the system can now detect if a record's expiry has changed due to a concurrent same-version write (e.g., revalidation or patch), preventing the accidental deletion of renewed records. The reviewer identified that the initial implementation incorrectly passed undefined for expiresAt in certain code paths, which would bypass the safety check; the suggested fixes ensure that expiresAt is correctly passed as null when undefined, maintaining consistency with the intended logic.

Comment thread resources/Table.ts Resolved
Comment thread resources/Table.ts Resolved
kriszyp and others added 4 commits October 26, 2026 20:45
…ewed, or reclaiming a blob its abandoned batch left in place

A write that does not advance a RocksDB record's version (an older resequenced
write, a 304 revalidation, a replica store) keeps the version, sets
VERSION_REUSED and can stamp a fresh expiry. evict() and the cleanup scan's
eviction batch proved "unchanged since the decision" by version alone, so they
deleted the live record, its index entries and its blob files.

evict() takes the expiry its caller read (observedExpiresAt, null for none) and,
on a reused version, skips an entry whose expiry changed — before a TTL demotion
too. Every deferred caller passes it; omitted, only the version is checked.
Batch items carry the scan's expiry and get the same check, and staging
re-applies the scan's HAS_BLOBS | REPLICA routing: a record that gained a blob
after the scan no longer reaches removeEntry, which queued its reclamation at
staging even when the batch then lost its commit.

Refs #3384

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dispatch-Task: harper-evict-version-reused-guards
…ver an unchanged-version revalidation

The sweep evicts when the record's @expiresAt field has passed, so that field,
not the metadata expiry an explicit expiresAt can override, is the observation
evict() must compare. Adds a test for a cache revalidation whose source reports
an unchanged lastModified: it rewrites the record under the same version with a
fresh expiry, which an eviction that read the old copy must leave alone.

Refs #3384

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dispatch-Task: harper-evict-version-reused-guards
…erns visibility

Comparing the sweep's field expiry with the entry's metadata expiry mixed two
sources: wherever an explicit expiresAt overrides the field (every source fill
sets one), an unchanged reused-version row would never pass. The metadata expiry
is what read-hiding and the cleanup scan use, so a same-version write that left
it unchanged leaves the row exactly as visible as the sweep saw it.

Refs #3384

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dispatch-Task: harper-evict-version-reused-guards
…t sweep's field check belongs

Refs #3384

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dispatch-Task: harper-evict-version-reused-guards
@kriszyp
kriszyp force-pushed the fix/evict-version-reused-guards branch from 381e3b3 to c41f97f Compare October 27, 2026 03:14
@kriszyp
kriszyp marked this pull request as ready for review October 27, 2026 14:55
@kriszyp
kriszyp merged commit ce39718 into main Oct 27, 2026
77 of 87 checks passed
@kriszyp
kriszyp deleted the fix/evict-version-reused-guards branch October 27, 2026 14:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

RocksDB eviction removes a resequenced record that reuses the version its caller read

1 participant