Skip to content

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

Open
kriszyp wants to merge 14 commits into
mainfrom
fix/qa431-reused-version-lost-counts
Open

Fix multi-worker lost counter increments and stale reads when a resequenced write reuses a version#2259
kriszyp wants to merge 14 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. 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

Harper already writes [8-byte BE version][4-byte BE metadata word] at the front of every primary record, and that metadata word is the header word rocksdb-js's VerificationTable reads at value offset 8 — its ACTION_32_BIT tag byte (14) is exactly the VERSION_HEADER_TAG the native predicate requires. So the whole cross-worker half of the fix is one durable bit:

  • VERSION_REUSED is rocksdb-js's constants.VERSION_NOT_UNIQUE_FLAG, set in recordUpdater whenever a RocksDB record write does not advance past the version it replaces. Every record write funnels through that one place. rocksdb-js 2.8.0 then never answers FRESH for such a value and never publishes it to a slot, on every read path — sync, async, and both transactional ones (HarperFast/rocksdb-js#766).
  • The JS record cache refuses to hold a flagged record and drops any copy it already had.
  • 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), and a cold read for a kind that keeps its own conflict semantics (publish/message/sourcedFrom resolve, and a replay's cold read) still takes the vouch fast path. Both previousResidency and created-time retention are 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.

Because the bit lands in a persisted record, a module-load guard fails fast if a future rocksdb-js moves VERSION_NOT_UNIQUE_FLAG onto one of Harper's own metadata flags, into the tag byte, or removes it — a silently-unset flag is a silent return of the data-loss bug; the message names the minimum rocksdb-js version it requires (>= 2.8.0).

This replaces the earlier JS-only compensation (a VERSION_UNVOUCHABLE sentinel parked in the VT slot by the writer, plus a verifyVersion probe on every warm read). That machinery — the writer-side park with its backoff/verify/resolve ladder, the reader-side sentinel checks, and the residual re-vouch race it could not close — is entirely gone.

Consolidated with #2065

main independently landed an overlapping fix for the same bug (PR #2065, merged 2026-08-29, same day this PR was opened): it set the identical VERSION_NOT_UNIQUE_FLAG bit, with the identical guard predicate, in the same recordUpdater() function, for its own getFromSource source-fill write path. This branch's generic in-recordUpdater fix already covers that call site (every write, including source-fill, funnels through the one patched function), so the rebase consolidated onto this PR's implementation as canonical — broader (every write, not just source-fill), sourced from rocksdb-js's own constants export instead of a hardcoded local value, guarded against the constant drifting out of Harper's reserved bit range, and kept out of assignMetadata/the audit extendedType bits that assignMetadata also feeds. #2065's redundant inline block and its two duplicate-intent tests were folded into this PR's VERSION_REUSED mechanism rather than dropped; #2065's disjoint Table.ts source-version capping/tie-breaking changes are unrelated to the flag and carried through untouched.

Verification

  • Red on unpatched main: amplified QA-431(5)-shaped repro (same fixture, in-burst GET pressure, taskset to 4 CPUs) lost increments in 3–15 windows per 900, with a per-vouch audit proving the losses durable. The bit is load-bearing on 2.8.0: with metadataInNextEncoding |= VERSION_REUSED disabled and everything else in place, unitTests/resources/caching-rocks-database.test.js fails at VT must not vouch for a version shared by two stored values — the native layer vouches the reused version. Both new unit assertions (flag set, refusal to vouch) fail without it, and a client-level read proves a caller sees the merged value rather than a stale vouch.
  • Green with the fix: ttl-rate-limiter-convergence.test.ts — 100/100 windows exact, zero converged-late, zero stuck-short, zero over-count, run four separate times across this rebase (twice pre- and twice post- the round-12/13 fixes below); the unmodified ttl-rate-limiter-concurrent.test.ts 5/5 including QA-431(5).
  • Gates on this rebase (onto main@5cd0e2c12, origin/main unmoved since): npm run build clean; npm run test:unit:resources — 2020 passing / 0 failing / 29 pending (2 timeout-based failures in an unrelated branchDatabase.test.js suite were box-load contention from other agents' concurrent runs on this shared machine, not this change — confirmed by an isolated re-run: 70/70 passing); unitTests/resources/caching-rocks-database.test.js + caching.test.js 47/47 passing.

For the human reviewer

  • The one open major, and it is yours to call: a reloadCommitBase write still pays a second point read + decode of its base at commit time, on both storage engines — LMDB pays it despite having no VerificationTable defect to dodge. Now that the native layer refuses to vouch for a reused version, independent review (codex, twice across rounds) argues the reload is redundant belt-and-braces — the cheaper shape proposed is to make the existing resource-phase pre-read snapshot-direct and reuse it, one read instead of two. I kept the reload because the pre-read's base can also arrive from this.#entry, which may have been populated by an application get() well outside the committing transaction, and narrowing that correctly touches three write paths; reversing it later is one predicate change plus a re-run of the convergence suite to prove the flag alone holds.
  • CI is currently red, but the evidence points at npm-registry conditions today, not this diff: Unit Test (Node.js v22) hit its 10-minute job timeout twice (push and a manual re-run), stalled inside install_node_modules's real npm install subprocess tests. More tellingly, all three Next.js adapter integration jobs (Node 20/22/24) independently hit their 30-minute timeout stalled on "Install fixture dependencies" — a plain dependency install in a downstream fixture repo that never reaches Harper's code at all, so it cannot be this diff's write-path cost. That's the same failure shape (an npm install step stalling) recurring across unrelated jobs and repos in the same CI run, which reads as today's npm registry/network conditions rather than a regression. I did not find a clean way to fully rule out a smaller, real contribution from the reload's added write-path cost — the completed unit-test jobs on this head (8m53s for v24, 9m53s for v26) ran somewhat slower than the same job on the same-day main baseline (8m8s) — but given the much larger, code-independent stalls alongside it, I'd re-run CI once it's confirmed to have settled down before reading anything into that difference. Recommend re-running the two failing checks before merge rather than merging on this state.
  • New, from this rebase's independent review — a real gap, not fixed here: the reload's safety only holds when the committing transaction holds a snapshot for RocksDB's optimistic conflict validation to check the later Put against (verified against rocksdb-js's binding: SetSnapshot() is gated on !disableSnapshot on every Get/Put/Delete path). Harper sets this.snapshotFree = true after a mid-scope-commit rotation (a scope that commits more than once), and a snapshot-free transaction gets no such validation — the reload narrows the lost-update window to the read-to-put span instead of closing it (see the comment at DatabaseTransaction.ts around the reload). Closing it needs either a rocksdb-js API to force a snapshot on one read of an otherwise snapshot-free transaction, or having Harper skip the snapshot-free rotation for a scope that will do a reloadCommitBase write — both bigger than this PR. The primary defense (the persisted VERSION_REUSED flag + native VT refusal) does not depend on this reload and is unaffected.
  • Design choice: compensating for "one version, two values" with a durable non-uniqueness bit rather than advancing the version on resequenced folds — advancing would fabricate a timestamp no real update carries and break cross-node CRDT convergence.
  • 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. The predicate flags ties (newVersion <= existing) too, so same-timestamp deletes and relocates that deliberately hold their version are flagged as well.
  • Load-time throw: the flag-range guard crashes startup rather than degrading if rocksdb-js ever ships without the constant. That is deliberate — the failure it prevents is silent data loss — but it is a hard dependency on 2.8.0+.
  • Test-oracle tolerances (declined, repeatedly flagged in review): the convergence oracle counts acked === 0 windows as inconclusive with a 50% floor, admits convergence within a 400 ms poll, and treats hits <= acked + errs as exact-or-explained. Each absorbs a real failure it could otherwise catch. It is deliberately tolerant so the test is not flaky on loaded CI runners; a deterministic cross-worker regression test (including a tombstone that reuses a version, which nothing covers today) would be strictly better and is not in this PR.
  • validate() runs once: created-time retention now reads the reloaded base, but validate() is not re-run on a conflict retry, so a retry that reloads a newly created base still keeps the first round's decision. Pre-existing structure, not introduced by this PR; re-running validate() on retry would also re-run schema validation and audit dedup, which is its own change.
  • Write kinds outside the reload set: publish/message writers and sourcedFrom cache-fill resolves keep their own conflict semantics and are not marked reloadCommitBase — worth a follow-up look rather than widening this PR.
  • Rebased onto current main, consolidating with Fix sourcedFrom cache-fill conflict convergence #2065 (see above) — main's overlapping flag logic and two duplicate-intent tests were folded into this PR's mechanism rather than kept side-by-side; main's disjoint source-version capping/tie-breaking changes in the same files carried through untouched. Independent review caught and this rebase fixed two real regressions the consolidation introduced: (1) the merge reordered metadataInNextEncoding = assignMetadata ahead of the VERSION_REUSED OR instead of after it, so the documented assignMetadata = -1 default silently dropped the metadata word — and the flag with it — for any resequenced write reaching recordUpdater through that default (latent in-repo, but live via the function's direct callers); restored the dropped Math.max(assignMetadata, 0) normalization main's replaced line had. (2) A comment overclaimed the snapshot-free conflict guarantee described above — corrected instead of left wrong. Also strengthened two tests independent review flagged as gaps: the integration test's 30s readiness wait now throws instead of silently falling through on timeout, and the reused-version unit test now asserts a client-level read (not just internal verifyVersion/metadataFlags state).

Refs #1881 (same version-reuse family, index-scan surface).

Signed: Claude Sonnet 5 (dispatch agent)

🤖 Generated with Claude Code

Review-Coverage: authored=claude; ran=codex,gemini; declined=cursor-grok,cursor-composer,domain; rounds=14 @ 222ffae

Human-Review-Need: 4 @ 222ffae

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

@kriszyp
kriszyp marked this pull request as ready for review August 28, 2026 12:10
@kriszyp
kriszyp force-pushed the fix/qa431-reused-version-lost-counts branch from d32a9fa to ec607a1 Compare August 28, 2026 13:11

@cb1kenobi cb1kenobi left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed ec607a1 and found no blocking issues. The version-reuse flag and snapshot-direct commit-base reads consistently address the stale-cache path. No confirmed blocking defects were found on changed lines.


Generated by Barber AI

@heskew heskew left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed exact head ec607a1.

No blocking defects introduced by this PR. Codex independently traced the VERSION_REUSED contract through Harper encoding and rocksdb-js VerificationTable behavior, the uncached transaction-snapshot reload of state-deriving commit bases, same-key staged-write ordering, replay and copy-apply exclusions, and the published/pinned rocksdb-js v2.8.0 dependency. The paths are internally consistent and address the stale-read/lost-increment failure without exposing a new correctness or security issue.

Prior-review attribution: @gemini-code-assist reviewed an earlier head and reported no findings; @cb1kenobi independently found no blockers at this exact head; @claude likewise reported no blockers at this exact head. I agree after the independent pass above.

Validation at this head: npm ci and npm run build passed; the focused PrimaryRocksDatabase tests passed 13/13; changed-file oxlint and Prettier checks passed; the focused QA-431 four-worker convergence test finished 100/100 clean windows with zero stuck-short, over-counted, or inconclusive windows; and GitHub CI is green. A full local resource-suite process aborted late after hundreds of passing tests, but its isolated active suite passed and all GitHub unit-test matrices passed, so I do not attribute that local runner failure to this PR.

Branch state only: the branch currently conflicts with main in resources/RecordEncoder.ts and unitTests/resources/caching-rocks-database.test.js. It must be updated before merge, but this is not a correctness finding against the reviewed head.

🤖 Posted by Codex on behalf of @heskew

kriszyp and others added 12 commits September 3, 2026 11:22
…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
…inel

The record metadata word Harper writes at value offset 8 is the same header
word the VerificationTable reads, so marking a reused version there
(VERSION_NOT_UNIQUE_FLAG) is what stops the native layer vouching for it —
removing the per-read sentinel probe and the writer-side slot parking, and
with them the warm-read regression they cost.

Co-Authored-By: Claude Opus <noreply@anthropic.com>
…lag bit

- only a base that feeds stored state (or a conflict-retry reload) gives up the
  cache vouch; publish/message/sourcedFrom cold reads keep it
- stamp created time from the reloaded base, matching the residency sibling
- fail at load if rocksdb-js moves VERSION_NOT_UNIQUE_FLAG onto a Harper flag
  or into the tag byte, since the bit is persisted in every resequenced record
- wrap the warm-read bench in describe() so mocha cannot exit mid-suite on it

Co-Authored-By: Claude Opus <noreply@anthropic.com>
…he sync base read

Co-Authored-By: Claude Opus <noreply@anthropic.com>
Co-Authored-By: Claude Opus <noreply@anthropic.com>
rocksdb-js 2.8.0 pins msgpackr exactly at 2.0.6, so Harper's 2.0.5 top-level
pin forced a second copy under node_modules/@harperfast/rocksdb-js — two
msgpackr instances in one process, each with its own structure cache and
extension registry, and a duplicate msgpackr-extract native binding.

Co-Authored-By: Claude Opus <noreply@anthropic.com>
…base bypass, name the required rocksdb-js version in the flag-drift throw

- resources/DatabaseTransaction.ts: uncachedRead ignored isReplay even though
  reloadsCommitBase (the gate above it) excludes it — a replay's cold read
  (operation.entry === undefined) took the uncached bypass anyway, skipping
  the cache-warming path recovery relies on. Now consistent with the comment
  above it.
- resources/RecordEncoder.ts: the VERSION_NOT_UNIQUE_FLAG shape-mismatch
  throw named the bad value but not the fix; a build resolving rocksdb-js
  <2.8.0 now gets a message that says what's required.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ix a test-id collision, retarget caching.test.js's VERSION_NOT_UNIQUE_FLAG import

The rebase onto main folded PR #2065's redundant VERSION_NOT_UNIQUE_FLAG/inline
recordUpdater block into this branch's generic VERSION_REUSED mechanism (both already
resolved to the same rocksdb-js constant), but three spots needed a manual follow-up the
per-commit auto-merge couldn't catch:

- unitTests/resources/caching-rocks-database.test.js ended up requiring VERSION_REUSED
  from RecordEncoder.ts twice (once from this branch's own history, once reintroduced
  when a later commit's independent edit auto-merged around it).
- The new expiresAt-preservation test (adapted from #2065's now-dropped duplicate) reused
  id 10, which collides with the pre-existing "Third read hits VT fast path" test.
- unitTests/resources/caching.test.js (#2065's own, untouched by this branch) still
  imported and asserted on VERSION_NOT_UNIQUE_FLAG, an export this consolidation removed
  from RecordEncoder.ts — the import would have resolved to undefined, silently turning
  `metadataFlags & VERSION_NOT_UNIQUE_FLAG` into `metadataFlags & undefined` (always 0),
  weakening rather than failing the two assertions that depend on it.

Also updates DESIGN.md's getFromSource() prose to name the surviving VERSION_REUSED
export instead of the dropped alias, and to state explicitly that the flag applies
generically to every reused-version RocksDB write, not just this source-fill path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
kriszyp and others added 2 commits September 3, 2026 15:59
…malization, correct the snapshot-free conflict-guarantee overclaim

RecordEncoder.ts: the consolidation moved `metadataInNextEncoding = assignMetadata` ahead of the
VERSION_REUSED OR instead of after it, as main's replaced line had it. With the documented
`assignMetadata = -1` ("no metadata word") default, `-1 | VERSION_REUSED` stays -1, silently
dropping the metadata word (and the flag with it) for a resequenced write that reaches
recordUpdater() through that default — latent in-repo (every Table.ts caller passes 0) but live
via the exported function's direct callers (unitTests/apiTests/computedDurableEncoding-test.mjs).
Restores the dropped Math.max(assignMetadata, 0) normalization, scoped to the flag assignment.

DatabaseTransaction.ts: the reload's guiding comment claimed a write landing between the reload and
commit "still surfaces as a conflict and retries" unconditionally. Verified against rocksdb-js's
binding (transaction_handle.cpp gates every SetSnapshot() call on !disableSnapshot) that this does
not hold for a snapshot-free transaction (this.snapshotFree, set after a mid-scope-commit
rotation) — there is no snapshot for RocksDB's optimistic conflict validation to check the Put
against. Corrected the comment instead of overclaiming; the persisted VERSION_REUSED flag and
native VerificationTable refusal (the primary defense) do not depend on this reload. Also trimmed
the two comment blocks that only narrated adjacent code, per the same round's nit finding.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PxP3CYDYdCZZQmR5MmTR4E
…on readiness-loop timeout, assert a client-level read sees the merged value

- DatabaseTransaction.ts: drop the "see PR discussion" pointer — codex correctly flagged it as a
  reference that doesn't survive outside this review round; state the snapshot-free gap directly
  instead.
- ttl-rate-limiter-convergence.test.ts: the readiness loop fell through silently if the fixture
  never left 503 within its 30s deadline, so a broken fixture would run the real tests against a
  dead server and surface as confusing fetch errors instead of a clear setup failure (Gemini).
- caching-rocks-database.test.js: "VT does not vouch..." asserted only internal native-layer state
  (verifyVersion, metadataFlags); added a client-level TestTable.get(9) to prove a caller actually
  sees the merged value rather than a stale cache vouch, which is the user-visible symptom this PR
  fixes (Gemini).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PxP3CYDYdCZZQmR5MmTR4E
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.

3 participants