Pace replication retries on one jittered schedule and bound subscription setup - #800
Pace replication retries on one jittered schedule and bound subscription setup#800kriszyp wants to merge 13 commits into
Conversation
Replication had no jitter anywhere in production code and a different retry policy at every site, from a flat 200ms subscription-setup delay to jitterless exponentials. The subscription-setup site had no dedup, no cap, and a non-unref'd timer, so whatever re-drove onNodeUpdate amplified 1:1 into main-thread timers, worker-side WebSocket/TLS setup, and "Setting up subscription with leader" warns — ~1,400 lines/s/node in the field, ending in an OOM kill (harper-pro#327). Add replication/backoff.ts: one createBackoff schedule with an exponential ceiling, full jitter, an optional per-site floor, an optional wall-clock budget, and injectable RNG/clock. Adopt it at the subscription-setup scheduler, scheduleReconnect, the wedge and receive-stall re-drives, the hdb_nodes watcher restart, the send-auth reprobe, the clone JWT/version retries, and the blob-repair sweep. The subscription-setup scheduler additionally enforces at most one pending setup per (peer URL, database), keyed in its own map because the stale-worker path deletes and recreates the connectionReplicationMap entry. Its dispatch re-reads the live entry at fire time rather than capturing a payload, so a deduped update is never lost and no closure is allocated per suppressed event, and the setup is cancelled on connect, unsubscribe, and node deletion. Also fix the hdb_nodes watcher's premature backoff reset: the success marker was set the instant subscribe() resolved, so a subscribe-then- immediately-throw cycle reset the delay on every pass and never escalated. It now resets only after an iteration survives a healthy- uptime threshold. Refs #327 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LHXY4wfz8qJ4ryA5gKPAme
The deduped scheduling path read `entry.nodes` at fire time, but `onDatabase` replaces that array on its early-return path *without* running the leader/url enrichment — so a node update arriving between arming and firing left the setup posting a request with no url, and the worker logged "Failed to create web socket to undefined" and never connected (caught by selectiveTableSubscription.test.mjs). The schedule now carries the payload of the call that armed or last refreshed it; only calls that did enrich reach the scheduler, so "newest payload wins" holds without the clobber. Also resolve `nodes[0].url` where the payload is built rather than at the scheduling site, so the wedge re-drive — which posts from `entry.nodes` — cannot inherit the same urlless array. Refs #327 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LHXY4wfz8qJ4ryA5gKPAme
…covery timers From the independent pre-push review of the previous two commits: - send-auth reprobe (blocker): checking the budget only at the top of the loop let a row that became decodable *after* an event-loop stall pushed us past the 30s deadline still authorize the peer — the loop exits on a non-UNCHANGED row without consulting the deadline. It now fails closed on the elapsed time before trusting the next read. The backoff is also built lazily, so an ordinary decodable authorization event allocates nothing and never reads the clock. - scheduleReconnect: a fixed 100ms floor under full jitter did not preserve the #339 dial-rate guard, which is ceiling-relative by nature. Added `jitter: 'equal'` to the utility (draw from the top half of the window) and used it here, so the minimum dial interval stays proportional as the ceiling escalates. Full jitter stays the default everywhere the invariant is latency rather than rate. - subscription setup: `onDatabase`'s early-return path builds a fresh payload but never reaches the scheduler, so an armed setup could still dispatch pre-update routing state. It now calls `refreshPending`, and carries `isLeader` forward onto the replacement array so neither that path nor the wedge re-drive loses the leader decision. - wedge / receive-stall re-drives: each entry now owns a single `reDriveTimer` that a later decision replaces and connect/unsubscribe/ delete disarm, so a sweep staggered across many databases cannot stack waves or fire after an unsubscribe. The stall kick additionally re-reads the receive watermark at fire time, so a copy that resumed during the delay is not reconnected. - a same-name node moving to a new URL now cancels any setup armed for the address it left. Refs #327 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LHXY4wfz8qJ4ryA5gKPAme
Bound worker-side pre-readiness subscription work, retain self-catchup state until dispatch, and make exhausted backoffs fail closed. Align reconnect and watcher jitter with the full-jitter policy and extend regression coverage. Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Draw the wedge/stall re-drive jitter once per reconcile sweep instead of once per entry: a per-entry draw varied consecutive delays by up to ±200ms, letting several dials share a 50ms instant and weakening the concurrency bound RECONNECT_STAGGER_MS exists to hold (#446). Only the worker that currently owns an entry may retire its pending setup on connect, so a stale worker's still-open connection cannot cancel the setup armed for the entry's replacement. End a blob-repair sweep once the schedule is exhausted rather than pacing an unrepairable backlog at 1s/record with the cursor open, and record why the subscription connection-key helper keeps its two teardown callers' argument order. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HEgBi5zh8QKMbFmeGwFv7w
A component-readiness rejection retained the pre-readiness subscribe/unsubscribe actions but never re-attempted them, so nothing applied until another parentPort message arrived or the wedge reconcile noticed ~30s later — the empty-subscription window this gate exists to close. Re-attempt on the same capped, jittered, unref'd schedule the rest of the discipline uses, resetting once readiness succeeds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HEgBi5zh8QKMbFmeGwFv7w
…ownership - shouldCloseSendAuthWatch: the row read is synchronous and can itself carry the clock past the advertised grace period, so re-check the deadline after it and fail closed. - repairBlobs: bound how long a failure run may spend *pausing* instead of ending the sweep on a consecutive-failure count — a cluster-wide-lost prefix must not make the repairable records behind it permanently unreachable through the operation. Drop the per-thread single-flight guard with it: `getRecord()` has no timeout, so one wedged peer would hold the flag for the life of the thread and remove the operator's lever. - runNodeUpdateWatcher: stamp the health clock once the subscription is live, so a subscribe() that blocks past the threshold and then throws no longer reads as uptime. - Worker admission: own the readiness re-attempt so a pre-readiness burst against a rejecting import cannot multiply timers, imports and warn lines 1:1 with messages. - Connect ownership: tag the report with the sending thread id rather than relying on core's undocumented onMessageByType listener arity, behind a tested pure helper. - Disarm an entry's recovery timer when its worker exits or the entry is replaced, and adopt the shared schedule for the copy-cursor flush retry. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HEgBi5zh8QKMbFmeGwFv7w
The backoff table described the blob-repair cutoff the previous commit replaced and listed neither the copy-cursor flush retry nor the worker readiness re-attempt; the exclusions paragraph now also names the copy-finalize timeout bound, which is a wait bound rather than a retry and must stay unjittered. Give the readiness re-attempt the same floor every other adopting site has, so a zero draw cannot re-probe on the next macrotask. Sample the per-record blob-repair warn once the sweep goes unpaced, and put clearWorkerFromEntries' description back over clearWorkerFromEntries. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HEgBi5zh8QKMbFmeGwFv7w
Gating the connect edge on "is this the entry's current worker" is not answerable on the main thread: connectToNextWorker subscribes a failover peer on a worker that is not entry.worker, and a superseded worker's hung-but-open connection reports for the same (url, database) as its replacement. Gating on it meant the escalated setup delay stopped resetting, and connectedBitRestartChurn's chaos cycles converged in 5.3s, 12.0s, then not within 25s — a backoff compounding across reconnects. Reset the delay unconditionally and leave the armed setup and recovery timers to their own fire-time guards, which is what this path did before the schedule existed. That suite is green again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HEgBi5zh8QKMbFmeGwFv7w
Removing the connect-edge cancellation left the receive-stall kick with no guard that observes a reconnect: entry identity holds, receiveStallReconnectAt is untouched by connectedToNode, and the watermark cannot move on a socket that just opened — so a leg that drops and reconnects inside the kick's stagger window was force-reconnected on the strength of the old socket's watermark. Claim it through a connect generation, the way the wedge kick claims its entry through disconnectedAt (which a stalled, connected:true entry does not have). The design table, its prose, and dispatchSubscribeSetup's JSDoc still described the cancel-on-connect behavior that commit removed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HEgBi5zh8QKMbFmeGwFv7w
entry.receiveStallReconnectAt both claims the armed kick and throttles re-detection, which needs lastReceivedTime past the stamp. Skipping the kick because the leg reconnected inside the stagger window therefore spent an epoch on a kick that never happened: if the fresh socket stalled too it never moved the watermark past the stamp and the receive-stall net never re-armed for that (peer, database) again. The decision moves into shouldFireStallKick — a pure helper with the arm/bump/fire cases under test, which is what the guard shipped without. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HEgBi5zh8QKMbFmeGwFv7w
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HEgBi5zh8QKMbFmeGwFv7w
There was a problem hiding this comment.
Code Review
This pull request introduces a unified, jittered backoff discipline across the replication system to mitigate thundering herd issues and prevent OOM conditions during retries. Key changes include the addition of a generic createBackoff utility, a SubscribeSetupScheduler to dedup and pace subscription setups, and a createWorkerSubscriptionAdmission gate to manage worker readiness. The changes are well-documented in DESIGN.md and supported by extensive new unit tests. I have kept the review comment regarding Map lookup optimization in subscriptionManager.ts as it provides a valid improvement opportunity.
Addresses the Gemini review comment on #800. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HEgBi5zh8QKMbFmeGwFv7w
|
✅ Reviewed the full diff (backoff.ts, subscriptionManager.ts, replicationConnection.ts, knownNodes.ts, replicator.ts, cloneNode/*, blobRepair.ts, DESIGN.md) — no blockers found, and the sole prior review comment (gemini's Map-lookup suggestion) is resolved by commit a5b6aa0. |
| } | ||
| if (existingEntry) { | ||
| worker = existingEntry.worker; | ||
| nodes[0].isLeader = nodes[0].isLeader || existingEntry.nodes?.[0]?.isLeader; |
There was a problem hiding this comment.
[High] Recompute leadership instead of preserving a stale leader bit
This OR makes isLeader: true persist even when the latest node state explicitly demotes that peer. The fast path then stores the stale flag, so later setup or wedge recovery can route through the former leader. Move the current leader calculation before this branch and assign its result instead of carrying forward the cached flag.
—
Reviewed a5b6aa0
| return; | ||
| } | ||
| mainWorkerEntry.connected = true; | ||
| mainWorkerEntry.connectGeneration = (mainWorkerEntry.connectGeneration ?? 0) + 1; |
There was a problem hiding this comment.
[High] Scope stall cancellation to the reporting connection
Any connect report for this URL/database increments the generation, including reports from failover or superseded workers. Such a report can make shouldFireStallKick cancel recovery for a different connection that remains stalled, and repeated stale reports can keep suppressing its kicks. Include a worker/session identity in the report and advance the generation only for the connection being monitored.
—
Reviewed a5b6aa0
One backoff schedule for every replication retry site, and admission control on the one surface where a retry could amplify: subscription setup.
The defect (#327)
A transient boot-time DNS failure kept
onNodeUpdate → onDatabasefiring. Each qualifying event turned straight into a retained (notunref'd) 200 mssetTimeout, asubscribe-to-nodemessage, worker-side WebSocket/TLS setup, and oneSetting up subscription with leader …warn — no dedup, no cap, no jitter. Whatever re-drove the event amplified 1:1: ~1,400 log lines/s/node in the field, ending in an OOM kill. Pacing alone would not have fixed it — the amplification is per event, not per delay — so the storm surface gets admission control as well as a schedule.The utility
createBackoffinreplication/backoff.ts—{initialMs, maxMs, minMs?, factor?, jitter?, budgetMs?, maxAttempts?, random?, now?}: exponential ceiling, full jitter, optional fixed floor, optional wall-clock/attempt budget, injectable RNG and monotonic clock. Two properties worth knowing:undefined, never0, so a missed exhaustion check cannot become a busy loop;budgetMsis a deadline read off the clock, not a sum of requested sleeps, so a resolver hang or an event-loop stall cannot stretch a grace period past what it advertises.Before this there was no jitter anywhere in production code: every exponential in the repo was lockstep across a fleet.
Admission control
Subscription setup (
createSubscribeSetupScheduler) holds at most one armed setup per (peer URL, database), carrying the newest payload, on a 200 ms floor / 400 ms → 30 s jittered ceiling that resets when the pair connects. It lives in its own map rather than on theconnectionReplicationMapentry becauseonDatabase's stale-worker path deletes and recreates that entry — per-entry state would be wiped on exactly the path that most needs the dedup. Self-catchup stays separate one-shot state, attached on a fresh array immediately before dispatch and consumed only once the worker message is accepted, so cancellation, a stale dispatch, or a synchronouspostMessagethrow cannot lose it. The warn moved inside the "actually armed" branch: it now describes an attempt, not an event. Setups are cancelled on connect, unsubscribe, node deletion and same-name URL migration — all reachable once a pending timer can live 30 s instead of 200 ms.The worker readiness boundary used to attach a continuation to the component-readiness promise per message (
createWorkerSubscriptionAdmission). Before readiness, one insertion-ordered map now retains the latest action per connection key and database behind a single continuation, with exactly one armed re-attempt if that readiness rejects; after readiness the handlers run inline and allocate no queue state. Subscribe, unsubscribe, force-reconnect and this map all derive identity through one exportedgetSubscriptionConnectionKey.Recovery timers are owned. One
entry.reDriveTimerper entry,unref'd, disarmed on unsubscribe, node deletion, worker exit and entry replacement, with the sweep's jitter drawn once and used as a common base so theRECONNECT_STAGGER_MSspacing that bounds concurrent TLS setup (#446) survives the decorrelation. A connect report resets the escalated delay but cancels nothing: the main thread cannot attribute a report to the entry that armed the work (connectToNextWorkersubscribes a failover peer on a worker that is notentry.worker), and both timers already re-check live state when they fire — the stall kick's half of that isshouldFireStallKick, which also hands back the re-detection stamp when a reconnect cancels a kick.Sites adopting the schedule
scheduleReconnect(full jitter under the unchanged 500 ms → 30 s ceiling, keeping the hard 500 ms floor from #339;onFrameSentis still the only reset), the wedge and receive-stall re-drives, thehdb_nodeswatcher restart, the send-auth reprobe (a 30 s wall-clock deadline in place of 60 fixed sleeps), the copy-cursor flush retry, the two clone probes, and the blob-repair per-record pause.DESIGN.mdcarries the per-site table and the list of what is deliberately excluded.The
hdb_nodeswatcher reset bug:iteratedSuccessfullywas set the instantsubscribe()resolved, so a subscription that resolved and immediately threw counted as a success every pass and pinned the restart delay at 1 s forever. Progress is now an iteration that stayed live pastNODE_WATCHER_HEALTHY_UPTIME_MS, measured from when the subscription actually came up.For the human reviewer
Decisions worth disagreeing with:
ceiling/2 + rand·ceiling/2) would restore the old mean and is a one-line change, cheapest before this reaches a fleet.refreshPendingside channel, rather than fixingonDatabase's early-return path soentry.nodesis always the enriched array and reading it at fire time. The root-cause fix touches enrichment ordering inonDatabase; this does not.subscribe-to-nodecan reach a live connection, which is what this path always did.retryTimeis now a derived getter over the backoff ceiling. It has no production reader left — it exists for the existing tests — and its meaning shifts from "next delay" to "next ceiling".getRecord()has no timeout, so one wedged-but-open peer would hold the flag for the life of the thread and take away the operator'srepair_blob_datalever until restart. Concurrent sweeps stack again, as onmain.Open findings from the final review round (round 12,
verdict: CHANGES, adjudicated severity major), not fixed here — this was the first full re-read since round 1, so these interactions had not been looked at since the branch changed underneath them:createSubscribeSetupScheduler.schedule()computesdelay = backoffDelay + staggerMs, and each(url, database)owns an independentcreateBackoffwith its own RNG, so the fixedcount++ * RECONNECT_STAGGER_MSladder is added on top of independent jittered draws. Adjacent databases can draw 399 ms and 200 ms and fire at 399 ms and 250 ms — closer together than the ladder intends, and several pairs can land on one tick. A worker exit can therefore burst the concurrent WebSocket/TLS setups Bound replication worker exit listeners (#357); membership-aware cluster_status.is_enabled (#217) #446 exists to space out. Verified by reading both sites. Tracked as Subscription-setup stagger is added on top of independent jitter draws, so the #446 spacing does not hold on stale-worker reassignment #805.entry.reDriveTimerand the oldconnectionReplicationMapentry survives the migration with its ownership guards intact — so an armed recovery timer can post a force-reconnect to the obsolete endpoint. The entry leak is pre-existing; the timer that now rides on it is not. Verified by reading the site.Reviewer findings I declined, with the evidence:
refreshPendingis a no-op after the timer fires, so a payload change is lost until the wedge reconcile." The early return it describes predates this branch and returned before reaching the oldsetTimeoutfor exactly the same entry state, so post-fire behavior is unchanged andrefreshPendingis purely additive.if (!(…))reformat in the failover path is unrelated and prettier will revert it." The opposite: running this repo's prettier overorigin/main'ssubscriptionManager.tsreports it as unformatted, and the shape in this branch is exactly what prettier emits. Reverting would failprettier --check.core/AGENTS.md, scoped to core; eight harper-pro replication unit tests already use sinon fake timers, including two this branch edits.backoff.ts's docblock states why full jitter is the default and whatbudgetMsguarantees against a stalled event loop;knownNodes.tssays whyNODE_WATCHER_HEALTHY_UPTIME_MSexists at all;replicationConnection.tsrecords why the growth curve changed (the previous ~0.4%/retry took >1000 retries, so a dead peer accumulated native TLS state faster than V8 reclaimed it);blobRepair.tssays why its cap is low rather than the 30 s replication cap. Each is a why or a constraint the code cannot state, and the issue refs are the evidence for a constant rather than narration. The test-scaffolding narration codex names separately is a fair hit and worth trimming when these findings are addressed.Pre-existing problems surfaced by the review and deliberately not fixed here — each is filed rather than folded into a retry-pacing PR:
connect()checksintentionallyUnsubscribedonly before thecreateWebSocketawait, andunsubscribe()closesthis.socket?, which is undefined during it — so an unsubscribe landing inside a TLS handshake can leave an orphan live session withconnected: true.connectionskey with the URL pair in the opposite order to the subscribe path. Identical for every ordinary subscription; under failover (connectToNextWorkersubscribes node B over peer A's URL) the teardown lookup has always missed. Behavior preserved, the asymmetry is now documented at the helper.unsubscribed = false) resetsdisconnectedAtandcreatedAtbut notreceiveStallReconnectAt, so an unsubscribe/re-add carries the old stall stamp and a connection that readsReceivingwithout advancinglastReceivedTimeis invisible to the stall net until real progress. Same shape as theshouldFireStallKickrelease this PR adds, in a branch that predates it.Coverage gap, stated plainly:
startOnMainThreadenclosesonNodeUpdate,onDatabase,connectedToNodeandreconcileWorkers, so every unit test here targets an extracted helper. The end-to-end evidence for the storm is the opt-in stress suite below, not a unit test.Verification
Built (
npm run buildemits; its ~25tscerrors are byte-identical toorigin/main's, so the exit code is not a usable gate here — see the issue note below).npm run lint:requiredandprettier --checkclean.npm run test:unit: 897 passing. New: the backoff matrix (growth, cap, full-jitter bounds and floor via injected RNG, reset, wall-clock vs attempt exhaustion, deadline clamping), the setup scheduler (a 60,000-event / 60 s storm collapsing to 9 dispatches with never more than one pending timer,hasRef() === false, payload refresh, cancellation, contained dispatch throw, reset-on-connect without cancellation), reconnect decorrelation with the floor and the unchanged first-sent-frame reset, the watcher's escalate-on-subscribe-then-throw and its uptime stamp, worker readiness admission (latest action per target, one armed re-attempt, the floor on a zero draw), and the stall-kick decision matrix.Cluster Integration Tests 6/6, which fails on the sametxnlogTearReplicationassertion thatmain's own scheduled run fails on today (run 33650188518, all three Node versions, identical message). Not from this branch.npm run test:integration:cluster: green exceptcloneReadinessKeySet(QA-762) andtxnlogTearReplication, both of which fail identically onorigin/main(23b2e51) in this environment — verified by building and running that commit in the same worktree.oversizedFrameCursorSafetyflaked once under full-suite load and passes in isolation.integrationTests/stress/wedgedPeerSubscribeStorm.test.mjsunderHARPER_RUN_STRESS_TESTS=1: passes. That is the suite that asserts a boundedSetting up subscription with leaderrate, noMaxListenersExceededWarning, no OOM marker, RSS under cap, and a reconverging mesh while a peer stays wedged — the evidence a unit test cannot give for this path.connectedBitRestartChurn(QA-587) went red mid-development with its chaos cycles converging in 5.3 s, 12.0 s, then not within the 25 s budget; that is what identified the connect-attribution mistake described above. Green after the fix (11.4 s / 13.4 s / 21.4 s, plus a 48 s outage converging in 24.6 s), and green again on the final head (28.2 s for the whole chaos test).Pre-push review (HEG step 10,
prepush-review.mjs --author claude): 12 rounds. Rounds 1-9 ran codex as the graded leg with harper-domain adjudicating and Cursor Grok contributing to its per-branch round cap, converging from aBLOCKthrough twoCHANGEStoCOMMENTS; the Gemini leg failedauththroughout because the worker had noagylogin. Once that was fixed, rounds 10-12 added the missing lens, and round 12 was the first full re-read since round 1 with both families running:ran=codex,gemini,verdict: CHANGES, adjudicated severity major, three substantive findings listed above and unfixed. Worth knowing why they appear this late rather than treating them as new: rounds 2-9 were delta reviews scoped to each round's own commits, so no leg re-examined these interactions after the branch changed around them. A note on the earlier rounds' budget too — codex was SIGKILLed mid-diff at the default 1500 s whole-run--timeout(about 975 s for finding legs after the adjudication reserve) and needed 983 s here at--timeout 3000.Refs #327
Review-Coverage: authored=claude; ran=gemini,codex; adjudicated=domain; declined=cursor-grok,cursor-composer; rounds=12 @ a5b6aa0
Human-Review-Need: 4 (decisions: self-catchup-consumption-point, connect-report-resets-but-does-not-cancel, sticky-isleader-carry-forward, setup-delay-ceiling-200ms-to-30s, watcher-healthy-uptime-10s, blob-repair-continues-unpaced, isleader-carried-forward, connect-report-resets-not-cancels, subscribe-setup-ceiling-30s, stall-throttle-release-only-on-fire, shared-redrive-timer, watcher-health-signal, setup-cap-30s, one-jitter-draw-per-sweep, rider-consumed-at-dispatch, retrytime-as-derived-getter, stall-throttle-release-semantics, connect-lifecycle-shape, shouldfirestallkick-api, reset-on-unattributable-report, blob-repair-unpaced-tail, blob-sweep-single-flight-removed, payload-carried-on-the-schedule, send-auth-fail-closed-on-late-row) @ a5b6aa0