Skip to content

Stop RocksDB eviction from deleting renewed records or the blobs of records that survive - #3413

Merged
kriszyp merged 7 commits into
mainfrom
fix/evict-rocksdb-version-gate-blob-commit
Oct 23, 2026
Merged

kriszyp merged 7 commits into
mainfrom
fix/evict-rocksdb-version-gate-blob-commit

Conversation

@kriszyp

@kriszyp kriszyp commented Oct 21, 2026 •

Copy link
Copy Markdown
Member
A robot janitor holding a stale index card gestures at a fresh glowing file folder with butterfly wings

⊙ Problem

On RocksDB, Table.evict() could delete data it should have kept:

  • Renewed records. On tables with no audit log and no source, it skipped the version check. A stale eviction deleted the renewed record, left its new index entries behind and unlinked its blob files.
  • Blobs of surviving records. It queued blob files for deletion before committing. When the commit failed on a conflicting write, the record survived but its blob file was deleted.

🤔 Is the evict-only fix the right scope?

Yes, as the task specified. _writeDelete and updateRecord still delete blobs before commit (#2861, #3367). The shared fix belongs in removeEntry/DatabaseTransaction, not in each caller.

💡 Solution

⚖️ Alternatives

🤔 Accept the narrower race that post-commit deletion keeps?

Deleting after commit prevents blob loss on commit conflicts. A narrower race remains, shared by LMDB eviction and source-miss removal: a write that re-encodes the same blob between commit and cleanup can lose its re-reference flag. The fix belongs in blob.ts (#3382).

🤔 Leave reused versions to #3384?

A resequenced write can reuse the version the caller read, and the gate still evicts that record, as on main and in the batcher. Refusing reused versions changes when expired records leave, so it is separate.

🔧 Changes

✅ Verification

  • New unitTests/resources/evictionBlobs.test.js runs on an audit: false table with no source, an index and file-backed blobs.
  • The renewed-record and conflicted-eviction tests fail on base e187b83d2 (RocksDB: "the renewed record must survive"; "an abandoned eviction must not queue the competing write's blob for reclamation"). On LMDB the first passes on base and with the fix; the others are skipped on LMDB.
  • Local test:unit:resources: LMDB 3413 passing; RocksDB 4512 passing, with 2 load flakes (replicationSeqCursor, subscriptionReplay) that pass alone. test:unit:main and test:integration:all could not finish locally next to 7 other suites, so CI ran them: all 28 unit and integration jobs (Linux, Bun, uWS, Windows) passed at ffbd495, which has the same production code. At 2a20c57, three unrelated flaky jobs were rerun.
  • End to end: the tests drive the production Table.evict() against real stores, indexes and blob files. A running server cannot order these interleavings deterministically.
  • PR QA: skipped @ 2a20c57 — dispatch unavailable (no worker token available to this agent).
Test cases
  • Renewed record: the record, its index entry and its blob file survive a stale eviction. A later eviction of the live version still removes them.
  • Conflicted eviction: the commit is ordered to fail with ERR_BUSY. The blob file and its re-reference flag survive.
  • Refill before cleanup: a write committed between the eviction's commit and its cleanup keeps the blob file. Removing collectRetainedFileIds makes this test fail.
  • Resequenced write: a write that reused the read version changed an indexed field. The eviction leaves no index entry behind. Indexing from the caller's copy makes this test fail.

Refs #2861

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

Release target: v5.4 — the milestone of #2861; v5.3 has the same code, but no known need for a backport
Related PRs: #2155 overlaps (rewrites the same evict() version check and removal), #3375 overlaps (adds a replica branch inside evict()), 31 others independent
Complexity: complicated

Deferred findings

Dispatch: task harper-evict-rocksdb-version-gate-blob-commit · queued by unknown · ran by claude/opus/high · worker kzyp-xps-1

Review-Coverage: authored=claude; ran=gemini,codex; adjudicated=domain; blocked=cursor-grok(failed); declined=cursor-composer,cursor-kimi,cursor-muse,muse; rounds=6; full=1 @ 2a20c57

Review-Attention: study ~15m (critical: Table.ts; decisions: per-caller-post-commit-reclaim, reclaim-on-commit-not-durability, version-gate-reused-version) @ 2a20c57

kriszyp and others added 6 commits October 21, 2026 09:00
…commits

Refs #2861

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01227hE2ocnUgq4RfTawEXQ8
Dispatch-Task: harper-evict-rocksdb-version-gate-blob-commit
…y after commit

evict() read the entry in its transaction and compared the caller's version only on
audited or sourced tables; elsewhere it removed whatever version was live, deleting a
renewed record, orphaning its new index keys and unlinking its blobs. Its RocksDB
removal also went through removeEntry, which queues blob reclamation at staging, so an
eviction abandoned on ERR_BUSY still reclaimed a blob the surviving record references.

Refs #2861

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01227hE2ocnUgq4RfTawEXQ8
Dispatch-Task: harper-evict-rocksdb-version-gate-blob-commit
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01227hE2ocnUgq4RfTawEXQ8
Dispatch-Task: harper-evict-rocksdb-version-gate-blob-commit
A resequenced write can reuse the version the caller read, so the caller's copy may not be
the record being removed; the eviction batcher already indexes from the fresh entry.

Refs #3384

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01227hE2ocnUgq4RfTawEXQ8
Dispatch-Task: harper-evict-rocksdb-version-gate-blob-commit
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01227hE2ocnUgq4RfTawEXQ8
Dispatch-Task: harper-evict-rocksdb-version-gate-blob-commit
…matches the stored record

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01227hE2ocnUgq4RfTawEXQ8
Dispatch-Task: harper-evict-rocksdb-version-gate-blob-commit
@kriszyp kriszyp added this to the v5.4 milestone Oct 21, 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 record eviction logic (evict()) in resources/Table.ts to ensure that blob files are only reclaimed after the eviction transaction successfully commits, preventing premature deletion in case of conflicts or abandoned evictions. It also updates resources/DESIGN.md to document these changes and adds a comprehensive test suite in unitTests/resources/evictionBlobs.test.js. The review feedback points out a style guide violation in the new test file, where Node.js built-in modules (fs, crypto, and timers/promises) should be imported using the node: prefix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01227hE2ocnUgq4RfTawEXQ8
Dispatch-Task: harper-evict-rocksdb-version-gate-blob-commit
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.

1 participant