Skip to content

fix(replication): synchronous getSync for hdb_nodes point lookups (#470) - #474

Closed
kriszyp wants to merge 5 commits into
mainfrom
kris/self-replicate-gate
Closed

kriszyp wants to merge 5 commits into
mainfrom
kris/self-replicate-gate

Conversation

@kriszyp

@kriszyp kriszyp commented Jun 24, 2026 •

Copy link
Copy Markdown
Member

Fixes #470

Root cause (corrected — supersedes this PR's first revision)

The system database is RocksDB, whose get() returns a MaybePromise: synchronous when the
row is in the block cache / memtable, a Promise on a cache-miss disk read. The replication
point-lookups in knownNodes.ts assumed a synchronous (LMDB-style) get() and read .replicates /
.name off the result directly. Once system grows past what stays in block cache, the hdb_nodes rows
are evicted, get() returns a Promise, and (promise)?.replicates is undefined — silently disabling
shouldReplicateFromNode's self-gate (the isDesired predicate for findWedgedNodeUrls/reconcileWorkers
and onDatabase re-subscribe), resolveNodeForAuth, and the change-stream fullRecord reads. That
breaks replication recovery: a still-desired peer is excluded from all re-drive/re-subscribe with no
signal (the observed preprod wedge — connected:false, retries:0). Confirmed live via CDP:
ps.get(name) → Promise, await ps.get(name) / ps.getSync(name) → the full pristine record.

Change

Use getSync() — the synchronous point read already used throughout core/resources/Table.ts and
search.ts — at the four affected sites:

  • selfNodeReplicates → store.getSync(name)?.replicates (a one-line testable seam; the scan-fallback
    helper from the first revision is removed).
  • resolveNodeForAuth → synchronous getSync point read (the range-visible { name } reconstruction
    remains only as defense-in-depth and should now be rare).
  • the two change-stream fullRecord reads (get() → getSync()).

A deleted/absent row yields null/undefined (?. → falsy, correct); a genuine replicates: false
is preserved.

Tests

unitTests/replication/selfNodeReplicates.test.mjs rewritten to model the real RocksDB MaybePromise
contract: a store stub whose get() returns a Promise on a block-cache miss but getSync() returns the
value. The bug-closing case asserts that the un-awaited get(...)?.replicates is undefined (the old
hazard) while selfNodeReplicates returns the correct value via getSync; plus cache-hit, replicates
object, genuine false preserved, absent → undefined, and clean-null tombstone → undefined (no revive).
readNodeForAuth.test.mjs stub updated to expose getSync. Full replication unit suite: 185 passing,
0 failing
. Built via tsc (only pre-existing _socket/etc. errors in replicationConnection.ts,
none in changed files).

Notes

🤖 Generated with Claude Code

kriszyp and others added 3 commits June 23, 2026 23:43
…eFromNode (#470)

shouldReplicateFromNode's primary predicate ended in
getHDBNodeTable().primaryStore.get(getThisNodeName())?.replicates — a point-lookup of this
node's own hdb_nodes self-record. On a repeatedly-upgraded node that point-lookup can return an
empty/undecodable value (the harper-pro#352 shared-structure misread), making ?.replicates
undefined and the whole predicate falsy. Because this predicate is the isDesired gate for BOTH
the wedge backstop (findWedgedNodeUrls/reconcileWorkers) and the onDatabase re-subscribe path, a
still-desired peer was silently excluded from all recovery (live 4-node preprod wedge:
connected:false for hours, retries:0).

New selfNodeReplicates(store, name) mirrors the #461/#460 decode-resilient pattern: prefer the
point-lookup replicates, fall back to the RANGE/scan-decoded self record, and only default to
true when the self key is range-visible but nothing decodes (add_node always writes self
replicates:true per setNode.ts). A genuine, decodable replicates:false and a genuinely-absent
self-record are both preserved.

harper#1463 (make the point-lookup decode like the scan path) is the durable root fix; this is
the harper-pro-side guard. Unblocks harper-pro#467's reconnect-recovery backstop, which is gated
behind this predicate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… in selfNodeReplicates

Codex cross-model review: the range-visibility default could revive a removed node. A genuine
self-node deletion leaves a CLEAN null tombstone that stays range-visible, so the prior
`storeRecordRangeVisible ? true` default would re-enable replication for an intentionally-removed
node. Use the existing probeNodeRow tombstone-vs-decode-failure distinction (clean null = deleted,
do not revive; throw/present-invalid = #352 decode failure, recover via scan then default true).
Adds a tombstone-guard unit test (red without this change).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…aware resolution order

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@kriszyp
kriszyp requested review from kylebernhardy and ldt1996 June 24, 2026 06:03
@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 24, 2026

Copy link
Copy Markdown
Contributor

Reviewed; no blockers found.

The system database is RocksDB, whose get() returns a MaybePromise: the value
synchronously when the row is in the block cache/memtable, but a Promise on a
cache-miss disk read. The replication point-lookups in knownNodes.ts assumed a
synchronous (LMDB-style) get and read .replicates/.name off the result directly.

Once `system` grows past what stays in block cache the hdb_nodes rows are evicted,
get() returns a Promise, and (promise)?.replicates is undefined - silently disabling
the self-gate (shouldReplicateFromNode), the auth lookup (resolveNodeForAuth) and the
change-stream fullRecord reads. That breaks replication recovery: a still-desired peer
is excluded from findWedgedNodeUrls/reconcileWorkers and onDatabase re-subscribe with
no re-drive (the observed preprod wedge: connected:false for hours, retries:0).

Use getSync() - the synchronous point read already used throughout Table.ts/search.ts -
at the affected sites. This replaces the earlier scan-fallback workaround, which only
appeared to help because getRange() is synchronous and routed around the async get().

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@kriszyp kriszyp changed the title fix(replication): decode-resilient self-record gate in shouldReplicateFromNode (#470) fix(replication): synchronous getSync for hdb_nodes point lookups (#470) Jun 24, 2026
… probe (#470)

Codex review of the first revision caught two more unawaited point reads of the same
MaybePromise hazard:
- probeNodeRow: a RocksDB cache-miss tombstone is Promise<null>, so `record == null` was
  false and a removed node would be misclassified as a decode-failure and revived. getSync
  forces the synchronous read so a tombstone is a clean null.
- storeRecordRangeVisible fallback: `get(name) != null` would be truthy for Promise<...>
  on a cache miss, making an absent key look range-visible. Use getSync there too.

Test stubs updated to model the getSync contract.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@kriszyp

kriszyp commented Jun 24, 2026

Copy link
Copy Markdown
Member Author

Cross-model review (Codex) — dispositions

Codex raised three findings against the first revision; two were real and are now fixed, one is declined with reasoning.

  • ✅ Fixed (High): probeNodeRow still used store.get(key). On a RocksDB cache miss a tombstone is Promise<null>, so record == null was false → misclassified as decode-failure → a removed node could be reconstructed/revived. Now getSync.
  • ✅ Fixed (Low): storeRecordRangeVisible's fallback store.get(name) != null would be truthy for a Promise<...> on a cache miss, making an absent key look range-visible. Now getSync.
  • ⏸️ Declined (the self-record fallback removal): Codex flagged that dropping the scan/default fallback in selfNodeReplicates is unsafe "unless core guarantees point getSync decodes every row the scan path can." It does, by construction: getSync and getRange use the same decoder instance (decoder === encoder, CDP-verified) and the same shared-structures array — so getSync decodes any row the scan path decodes. The "range decodes / point doesn't" symptom was never a decode failure; it was the async Promise (await get() and getSync() both return the full, valid record; structures present and correct). Removing the fallback returns selfNodeReplicates to its original pre-fix(replication): synchronous getSync for hdb_nodes point lookups (#470) #474 semantics (get(self)?.replicates), which is not a regression — the scan-fallback was added in this PR's misdiagnosed first revision. If a row were ever genuinely undecodable (real corruption), getRange would fail identically, so a getRange-based fallback couldn't help anyway.

Full replication unit suite: 185 passing, 0 failing after both fixes.

@kriszyp
kriszyp marked this pull request as ready for review June 24, 2026 12:03
@kriszyp
kriszyp requested a review from a team as a code owner June 24, 2026 12:03
@kriszyp
kriszyp requested review from cb1kenobi and removed request for a team and kylebernhardy June 24, 2026 12:04
kriszyp added a commit that referenced this pull request Jun 24, 2026
…x P1)

shouldReplicateFromNode is the load-bearing predicate for BOTH shouldSubscribe
(onDatabase) and the wedge-reconcile isDesired check. Its final self-record term
still used an un-awaited primaryStore.get(getThisNodeName())?.replicates, so a
RocksDB block-cache miss (cold restart / grown hdb_nodes) made the whole predicate
falsy and silently unsubscribed a still-desired peer — upstream of the readNodeRowSync
guard added earlier in this branch. Switch it to getSync. PR #474 refactors this same
expression to selfNodeReplicates, which must likewise read sync.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
kriszyp added a commit that referenced this pull request Jun 24, 2026
…s PR

Supersedes #474. Adopts its comprehensive knownNodes.ts get()->getSync fix verbatim,
including readNodeForAuth / storeRecordRangeVisible / probeNodeRow — sites this branch's
original scan had under-classified as safe '#352 defensive readers' (a Promise from get()
does not throw and is truthy, so it slips through their try/catch + existence checks and
strands the peer / makes an absent key look present / revives a tombstone). Brings #474's
selfNodeReplicates helper + its unit test and the updated readNodeForAuth and
scanNodesForSubscription tests. shouldReplicateFromNode now reads via selfNodeReplicates
(getSync), replacing this branch's earlier inline getSync there.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@kriszyp

kriszyp commented Jun 24, 2026

Copy link
Copy Markdown
Member Author

Superseded by #476, which folds this PR's knownNodes.ts getSync sweep + selfNodeReplicates helper + tests in verbatim and extends the same fix across the rest of replication (subscriptionManager, replicator, clusterStatus) plus the core submodule (HarperFast/harper#1473).

Nice call converging on the MaybePromise root cause — that reframing (the #352 "empty object" being a pending Promise) turned out to be the crux, and #476 carries it everywhere. Closing in favor of #476.

— KrAIs (Claude Opus 4.8)

@kriszyp kriszyp closed this Jun 24, 2026
kriszyp added a commit that referenced this pull request Jun 24, 2026
…ePromise)

On RocksDB store.get() returns a Promise on a block-cache miss; the self-node
replication gate, ensureThisNode/ensureNode, the retrieval-connection lookup,
cluster_status, and the node-update watcher consumed that result synchronously,
so once hdb_nodes grew past the block cache (or right after a cold-cache restart)
a Promise self-row silently disabled replication, dropped peers from
cluster_status, or skipped opening a connection. Switch those reads to getSync
(extracted readNodeRowSync helper for the self-gate). Leaves shouldReplicateFromNode
to PR #474. Adds a deterministic unit test (readNodeRowSync) and a cluster
integration test (small block cache + cold restart + remove/add node). Bumps the
core submodule to the paired system-table getSync fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
kriszyp added a commit that referenced this pull request Jun 24, 2026
…x P1)

shouldReplicateFromNode is the load-bearing predicate for BOTH shouldSubscribe
(onDatabase) and the wedge-reconcile isDesired check. Its final self-record term
still used an un-awaited primaryStore.get(getThisNodeName())?.replicates, so a
RocksDB block-cache miss (cold restart / grown hdb_nodes) made the whole predicate
falsy and silently unsubscribed a still-desired peer — upstream of the readNodeRowSync
guard added earlier in this branch. Switch it to getSync. PR #474 refactors this same
expression to selfNodeReplicates, which must likewise read sync.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
kriszyp added a commit that referenced this pull request Jun 24, 2026
…s PR

Supersedes #474. Adopts its comprehensive knownNodes.ts get()->getSync fix verbatim,
including readNodeForAuth / storeRecordRangeVisible / probeNodeRow — sites this branch's
original scan had under-classified as safe '#352 defensive readers' (a Promise from get()
does not throw and is truthy, so it slips through their try/catch + existence checks and
strands the peer / makes an absent key look present / revives a tombstone). Brings #474's
selfNodeReplicates helper + its unit test and the updated readNodeForAuth and
scanNodesForSubscription tests. shouldReplicateFromNode now reads via selfNodeReplicates
(getSync), replacing this branch's earlier inline getSync there.

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

* fix(replication): use getSync for hdb_nodes point reads (RocksDB MaybePromise)

On RocksDB store.get() returns a Promise on a block-cache miss; the self-node
replication gate, ensureThisNode/ensureNode, the retrieval-connection lookup,
cluster_status, and the node-update watcher consumed that result synchronously,
so once hdb_nodes grew past the block cache (or right after a cold-cache restart)
a Promise self-row silently disabled replication, dropped peers from
cluster_status, or skipped opening a connection. Switch those reads to getSync
(extracted readNodeRowSync helper for the self-gate). Leaves shouldReplicateFromNode
to PR #474. Adds a deterministic unit test (readNodeRowSync) and a cluster
integration test (small block cache + cold restart + remove/add node). Bumps the
core submodule to the paired system-table getSync fix.

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

* fix(replication): read shouldReplicateFromNode self-record sync (Codex P1)

shouldReplicateFromNode is the load-bearing predicate for BOTH shouldSubscribe
(onDatabase) and the wedge-reconcile isDesired check. Its final self-record term
still used an un-awaited primaryStore.get(getThisNodeName())?.replicates, so a
RocksDB block-cache miss (cold restart / grown hdb_nodes) made the whole predicate
falsy and silently unsubscribed a still-desired peer — upstream of the readNodeRowSync
guard added earlier in this branch. Switch it to getSync. PR #474 refactors this same
expression to selfNodeReplicates, which must likewise read sync.

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

* fold #474 (knownNodes.ts getSync sweep + selfNodeReplicates) into this PR

Supersedes #474. Adopts its comprehensive knownNodes.ts get()->getSync fix verbatim,
including readNodeForAuth / storeRecordRangeVisible / probeNodeRow — sites this branch's
original scan had under-classified as safe '#352 defensive readers' (a Promise from get()
does not throw and is truthy, so it slips through their try/catch + existence checks and
strands the peer / makes an absent key look present / revives a tombstone). Brings #474's
selfNodeReplicates helper + its unit test and the updated readNodeForAuth and
scanNodesForSubscription tests. shouldReplicateFromNode now reads via selfNodeReplicates
(getSync), replacing this branch's earlier inline getSync there.

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

* test(cluster): fix blockCacheEviction startup hang (viable cache, 2 nodes, sentinel convergence)

The 1MB blockCacheSize + 512KB WriteBufferManager (allowStall) was sub-viable for
opening Harper's RocksDB databases — every node hung at startup (HarperStartupError,
no output for 60s), failing the suite. Use a small-but-viable 32MB block cache and
drop the WBM override; the COLD RESTART is the deterministic out-of-cache trigger
(the field repro). Also trimmed to 2 nodes and switched convergence checks to sentinel
records (no fragile full-count/pagination) for speed and reliability.

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

* test(cluster): harden remove_node/add_node step against post-reload ECONNREFUSED

remove_node reloads the node, so its operations API briefly refuses connections;
the immediate add_node hit ECONNREFUSED (TypeError: fetch failed). Wait for the node
to be reachable (pollHealth) and retry the re-add. (Cold-restart test 1 already passes.)

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

* test(cluster): drop orthogonal remove_node/add_node case from blockCacheEviction

Test 2 failed on convergence after removing a node's leader and re-adding: the node
ends up with a null self-record and (correctly) disables replication, not re-converging
in-window. The log shows selfNodeRow=null (a concrete value, NOT a Promise) — so this is
a remove_node re-subscription behavior orthogonal to the get() MaybePromise fix this suite
guards. The cold-restart test already exercises ensureThisNode/shouldReplicateFromNode/
cluster_status on a cold cache; add_node is covered by the before hook and
replicationReconnect/replicationTopology tests.

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

* Update integrationTests/cluster/blockCacheEviction.test.mjs

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>

* feat(types): type hdb_nodes store so sync get() misuse is a compile error

Adds a NodeStore type whose get() returns MaybePromise<NodeRecord> (the honest RocksDB
contract — a Promise on a block-cache miss) and types getHDBNodeTable()'s return, so any
SYNCHRONOUS consumption of get() (e.g. store.get(id)?.replicates) is now a TypeScript
error — use getSync() or await/when. The value type reuses the canonical Node type plus
an index signature for incidental fields (e.g. authorization).

tsc is clean against this branch with the typing applied — proving the get->getSync fixes
already on this branch leave ZERO remaining sync misuse on hdb_nodes — and a deliberate
store.get(x)?.shard is correctly rejected. This is the compiler-driven backstop for the
store.get() MaybePromise class (would have caught the readNodeForAuth/probeNodeRow misses
the manual audit under-classified). Pairs with the rocksdb-js DBI<T,V> value-generic.

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

* Update integrationTests/cluster/blockCacheEviction.test.mjs

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.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.

replication: hdb_nodes point lookups assume sync get() but system is RocksDB (MaybePromise) — self-gate/auth read a Promise, disabling recovery

1 participant