Fix multi-worker lost counter increments and stale reads when a resequenced write reuses a version - #2259
Fix multi-worker lost counter increments and stale reads when a resequenced write reuses a version#2259kriszyp wants to merge 5 commits into
Conversation
…sion a resequenced write reused A resequenced (out-of-order CRDT) write stores its merged record under the version it merged onto rather than advancing it — the version is the max applied update timestamp and must stay that way for cross-node convergence — so one version can identify two different stored values. The record cache's freshness oracle is exactly version equality (the rocksdb-js VerificationTable), so a worker still holding the pre-merge value is told it is fresh and serves it, and an addTo folding onto that stale base silently drops the increment the merge applied. That is the lost count QA-431(5) catches intermittently under multi-worker stress. Mark such a record VERSION_REUSED at the write that reuses the version (recordUpdater covers every record write), and park an unvouchable sentinel in the VerificationTable slot when a read encounters one, so no worker's cold read republishes that version and nothing caches the record until a later in-order write gives it a version of its own. Reproduced end-to-end on unpatched main (4-CPU-constrained amplified QA-431(5) traffic with concurrent GET pressure): windows durably stuck below their acked count across 20 polls; green with this change. The new unit tests fail on unpatched main at the invariant assertions. Builds on the abandoned branch fix/record-cache-stale-on-reused-version (worktree agent-harper-ttl-rate-limiter-lost-counts), validated here with a reproduced red/green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XLwSQZt7J2Ygn3B2EvNson
…d-version sentinel from the write The prior commit's read-side machinery was not enough: instrumented runs showed every lost increment came from a commit-path base read that the cross-worker version vouch confirmed as fresh. Two paths kept it alive: - The commit path (first attempt via the resource-phase read, and every coordinated-retry reload) read its base through the cache-vouch fast path. The vouch answers "is this the latest committed version?", which is the wrong question for a snapshot read — and for a version a resequenced write reused it is wrong outright, so an addTo or patch folded on the pre-merge value and overwrote the concurrent update it merged over. Incremental updates now always reload their base at commit through the committing transaction with a new uncachedRead option that bypasses the vouch, the VerificationTable seeding, and the cache entirely. - A reader-side sentinel park cannot close the read path: on a VT miss the native layer re-confirms freshness against the version stored in the record itself and republishes it — over the sentinel — so with every worker holding a warm cache no reader ever decodes the record to discover the VERSION_REUSED flag, and a stale holder is confirmed fresh forever (observed as GETs pinned below the acked count until expiry while the store held the correct value). The write now parks the sentinel itself on the transaction's success path — the writer knows before any reader can — and warm reads consult the sentinel before trusting version equality. Validated under the QA-431(5) reproduction (4-CPU-constrained, GET pressure during 4-worker bursts): unpatched main lost increments in 3-15 windows per 900; with this change 900/900 windows exact across three runs and zero stale vouches in the instrumented audit. The remaining exposure is the native soft-miss re-confirm racing the writer's park (instruction-scale); closing it fully needs rocksdb-js to honor a no-vouch flag, tracked as a follow-up. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XLwSQZt7J2Ygn3B2EvNson
…edicate, discriminating tests From the cross-model review round (Codex + Gemini + domain adjudication): - Full updates now reload their base at commit too: a put's existingEntry drives index diffing, blob retention, and residency, and a vouch-stale base with a reused version passes the optimistic check while diffing against the wrong old record (orphaned secondary-index rows; the previousResidency line also read the stale resource-phase closure entry and is now fed by the reloaded base). Bulk copy-apply rows and crash-recovery replays keep their pre-read base — one read per row, as before — since their convergence contract is the post-copy/replay pass. - parkUnvouchable now verifies the sentinel took and the transaction logs when a concurrent write's intent refused it, so the abort race the review identified is detectable instead of silent. - "This write stores under a reused version" is now derived once (versionIsReused in RecordEncoder) instead of by two expressions that agreed by coincidence. - The uncachedRead unit test now discriminates by object identity (the vouch path serves the cached object; a regression into it would have passed the old equal-values assertion), and the sentinel assertions use the exported constant. - The multi-worker convergence discriminator that reproduced the defect is committed as integrationTests/resources/ttl-rate-limiter-convergence.test.ts (10x10x50 with in-burst GET pressure; classifies converged-late vs stuck-short so a read-timing race is never mistaken for a lost write). - Warm-read probe cost measured (unitTests/resources/cache-probe.bench.js): 141ns/op, 525 vs 384 ns/op warm getEntry — noise per HTTP request, real in tight loops; the rocksdb-js no-vouch follow-up folds it into the existing native crossing. - Trimmed narrating comments flagged by the review. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XLwSQZt7J2Ygn3B2EvNson
…tried parks, contained late throws - The reload predicate is now an explicit reloadCommitBase write-kind flag (the round-1 fullUpdate inference silently excluded deletes, invalidates and relocates): a delete tearing down index entries from a vouch-stale base left phantom index hits for a deleted record, and a tie-timestamp tombstone set the durable VERSION_REUSED flag with nothing marking it for a park — delete commits now mark storedReusedVersion like updates do. - A refused park (concurrent write intent) is retried once after the intent has had time to clear, and a still-refused park logs at warn with store and key: if the competing write aborted, nothing else re-parks, and warm peers would silently serve the pre-merge value until the key's next write. - parkReusedVersionSentinels and parkUnvouchable contain any native throw: they run after durability, and a closing-store throw was skipping clearWrites/releaseContext and leaking the context. - Convergence test: a window whose burst was wholly rejected counts as inconclusive instead of clean (the measurable-fraction assertion then catches a run that rejected most increments). - Warm-read probe cost re-measured against a pristine-main build rather than by subtraction: 373 ns/op → 525 ns/op warm getEntry (+152 ns, the probe). The rocksdb-js no-vouch follow-up folds it into the existing native crossing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XLwSQZt7J2Ygn3B2EvNson
…resolve parks, tolerant test oracle - _recordRelocate always stores at the unchanged version but writes outside the tracked-write flow, so its park now happens directly after the write; _writeInvalidate and _writeRelocate mark storedReusedVersion for the nodeId-won timestamp tie their version guard admits. The park now covers everything that sets the durable flag. - parkUnvouchableWithRetry centralizes the refusal handling: bounded backoff (10/50/250ms) while a concurrent write's intent holds the slot, and the final refusal is resolved against the stored head — a competitor that advanced the version resolved the key legitimately (debug), a still- flagged head is the silent stale-read hole (warn with store and key). - Convergence test: a stored count in (acked, acked+errs] is a timed-out request that was applied, not a double-apply; the over assertion now only trips beyond acked+errs. The remaining open review major is the warm-read probe cost (373→525 ns/op vs pristine main), consciously carried until the rocksdb-js no-vouch follow-up folds the check into the existing native crossing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XLwSQZt7J2Ygn3B2EvNson
|
Reviewed; no blockers found. |
There was a problem hiding this comment.
Code Review
This pull request introduces a mechanism to handle version-reuse cache staleness in RocksDB-backed tables. It prevents the Verification Table (VT) from vouching for reused versions (where a resequenced write keeps an existing version, meaning one version could identify multiple different stored values) by marking such records with a VERSION_REUSED metadata flag, parking a VERSION_UNVOUCHABLE sentinel in the VT, and introducing an uncachedRead option to bypass the cache vouch on critical commit paths. The changes also include comprehensive integration tests, unit tests, and a benchmark script to measure the sentinel probe overhead. There are no review comments provided, so I have no feedback to address.
Fixes the intermittent
QA-431(5): N window(s) with lost counts under multi-worker stressfailure inintegrationTests/resources/ttl-rate-limiter-concurrent.test.ts— investigated and reproduced as a genuine product defect, not a test timing assumption.Root cause
A resequenced (out-of-order CRDT) write stores its merged record under the version it merged onto rather than advancing it — the version is the max applied update timestamp and must stay that way for cross-node convergence — so one version can identify two different stored values. Every freshness decision keyed on version equality is then wrong:
getEntry(key, {transaction})). The vouch answers "is this the latest committed version?" — the wrong question for a snapshot read, and wrong outright for a reused version — so anaddTo/patch folded onto the stale pre-merge value and durably overwrote the concurrent increment it had merged over. A patch stores a full record derived from its base, so a stale base also silently resurrects old values of fields the patch didn't mention; a put's stale base corrupts index diffing, blob retention, and residency the same way.Fix
recordUpdatermarks a record stored under a non-advancing version with aVERSION_REUSEDmetadata flag (versionIsReused()is the single definition both the flag and the park derive from); reads of flagged records are never cached and never vouched.uncachedReadoption onPrimaryRocksDatabase.getEntry: a plain transaction-snapshot read — no vouch trust, no VT seeding, no cache publish. Every record-write kind that derives stored state from its base (update, put, delete, invalidate, relocate — marked with an explicitreloadCommitBaseflag) reloads that base at commit through it; bulk copy-apply rows and crash-recovery replays keep their pre-read base (one read per row, as before — their convergence contract is the post-copy/replay pass).previousResidencyis derived from the reloaded base. Without the delete-path reload, a delete diffing indices from a stale base left phantom index entries for the deleted record.Verification
tasksetto 4 CPUs to emulate CI runners) lost increments in 3–15 windows per 900, with a per-vouch audit proving the losses durable (store short) — plus stale-read windows where the store was correct but every GET served a vouched stale value. The new unit tests fail on unpatched main at the invariant assertions.integrationTests/resources/ttl-rate-limiter-convergence.test.ts— it classifies each short window as converged-late (read-timing) vs stuck-short (durable loss), so a timing race is never mistaken for a lost write.test:unit:resources1674 pass / 3 fail and LMDB resources 1352 pass / 2 fail — every failure reproduced on pristine main (pre-existing: randomAccessFields ×2, replayStructures ×1).test:unit:mainis unrunnable on the fleet box for any branch (startup crash opening a stale shared data dir; environmental).For the human reviewer
getEntrymeasured 373 ns/op on a pristine-main build vs 525 ns/op with the fix (unitTests/resources/cache-probe.bench.js, 4-CPU-pinned). Noise per HTTP request; visible in tight cached-read loops. The rocksdb-js follow-up (native honors a no-vouch bit in the fixed metadata word at value offset 8) folds this into the existing native crossing and reclaims it.reloadCommitBase; flagged (unadjudicated) in the final review round — worth a follow-up look rather than widening this PR.Builds on the abandoned branch
fix/record-cache-stale-on-reused-version(its read-side machinery is commit 1, validated and extended here).Refs #1881 (same version-reuse family, index-scan surface).
Signed: Claude Fable 5 (dispatch agent)
🤖 Generated with Claude Code
Review-Coverage: authored=claude; ran=codex,gemini; declined=cursor-grok,cursor-composer,domain; rounds=4 @ 426518f
Human-Review-Need: 4 @ 426518f