Skip to content

fix(replication): decode-resilient outbound subscriptions + idempotent node-update watcher on deploy reload (#460) - #461

Merged
kriszyp merged 4 commits into
mainfrom
kris/deploy-resubscribe
Jun 23, 2026
Merged

kriszyp merged 4 commits into
mainfrom
kris/deploy-resubscribe

Conversation

@kriszyp

@kriszyp kriszyp commented Jun 23, 2026 •

Copy link
Copy Markdown
Member

Fixes #460

Summary

This PR closes two distinct failure modes that a deploy_component reload triggers on a follower. A deploy reloads the worker pool, which re-invokes replicator.startOnMainThread() on the same already-resolved main-thread module instance. Because whenThreadsStarted is already resolved, its .then() re-fires immediately — with no base-copy (unlike a full process restart, where hdb_nodes is base-copied first).

1. Undecodable peers dropped → zero outbound subscriptions (the data-loss symptom). The post-reload hdb_nodes subscription scan runs while a follower's replicated rows still fail to decode (a v5-era msgpackr shared-structure row misreading — the harper-pro#352 / harper#1163 family). The scan did if (!node) continue, silently skipping every undecodable peer, so the follower built zero outbound replication subscriptions (database_sockets: []) and silently stopped receiving all replicated writes — cache clears, data updates — until a full process restart. The #352 { name } fallback in resolveNodeForAuth is auth-only/inbound and is never reached by this outbound path.

2. Watcher / listener accumulation across deploys (the leak). That same re-fired whenThreadsStarted.then() re-ran subscribeToNodeUpdates → runNodeUpdateWatcher without tearing down the prior one, stacking a second unbounded watcher loop (and double-processing every hdb_nodes event), plus re-pushing routes, on every deploy. This is the source of the MaxListenersExceededWarning seen during the incident.

Fix

Decode-resilient subscription building (#1). Mirror the #352 auth-path fallback on the outbound path: when an hdb_nodes key is range-visible but its scan value is nullish, reconstruct a minimal descriptor from the key and still drive the subscription listener. Applied to both the startup/reload scan (subscribeToNodeUpdates) and the put-event path (processNodeUpdateEvent). The scan loop was extracted into pure, store-injectable helpers (scanNodesForSubscription / resolveScannedNode / reconstructNodeFromKey) for unit-testability.

Crucially, the scan does not reconstruct a genuine remove_node tombstone — a deleted node leaves a row whose POINT lookup returns null cleanly, whereas a decode failure throws from the point lookup. probeNodeRow disambiguates the two for any nullish scan value (clean null ⇒ deleted ⇒ skip; throw or present value ⇒ decode failure ⇒ reconstruct), so a removed peer is never revived/resubscribed. (The put-event path doesn't need this — genuine deletes arrive as delete events routed through isGenuineNodeDeletion, not as null-valued puts.)

Key detail: the reconstructed descriptor is { name, replicates: true }, not the auth path's { name }. The outbound path gates on node.replicates (see shouldReplicateFromNode and the early-return in subscriptionManager.onNodeUpdate); a bare { name } would be filtered out and no subscription created. replicates: true is the cluster default (what ensureThisNode and route-defaulting write), and getNodeURL already falls back to wss://<name>:9933. The next decodable hdb_nodes update replaces it with the full record.

Reload idempotency / listener-leak (#2). Make runNodeUpdateWatcher supersede its own prior instance instead of stacking one. A per-purpose generation token (watcherGenerations keyed by a string) is bumped on each (re)start; the prior loop's async iterator is closed via .return() and the older loop exits on its next turn. The key namespaces watchers so the three legitimate concurrent consumers — subscription management ('subscription-manager'), CA monitoring ('ca-monitor'), and replication-confirmation tracking ('replication-confirmation') — keep independent watchers and never supersede each other; only a re-invocation of the same consumer (i.e. a deploy reload) supersedes. subscribeToNodeUpdates gains an optional watcherKey to route each caller to a distinct key. A new exported stopNodeUpdateWatcher(key) makes the teardown explicit and testable. Separately, startOnMainThread's whenThreadsStarted.then() now clears the module-level routes list before repopulating so routes don't accumulate across deploys.

Where to look

  • reconstructNodeFromKey returning replicates: true is load-bearing — please sanity-check that this does not create a subscription to a peer that shouldn't be replicated from. A reconstructed descriptor only arises for a peer whose key is already in hdb_nodes (a known, range-visible peer), and the subsequent decodable update reconciles replicates. But this is the spot to scrutinize.
  • The watcher-key namespacing in runNodeUpdateWatcher / subscribeToNodeUpdates — there are exactly three call sites (subscription-manager, ca-monitor, replication-confirmation), each with a distinct key; collapsing same-key re-invocations is the intended semantics. A subscribe() that races a supersede is guarded (the loop re-checks the generation after every await), but it's worth a second pair of eyes on the await points.
  • The processNodeUpdateEvent put-path: a genuine delete still routes through isGenuineNodeDeletion (unchanged); only a non-deletion null-value now reconstructs-and-forwards instead of being dropped.

What I deliberately did NOT touch

  • nodeMap / connectionReplicationMap are not cleared on reload. Re-driving the same peers through onNodeUpdate is effectively idempotent (existing connections are reused via connectionReplicationMap.get), and clearing them would tear down live replication connections — too risky for a patch. The watcher-dedup + routes reset addresses the actual unbounded accumulation.
  • The [DeployLifecycle] deploy:start/deploy:end listeners are added/removed by Scope (core), which already removes them in Scope.close(). The replicator uses the old startOnMainThread extension API, not a Scope, so it does not add those listeners. If the MaxListenersExceededWarning on [DeployLifecycle] persists after this fix, the residual is a Scope-close gap in core, tracked separately (see follow-ups).

Deferred follow-ups

Tests

unitTests/replication/scanNodesForSubscription.test.mjs:

  • Pure seams (reconstructNodeFromKey, resolveScannedNode, probeNodeRow) and the scan loop (scanNodesForSubscription): an all-undecodable follower drives zero subscriptions under the old continue skip and one per range-visible peer under the fix; a genuine tombstone is skipped (not revived) while a decode-miss peer in the same scan is still reconstructed.
  • Watcher idempotency: re-invoking the same key supersedes (closes) the prior watcher; distinct keys run concurrently without superseding each other; stopNodeUpdateWatcher closes the active watcher and halts delivery.

Commands run:

  • npm run build
  • npx mocha --require unitTests/unitTestSetup.cjs 'unitTests/replication/**/*.test.mjs' → 172 passing (includes the existing nodeUpdateWatcher.test.mjs, unaffected by the runNodeUpdateWatcher refactor).

🤖 Generated with Claude Code (model: Claude Opus 4.8)

kriszyp and others added 2 commits June 23, 2026 12:44
…nodes peers (#460)

A deploy_component reloads the worker pool, re-invoking startOnMainThread()
on the already-resolved main-thread module. Its whenThreadsStarted.then()
fires immediately with no base-copy, so the subscription scan runs while a
follower's replicated hdb_nodes rows still fail to decode. The scan did
`if (!node) continue`, silently skipping every undecodable peer, leaving the
follower with zero outbound subscriptions until a full restart.

Mirror the #352 auth-path fallback on the outbound path: when an hdb_nodes
key is range-visible but its value decodes to null, reconstruct a minimal
{ name, replicates: true } descriptor and still drive the subscription
listener. replicates:true is required (unlike the auth-only { name }) because
the subscription path gates on node.replicates; getNodeURL falls back to
wss://<name>:9933. Applied to both the startup scan (subscribeToNodeUpdates,
via the extracted pure helpers scanNodesForSubscription/resolveScannedNode/
reconstructNodeFromKey) and the put-event path in processNodeUpdateEvent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…eloads (#460)

A deploy_component reload re-invokes replicator.startOnMainThread on the
same already-resolved main-thread module; its whenThreadsStarted.then()
re-fires and re-runs subscribeToNodeUpdates -> runNodeUpdateWatcher,
previously stacking a second unbounded watcher loop (and an extra
forEachReplicatedDatabase chain) on every deploy. Make the watcher
supersede its prior instance via a per-purpose generation token + iterator
close, namespaced so the subscription-management and replication-
confirmation watchers still run concurrently. Also clear the module-level
routes list before repopulating so routes don't accumulate across deploys.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@kriszyp
kriszyp requested review from kylebernhardy and ldt1996 June 23, 2026 18:56

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

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 generation tokens to prevent duplicate node-update watchers and implements a fallback mechanism to reconstruct minimal node descriptors from keys when decoding fails, resolving outbound subscription issues on component reloads. The review feedback suggests enriching these reconstructed nodes with properties from previous node states (oldNode) during event processing and subscription scans to prevent transient loss of properties like shard and url. Additionally, it recommends handling potential promise rejections when closing the async iterator in stopNodeUpdateWatcher to avoid unhandled promise rejections.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread replication/knownNodes.ts Outdated
Comment thread replication/knownNodes.ts
Comment thread replication/knownNodes.ts Outdated
@claude

claude Bot commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Reviewed; no blockers found.

…monitor watcher key) (#460)

Codex P1: the subscription scan reconstructed a descriptor for ANY null
range value, which would revive a genuine remove_node tombstone (a deleted
node leaves a clean-null row). Add probeNodeRow to disambiguate via the
point lookup: a throw = present-but-undecodable (#352/#1163) -> reconstruct;
a clean null = tombstone -> skip. resolveScannedNode now takes an injected
probe and refuses to revive deleted nodes.

Codex P2: monitorNodeCAs and the subscription manager both called
subscribeToNodeUpdates on the default watcher key, so the new supersede
behavior had the subscription-manager watcher stop the CA-monitor watcher
(CA rotations would stop updating replication TLS contexts). Give each
consumer a distinct key: 'subscription-manager', 'ca-monitor',
'replication-confirmation'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@kriszyp
kriszyp requested review from DavidCockerill and heskew and removed request for kylebernhardy June 23, 2026 19:30
@kriszyp
kriszyp marked this pull request as ready for review June 23, 2026 19:30
@kriszyp
kriszyp requested a review from a team as a code owner June 23, 2026 19:30
@kriszyp kriszyp added this to the v5.1 milestone Jun 23, 2026
…er-close promise (#461 review)

- Enrich a reconstructed decode-miss descriptor with the prior in-memory node's fields
  (url/shard/ca/etc.) on BOTH the processNodeUpdateEvent paths and the subscribeToNodeUpdates
  reload scan, so a transient decode miss no longer strips connection fields off the recovery
  descriptor — name + replicates from the reconstruct still win. Addresses Gemini #461 review.
- Wrap stopNodeUpdateWatcher's iterator.return() in Promise.resolve().catch() to avoid an
  unhandled rejection on close.

Co-Authored-By: Claude Opus 4.8 <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.

deploy_component worker-reload leaves followers with zero replication subscriptions

1 participant