Repository navigation
Stop RocksDB eviction from deleting records renewed under the same version, or the blobs of records an abandoned batch keeps - #3575
Merged
Conversation
Contributor
There was a problem hiding this comment.
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.
…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
force-pushed
the
fix/evict-version-reused-guards
branch
from
October 27, 2026 03:14
381e3b3 to
c41f97f
Compare
kriszyp
marked this pull request as ready for review
October 27, 2026 14:55
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
⊙ 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_REUSEDand 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
evict()takes that expiry as an optional last argument; internal callers pass it.Fixes #3384
⚖️ Alternatives
🤔 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
resources/Table.ts,evict(): new optionalobservedExpiresAton the interface and the method;reusedVersionChangedExpiry.resources/Table.ts, eviction batch: items carry the scannedexpiresAt(stored on add); staging applies the same rule and re-checksHAS_BLOBS | REPLICAbeforeremoveEntry, which queues blob reclamation at staging.resources/Table.ts, callers: the read-path expiry check, the cleanup scan (batched and per-record) and the@expiresAtsweep pass the expiry they read.security/certificateVerification/verificationTables.ts: passesnull, since those verdicts are selected for having no expiry.resources/DESIGN.md: the rule and where it is enforced.🤔 Should the
@expiresAtsweep 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 setsVERSION_REUSED.✅ Verification
unitTests/resources/evictionBlobs.test.jsfail on a fresh build oforigin/mainsources 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.🤔 Are the test helpers' comments worth keeping?
Yes. Each states a fact the body does not show:
resequencemust add a key the record lacks (older writes to existing keys are dropped);scanReadsBeforefeeds only the next pass.New tests and gate details
New tests (RocksDB; LMDB has no reused versions and skips them):
evict()keeps a record a resequenced write renewed from the tableexpiration; evicting with the current expiry still removes it and reclaims its blob.evict()withnulltreats a gained expiry as a change; with no argument, only the version is checked.evict()keeps a cached record a revalidation renewed under an unchanged sourcelastModified.evict()'s own read).ERR_BUSY, the retry re-applies the rule).ERR_BUSY) keeps the blob of a record that gained one at the same expiry; the re-reference signal shows no reclamation was queued.Gate failures, none in code this PR touches:
test:unit:resourcesbefore the rebase (RocksDB 1, LMDB 3):subscribeReadGateCallerImpact.test.jssocket?.off is not a function, fixed onmainby Give the read-gate WebSocket test's fake socket the off() REST calls on close #3562; LMDBreplicationDirectiveAuthorization.test.js,LMDBTransactionhas noreplicationConfirmationexport onmain.test:integration:all(6): 4 Ollama tests (no local models);put-operationandttl-rate-limiter-concurrenttimed out under load and pass on rerun (25/25).test:unit:maindid not finish in 55 minutes on a box at load ~22. Its 22 failures were all inComponentV1.test.js, which passes alone (35/35).unitTests/security: 926 passing.@expiresAtsweep wiring is not behavior-tested: on RocksDB the sweep stops beforeevict()(@expiresAt per-record TTL never evicts on RocksDB (default engine) — index.getValues() is LMDB-only #1481), and LMDB never setsVERSION_REUSED.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
121445d3b552eb4fnit data-integrity —resources/Table.ts— @expiresAt sweep decides on value[expiresAtName] but passes metadata entry.expiresAt as observedExpiresAt — NOT FILED12a199780137ad7bnit data-integrity —resources/Table.ts— omitted observedExpiresAt keeps version-only eviction for callers that await between read and evict — NOT FILEDLate Grok findings
3 surviving (highest minor) — Grok reviewed 8344eb9
resources/Table.ts:11011— The@expiresAtindex sweep decides to evict from the record's field (value[expiresAtName],:11011), but it passes metadataentry.expiresAtasobservedExpiresAt(:11018). Failure case: a resequenced (out-of-order merged) patch keeps the same version, setsVERSION_REUSED, moves the fieldunitTests/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/:240restate the helper's purpose. Trim to the ones that carry non-obvious factsunitTests/resources/evictionBlobs.test.js:181— Every new test producesVERSION_REUSEDthrough 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 gapNot 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-1Review-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