Repository navigation
fix(replication): decode-resilient outbound subscriptions + idempotent node-update watcher on deploy reload (#460) - #461
Conversation
…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>
There was a problem hiding this comment.
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.
|
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>
…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>
Fixes #460
Summary
This PR closes two distinct failure modes that a
deploy_componentreload triggers on a follower. A deploy reloads the worker pool, which re-invokesreplicator.startOnMainThread()on the same already-resolved main-thread module instance. BecausewhenThreadsStartedis already resolved, its.then()re-fires immediately — with no base-copy (unlike a full process restart, wherehdb_nodesis base-copied first).1. Undecodable peers dropped → zero outbound subscriptions (the data-loss symptom). The post-reload
hdb_nodessubscription 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 didif (!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 inresolveNodeForAuthis 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-ransubscribeToNodeUpdates→runNodeUpdateWatcherwithout tearing down the prior one, stacking a second unbounded watcher loop (and double-processing everyhdb_nodesevent), plus re-pushingroutes, on every deploy. This is the source of theMaxListenersExceededWarningseen during the incident.Fix
Decode-resilient subscription building (#1). Mirror the #352 auth-path fallback on the outbound path: when an
hdb_nodeskey 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_nodetombstone — a deleted node leaves a row whose POINT lookup returnsnullcleanly, whereas a decode failure throws from the point lookup.probeNodeRowdisambiguates 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 asdeleteevents routed throughisGenuineNodeDeletion, not as null-valued puts.)Key detail: the reconstructed descriptor is
{ name, replicates: true }, not the auth path's{ name }. The outbound path gates onnode.replicates(seeshouldReplicateFromNodeand the early-return insubscriptionManager.onNodeUpdate); a bare{ name }would be filtered out and no subscription created.replicates: trueis the cluster default (whatensureThisNodeand route-defaulting write), andgetNodeURLalready falls back towss://<name>:9933. The next decodablehdb_nodesupdate replaces it with the full record.Reload idempotency / listener-leak (#2). Make
runNodeUpdateWatchersupersede its own prior instance instead of stacking one. A per-purpose generation token (watcherGenerationskeyed 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.subscribeToNodeUpdatesgains an optionalwatcherKeyto route each caller to a distinct key. A new exportedstopNodeUpdateWatcher(key)makes the teardown explicit and testable. Separately,startOnMainThread'swhenThreadsStarted.then()now clears the module-levelrouteslist before repopulating so routes don't accumulate across deploys.Where to look
reconstructNodeFromKeyreturningreplicates: trueis 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 inhdb_nodes(a known, range-visible peer), and the subsequent decodable update reconcilesreplicates. But this is the spot to scrutinize.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. Asubscribe()that races a supersede is guarded (the loop re-checks the generation after everyawait), but it's worth a second pair of eyes on the await points.processNodeUpdateEventput-path: a genuinedeletestill routes throughisGenuineNodeDeletion(unchanged); only a non-deletion null-value now reconstructs-and-forwards instead of being dropped.What I deliberately did NOT touch
nodeMap/connectionReplicationMapare not cleared on reload. Re-driving the same peers throughonNodeUpdateis effectively idempotent (existing connections are reused viaconnectionReplicationMap.get), and clearing them would tear down live replication connections — too risky for a patch. The watcher-dedup +routesreset addresses the actual unbounded accumulation.[DeployLifecycle]deploy:start/deploy:endlisteners are added/removed byScope(core), which already removes them inScope.close(). The replicator uses the oldstartOnMainThreadextension API, not a Scope, so it does not add those listeners. If theMaxListenersExceededWarningon[DeployLifecycle]persists after this fix, the residual is a Scope-close gap in core, tracked separately (see follow-ups).Deferred follow-ups
hdb_nodesevent.hdb_nodesre-copy at reload — the decode-failure root cause (the 5.1 line (5.0.x unaffected): rolling v4→v5 in-place upgrade — late-flipped nodes reject all inbound replication with 1008; auth-path read of v5-era hdb_nodes rows fails (exposed by #345 guard; needs new fix on main) #352 / harper#1307–#1308 family); out of scope here.deploy:start/deploy:endlisteners don't accumulate independently of replication.Tests
unitTests/replication/scanNodesForSubscription.test.mjs:reconstructNodeFromKey,resolveScannedNode,probeNodeRow) and the scan loop (scanNodesForSubscription): an all-undecodable follower drives zero subscriptions under the oldcontinueskip 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.stopNodeUpdateWatchercloses the active watcher and halts delivery.Commands run:
npm run buildnpx mocha --require unitTests/unitTestSetup.cjs 'unitTests/replication/**/*.test.mjs'→ 172 passing (includes the existingnodeUpdateWatcher.test.mjs, unaffected by therunNodeUpdateWatcherrefactor).🤖 Generated with Claude Code (model: Claude Opus 4.8)