Skip to content

Fix multi-worker lost counter increments and stale reads when a resequenced write reuses a version - #2259

Draft
kriszyp wants to merge 5 commits into
mainfrom
fix/qa431-reused-version-lost-counts
Draft

Fix multi-worker lost counter increments and stale reads when a resequenced write reuses a version#2259
kriszyp wants to merge 5 commits into
mainfrom
fix/qa431-reused-version-lost-counts

Conversation

@kriszyp

@kriszyp kriszyp commented Aug 21, 2026

Copy link
Copy Markdown
Member

Fixes the intermittent QA-431(5): N window(s) with lost counts under multi-worker stress failure in integrationTests/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:

  1. Lost writes (the QA-431 failure). The commit path read its fold base through the record cache's version vouch (first attempt via the resource-phase read, coordinated-retry reloads via 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 an addTo/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.
  2. Indefinitely stale reads. On a VerificationTable miss, the native layer re-confirms freshness against the version stored in the record itself and republishes it ("soft VT miss" in rocksdb-js). For a reused version this re-vouches the stale holder's cache forever once every worker is warm — observed live as GETs pinned at 41–49/50 until TTL expiry while the store held exactly 50.

Fix

  • recordUpdater marks a record stored under a non-advancing version with a VERSION_REUSED metadata flag (versionIsReused() is the single definition both the flag and the park derive from); reads of flagged records are never cached and never vouched.
  • New uncachedRead option on PrimaryRocksDatabase.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 explicit reloadCommitBase flag) 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). previousResidency is 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.
  • The writer parks an unvouchable sentinel in the VerificationTable slot on the commit success path — reader-side parking cannot work because with all workers warm no reader ever decodes the flagged record, and the native soft-miss re-confirm republishes the reused version over a reader-parked sentinel. The park is verified, retried on a bounded backoff while a concurrent write's intent holds the slot, and a final refusal is resolved against the stored head — warn only when the head is still flagged (an aborted competitor would otherwise leave a silent stale-read hole); tie-timestamp delete tombstones mark for parking too. The park path is fully contained so a closing-store throw after durability cannot skip the commit's cleanup.
  • Warm cached reads consult the sentinel before trusting version equality.

Verification

  • Red on unpatched main: amplified QA-431(5)-shaped repro (same fixture, in-burst GET pressure, taskset to 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.
  • Green with the fix: 900/900 windows exact across 3 amplified runs plus 300/300 across the review rounds, zero stale vouches in the audit, and 8/8 runs of the unmodified QA-431 test (same CPU pinning).
  • The repro is committed as 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.
  • Gates: test:unit:resources 1674 pass / 3 fail and LMDB resources 1352 pass / 2 fail — every failure reproduced on pristine main (pre-existing: randomAccessFields ×2, replayStructures ×1). test:unit:main is unrunnable on the fleet box for any branch (startup crash opening a stale shared data dir; environmental).

For the human reviewer

  • Accepted perf trade (the one open review major): the warm-read sentinel probe costs +152 ns/op — warm getEntry measured 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.
  • Residual race (documented, not closable in harper): a warm reader passing its sentinel check just before the writer parks can be re-vouched once by the native soft-miss re-confirm; heals at the next decoding read. Same rocksdb-js follow-up closes it.
  • Durable uncacheability: a flagged key stays uncached/unvouched until its next in-order write rewrites it — a caching cliff for resequencing-heavy tables (e.g. replicated counters with clock skew) that no metric currently surfaces.
  • Design choice: compensating for "one version, two values" with unvouchability rather than advancing the version on resequenced folds — advancing would fabricate a timestamp no real update carries and break cross-node CRDT convergence.
  • Write kinds outside the reload set: publish/message writers and sourcedFrom cache-fill resolves keep their own conflict semantics and are not marked 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

kriszyp and others added 5 commits August 21, 2026 07:13
…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
@claude

claude Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Reviewed; no blockers found.

@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 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.

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