Skip to content

fix(replication): use getSync for seq/copyCursor resume-cursor reads (RocksDB MaybePromise) - #484

Merged
kriszyp merged 1 commit into
mainfrom
kris/seq-cursor-getsync
Jun 25, 2026
Merged

kriszyp merged 1 commit into
mainfrom
kris/seq-cursor-getsync

Conversation

@kriszyp

@kriszyp kriszyp commented Jun 25, 2026 •

Copy link
Copy Markdown
Member

Summary

Completes the RocksDB MaybePromise getSync audit (#476/#474/#470, which swept only the hdb_nodes point reads) for the replication subscription handshake's per-source resume-cursor reads.

__dbis__ is a RocksDB store, so get() is a MaybePromise — it returns the value synchronously on a block-cache hit, but a Promise on a cache miss. The handshake (replicateOverWS) read the per-source seq/copyCursor cursors with a non-awaited get(). Right after a restart/upgrade — exactly when the handshake runs — the cursor key is cold, so get() returns a Promise:

This scales with system-DB size: the larger the system DB, the more block-cache pressure, the likelier the seq key is evicted — so large customer databases full-copy on a routine upgrade while small ones resume incrementally (the observed field signature on the 5.1.12 wave).

Changes

  • replication/replicationConnection.ts: new readDbisCursorSync(dbisDB, kind, id) helper (getSync); the seq (direct + proxied) and copyCursor handshake reads route through it. Mirrors the writer at core/resources/Table.ts updateRecordedSequenceId and nodeIdMapping.ts, which already use getSync with the same rationale.
  • replication/replicator.ts: await the residency_by_id get() in the async load() path. A Promise there would make residency truthy and the following for...of throw; load() is already async, so await is the right fix rather than blocking with getSync.
  • New unit test unitTests/replication/readDbisCursorSync.test.mjs reproducing the cold-cache (get() → Promise) regression for both seq and copyCursor, mirroring readNodeRowSync.test.mjs.

Where to look

  • Behavior is identical on a cache hit (both get() and getSync() return the value synchronously); the only change is that a cold miss now blocks for the real value instead of yielding a Promise that silently reads as "no cursor."
  • The one judgment call: replicator.ts load() uses await (yields) rather than getSync (blocks), since it's a hotter retrieval path and already async — worth a sanity check that the short-circuit (entry.residency || (await …)) preserves the prior semantics.

Related

Follow-up (not in this PR)

Same durable-prevention treatment as #477: a typed Store<V> for the __dbis__/seq store whose sync .get() is the typed surface, so MaybePromise misuse on these reads becomes a compile error rather than a silent full-copy trigger.


🤖 Generated by Claude (Opus 4.8). Reviewed by Codex (clean — no correctness issues) and the GitHub claude review bot (no blockers). The Gemini leg was skipped — Gemini's daily quota is exhausted (the gemini-code-assist bot posted a quota warning); proceeding on the Codex review since the change mirrors the already-reviewed #476/#477 pattern.

…(RocksDB MaybePromise)

Completes the RocksDB MaybePromise getSync audit (#476/#474/#470, which swept
only hdb_nodes) for the replication subscription handshake's resume-cursor reads.

__dbis__ is RocksDB, whose get() returns a Promise on a block-cache miss and the
value synchronously on a hit. The handshake read the per-source seq/copyCursor
with an un-awaited get(); right after a restart/upgrade the cursor key is cold, so
get() returns a Promise. For a seq row Promise?.seqId is undefined, startTime
collapses to 1, and the "no resume cursor for this source" branch forces an
unnecessary FULL COPY of a node that holds a valid persisted cursor. The larger the
system DB, the more block-cache pressure and the likelier the eviction — so large
customer databases full-copy on a routine upgrade while small ones resume
incrementally (the field signature).

- replicationConnection.ts: route the seq (direct + proxied) and copyCursor point
  reads through a new readDbisCursorSync helper (getSync), mirroring the writer at
  core Table.ts updateRecordedSequenceId / nodeIdMapping.ts.
- replicator.ts: await the residency_by_id get() in the async load() path (a miss
  there made residency a Promise that the following for...of would throw on).
- unit test covering the cold-cache (get()->Promise) regression for seq + copyCursor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@gemini-code-assist

Copy link
Copy Markdown

Warning

You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again!

@claude

claude Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Reviewed; no blockers found.

@kriszyp
kriszyp marked this pull request as ready for review June 25, 2026 02:37
@kriszyp
kriszyp requested a review from a team as a code owner June 25, 2026 02:38
@gemini-code-assist

Copy link
Copy Markdown

Warning

You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again!

@kriszyp
kriszyp merged commit 082c5da into main Jun 25, 2026
30 checks passed
@kriszyp
kriszyp deleted the kris/seq-cursor-getsync branch June 25, 2026 11:49
kriszyp added a commit that referenced this pull request Jun 25, 2026
…edCopyCursor (#485)

Tightens discardMalformedCopyCursor's first param from `any` to
`DbisCursor | undefined` (and `dbisDB` to `DbisStore | undefined`), so the
#485 compile-time invariant reaches the copyCursor path too: passing a raw
dbisDB.get() result (a MaybePromise on a RocksDB block-cache miss) is now a
tsc error at the call site instead of a Promise that reads as malformed and
gets remove()d from disk — a spurious full-recopy on reconnect (#321).

Type-only: the production read already uses getSync via readDbisCursorSync
(#484), so compiled JS is byte-identical; this closes the type gap David
flagged so the whole __dbis__ read path is compile-safe, not just `seq`.

tsc error multiset unchanged from baseline; enforcement verified (a raw
get() at the call site now yields TS2345 MaybePromise-not-assignable).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
kriszyp added a commit that referenced this pull request Jun 25, 2026
…se is a compile error (#485)

Follow-up to #484 (the seq/copyCursor getSync fix): makes the whole MaybePromise
misuse class a compile-time error rather than a latent full-copy-on-upgrade bug,
mirroring what #477 did for the hdb_nodes store.

On RocksDB `__dbis__.get()` is a MaybePromise — the value synchronously on a
block-cache hit, a Promise on a miss. A new `DbisStore` type declares
`get(): MaybePromise<DbisCursor | undefined>` and `getSync(): DbisCursor | undefined`,
with an index signature so put/remove/getRange/etc. fall through (no broad retype /
blast radius). `tableSubscriptionToReplicator.dbisDB` and the `readDbisCursorSync`
helper are typed against it, so any synchronous consumption of `get()` —
`store.get(k)?.seqId`, truthiness, spread — is now a `tsc` error: use `getSync()`
or `await`.

- This makes the #484 fix an invariant: reverting `readDbisCursorSync`'s `getSync`
  back to `get()` no longer satisfies the `DbisCursor | undefined` return type
  (TS2322), and a direct `tableSubscriptionToReplicator.dbisDB.get(k)?.seqId`
  read errors (seqId does not exist on MaybePromise). Both verified locally.
- Removed a now-redundant `?? 1` on the seq read (every consumer is already
  `sequenceEntry?.seqId ?? 1/0`); behavior-identical.

Pure types + one redundancy removal; no runtime behavior change. tsc adds zero
errors vs baseline; unit suite green (194).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
kriszyp added a commit that referenced this pull request Jun 25, 2026
…edCopyCursor (#485)

Tightens discardMalformedCopyCursor's first param from `any` to
`DbisCursor | undefined` (and `dbisDB` to `DbisStore | undefined`), so the
#485 compile-time invariant reaches the copyCursor path too: passing a raw
dbisDB.get() result (a MaybePromise on a RocksDB block-cache miss) is now a
tsc error at the call site instead of a Promise that reads as malformed and
gets remove()d from disk — a spurious full-recopy on reconnect (#321).

Type-only: the production read already uses getSync via readDbisCursorSync
(#484), so compiled JS is byte-identical; this closes the type gap David
flagged so the whole __dbis__ read path is compile-safe, not just `seq`.

tsc error multiset unchanged from baseline; enforcement verified (a raw
get() at the call site now yields TS2345 MaybePromise-not-assignable).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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