Repository navigation
Stop verification-table slot collisions from answering FRESH for another key - #901
Merged
Merged
Conversation
Contributor
There was a problem hiding this comment.
Code Review
This pull request introduces key-tag encoding for verification table slots to prevent stale reads when colliding keys share the same version. It replaces bare slot pointers with a VtSlotRef struct that encapsulates both the slot pointer and a 63-bit key tag, and updates the verification table primitives to encode versions using XOR with the key tag. Feedback highlights a compatibility issue in the new integration test where spawning a .mts file directly via process.execPath will fail on Node.js versions prior to 22.6.0, suggesting instead to resolve and spawn the tsx CLI entry point.
Contributor
📊 Benchmark Resultsget-sync.bench.tsgetSync() > random keys - small key size (100 records)
getSync() > sequential keys - small key size (100 records)
ranges.bench.tsgetRange() > small range (100 records, 50 range)
realistic-load.bench.tsRealistic write load with workers > write variable records with transaction log
transaction-log.bench.tsTransaction log > read 100 iterators while write log with 100 byte records
Transaction log > read one entry from random position from log with 1000 100 byte records
worker-put-sync.bench.tsputSync() > random keys - small key size (100 records, 10 workers)
worker-transaction-log.bench.tsTransaction log with workers > write log with 100 byte records
Results from commit 3561821 |
kriszyp
marked this pull request as ready for review
October 5, 2026 19:57
This was referenced Oct 5, 2026
…her key A slot is addressed by hash(dbEpoch, cfId, key) & mask, so unrelated keys share slots, and a slot held a plain version. Keys written in one transaction carry the same version, so after one of them was rewritten, any colliding sibling that was re-cached published that version back into the shared slot, and verifyVersion/getSync answered FRESH for the rewritten key: a stale read. With the default 128K slots, the chance per key is about the number of same-version keys divided by the slot count. slotRefFor() now returns the slot with a 63-bit tag derived from the same hash, and every verify and populate path stores and compares version ^ tag, so a colliding key with an equal version no longer matches. Lock and settle paths are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…DME claim Take the tag from the slot hash directly (h >> 1, 47 bits independent of a 128K-slot index) instead of a second mix, rename the bare-pointer primitives to *Encoded so a plain version is not passed to them, and state the residual tag-coincidence bound in the README. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The expected-version read of the updated key takes the soft-miss path, which publishes the key's new version into its slot. With a random per-process seed, that slot is the last re-cached key's 1 time in 16, so the "last one cached still verifies" assertion failed intermittently (seen on Node 26 / ubuntu). Dispatch-Task: pr-maint-e1c7a4b6c27b2c50db4134c606417fb0 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The README said a collision "never" lets a stale value pass as fresh and then gave a probability, quoted 2^-46 where DESIGN.md and the code say 2^-47, and did not say the bound depends on the table size. Dispatch-Task: pr-maint-e1c7a4b6c27b2c50db4134c606417fb0 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kriszyp
force-pushed
the
fix/vt-collision-false-fresh
branch
from
October 6, 2026 04:43
f9b3cdf to
0b26880
Compare
cb1kenobi
approved these changes
Oct 6, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: fix-kriszyp_rocksdb-js_901-3fddd46d
This was referenced Oct 6, 2026
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
The verification table can answer FRESH for a key whose cached copy is stale.
Slots are addressed by
hash(dbEpoch, cfId, key) & mask, so unrelated keys share slots, and a slot stored the plain version. Keys written in one transaction carry the same version. Suppose A and B share a slot and were written together at version V, and A is then rewritten. A later read that re-caches B publishes V into the shared slot, andverifyVersion(A, V)orgetSync(A, …, expectedVersion = V)answers FRESH for A's old copy. That is a stale read. With the default 128K slots, the chance per key is roughly the number of keys sharing its version divided by 131,072, so large transactions make it routine. The README and header both claimed collisions "never" cause a stale value to be treated as fresh.It surfaced as an intermittent CI failure in
test/verification-table.test.ts("a version cached before close never leaks into a later incarnation"), first seen on Make optimistic commit lock buckets and validation policy configurable (#897). That test opens 40 fresh databases and seeds the same key and version in each. Every incarnation has a unique epoch, but each new slot can still collide with one an earlier incarnation seeded, which is a birthday collision (≈0.6% per run of that test alone).💡 Solution
VerificationTable::slotRefFor()returns aVtSlotRef: the slot plus a 63-bit tag taken from the same hash (h >> 1). Keys that share a slot agree only on the index bits, so at 128K slots two colliding keys still get the same tag with probability 2^-47. Every path that compares a version with a slot or publishes one now goes throughvtEncodeVersion(version, tag)=version ^ tag. Bit 63 stays clear, so tagged lock and settled-empty values are unaffected, and an encoding of 0 is never published or matched. A colliding key with an equal version therefore stores a different value, so collisions are back to costing only misses. Lock and settle paths keep usingslotFor()'s bare pointer because they never compare versions. The bare-pointer primitives are renamedverifyEncoded/populateEncoded/populateEncodedIfUnchanged, so a plain version is not passed to them by mistake.⚖️ Alternatives
🔧 Changes
src/binding/core/verification_table.{h,cpp}:vtEncodeVersion,VtSlotRef(withholds()),slotRefFor(), andVtSlotRefoverloads ofverifyVersion/populateVersion/populateVersionIfUnchanged;*Encoded;slotFor()andslotRefFor()share onehashFor();src/binding/database/database.{h,cpp},src/binding/transaction/transaction{,_handle}.{h,cpp}:vtSlotFor()returns aVtSlotRef. The sync FRESH fast paths useholds(), andvtPopulateIfSettled, the async-get state,verifyVersion()andpopulateVersion()go through the ref.README.md,src/binding/core/DESIGN.md,DESIGN.md: state the encoding and why keys that share a version need it. The README now states the residual as a probability, 2^-47 per lookup at the default 131,072 slots, doubling with each doubling ofverificationTableEntries, rather than "never".✅ Verification
test/verification-table-collision.test.ts(child-process fixturetest/fixtures/fork-vt-shared-version-collision.mts):getSyncwith that expected version must return the new value, and the last re-cached key must still verify.verifyVersionanswering true for the stale version.getSyncof the rewritten key takes the soft-miss path, which publishes that key's new version and evicts the last re-cached key whenever the two share a slot under the random per-process seed. The check now runs before those reads. Local Linux, Node 26: 6 of 60 runs failed before, 0 of 200 after.Native:
CollidingKeysWithSameVersionDoNotVouchForEachOther,VersionEqualToKeyTagIsNeverCached.Standalone repro of the CI test's loop (open a fresh DB, check the key, seed it, close; 2,000 iterations). Birthday math predicts about 15 hits.
origin/main(macOS Node 24 / Node 22, Linux Node 22)The hit rate does not change with the optimistic lock-bucket count (2^20, 65,536 or 16), so Make optimistic commit lock buckets and validation policy configurable #897 did not cause this.
Cost on the FRESH fast path: a microbenchmark of 2M
getSync(key, 0, undefined, version)hits per round (7 rounds, 3 alternating runs, macOS arm64) measured 93–95 ns per hit onorigin/mainand 97–102 ns with this change. That is about +3 to +8 ns, or 3–8%, and it held after shiftingmain's code layout. I could not attribute it to the extra XOR and compare, which should cost about a nanosecond, so treat it as an upper bound. ❓ Your call: whether that cost is acceptable for closing the stale read.The cross-model pre-push review (Codex, Gemini, Cursor Composer, plus Harper-domain adjudication) has run. Its findings are addressed: the cheaper tag, the
*Encodedrename, and the bounded README claim. The hot-path cost above is the remaining open item. A follow-up full round on the CI fix (Codex, Gemini, Cursor Composer, Harper-domain) found no code defects and produced the README wording fix; its delta round converged. Declined: a nit that the fixture only exercises the collision when the rewritten key shares a slot with another key, which fails to happen with probability ≈7×10⁻⁸; the native test forces the collision deterministically.macOS:
pnpm check,pnpm test(75 files, 1,104 passed, 10 skipped; then the VT, collision and transaction files again after the review fixes) andpnpm test:native(301 passed). Linux, Windows, Bun and Deno are left to CI.Make optimistic commit lock buckets and validation policy configurable (#897) surfaced this in its CI and should merge after this PR. Add a native RocksDB storage lease for derived indexes (#842) and Run a database's async commits on up to four concurrent commit threads (#902) touch read/write paths: any verify or populate site they add must go through
vtSlotFor()/slotRefFor(), since a rawslotFor()comparison still compiles.Rebased onto
mainpast Read every entry of a transaction-log segment that one transaction pushed past transactionLogMaxSize (#890) (unrelated transaction-log work). The only conflict was an add/add on the rootDESIGN.mdindex — both branches independently added the same one-line pointer file — resolved by keeping both entries undermain's heading. Because the rebase moves every commit SHA, the pre-push review re-ran as a full round (Codex, Gemini, Cursor Composer, Harper-domain); it found no new defects. Every finding mapped to a decision already recorded above or adjudicator-dismissed: the hot-path-cost ❓ restated as a "major" (no committedbenchmark/case for the tagged cache-hit path; the domain reviewer's own code trace calls the added cost "likely negligible" and unattributable, matching the microbenchmark above), the fixture-seed-dependence nit already declined, and a comment-narration nit already declined. Gemini's coverage-gap nit (noverifyVersioncheck after an async populate) was dismissed by the domain adjudicator — already covered bytest/verification-table.test.ts:209-212. Cursor's claim that the README's 2^-47 is wrong was dismissed as factually wrong (it has the mod-N/tag math backwards).Review follow-up (2026-10-06): a reviewer asked for a doc comment on
VerificationTable::slotRefFor; added in the file's tab style, also stating that a disabled table returns an empty ref. The earlier fixture-ordering thread was already fixed by the CI follow-up above. Delta pre-push review (Codex, Gemini, Harper-domain) on the doc comment found no new defects; it re-raised the items already recorded above (hot-path cost ❓, fixture seed nit, comment-narration nit, which now also names this doc comment — kept, since a human reviewer asked for it and every sibling method has one). Gemini's new claim of a deadholds()check after the read inGetSyncwas dismissed as factually wrong:holds()appears only on the fast path, and the post-read block publishes throughvtPopulateIfSettled's conditional CAS.Related PRs: #897 overlaps, #842 overlaps, #902 overlaps, #742 independent, #767 independent, #890 independent, #900 independent
Complexity: low
— Claude Opus 5.5 (CI fixture fix and README wording, 2026-10-05)
— Claude Sonnet 5 (rebase onto main past #890, 2026-10-06)
— Claude Opus 5.5 (review follow-up:
slotRefFordoc comment, 2026-10-06)🤖 Generated with Claude Code
Closes #903.
Review-Coverage: authored=claude; ran=gemini,codex; adjudicated=domain; declined=cursor-grok,cursor-composer,cursor-kimi,cursor-muse; rounds=4; full=2 @ e82deef
Review-Attention: study ~12m (critical: transaction.cpp, transaction_handle.cpp +1; decisions: xor-tag-in-slot, tag-from-index-hash, probabilistic-contract, do-less-alternative) @ e82deef