Skip to content

Pace replication retries on one jittered schedule and bound subscription setup - #800

Open
kriszyp wants to merge 13 commits into
mainfrom
fix/replication-uniform-backoff
Open

Pace replication retries on one jittered schedule and bound subscription setup#800
kriszyp wants to merge 13 commits into
mainfrom
fix/replication-uniform-backoff

Conversation

@kriszyp

@kriszyp kriszyp commented Sep 2, 2026

Copy link
Copy Markdown
Member

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 → onDatabase firing. Each qualifying event turned straight into a retained (not unref'd) 200 ms setTimeout, a subscribe-to-node message, worker-side WebSocket/TLS setup, and one Setting 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

createBackoff in replication/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:

  • an exhausted schedule returns undefined, never 0, so a missed exhaustion check cannot become a busy loop;
  • budgetMs is 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 the connectionReplicationMap entry because onDatabase'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 synchronous postMessage throw 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 exported getSubscriptionConnectionKey.

Recovery timers are owned. One entry.reDriveTimer per 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 the RECONNECT_STAGGER_MS spacing 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 (connectToNextWorker subscribes a failover peer on a worker that is not entry.worker), and both timers already re-check live state when they fire — the stall kick's half of that is shouldFireStallKick, 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; onFrameSent is still the only reset), the wedge and receive-stall re-drives, the hdb_nodes watcher 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.md carries the per-site table and the list of what is deliberately excluded.

The hdb_nodes watcher reset bug: iteratedSuccessfully was set the instant subscribe() 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 past NODE_WATCHER_HEALTHY_UPTIME_MS, measured from when the subscription actually came up.

For the human reviewer

Decisions worth disagreeing with:

  • Full jitter at the reconnect site, resolved by the task owner in favour of decorrelation. It halves the expected dial interval the Harper OOM-killed (SIGKILL) during high-throughput bulk upsert writes (10 GB) #339 fix installed (mean ~15 s against a deterministic 30 s at the cap), so a node facing a permanently-dead peer dials ~4×/min instead of 2×/min — far below the incident rate, and the 500 ms floor keeps the hard minimum. Equal jitter (ceiling/2 + rand·ceiling/2) would restore the old mean and is a one-line change, cheapest before this reaches a fleet.
  • Subscription setup escalates to a 30 s ceiling. This path is also the recovery path, so worst-case time-to-resubscribe for a never-connecting pair grows from 200 ms to 30 s in order to bound the storm. Two constants.
  • The setup schedule outlives its entry, deliberately — but that is a second lifecycle every future entry path has to remember to cancel.
  • The armed setup carries its own payload plus a refreshPending side channel, rather than fixing onDatabase's early-return path so entry.nodes is always the enriched array and reading it at fire time. The root-cause fix touches enrichment ordering in onDatabase; this does not.
  • Connect resets the setup delay but cancels nothing. An earlier revision cancelled the armed setup on connect and gated that on the reporting worker's identity; the chaos-restart suite caught it — attribution fails on the failover path, so the delay stopped resetting and cycles converged in 5.3 s, 12.0 s, then not within the 25 s budget. Resetting unconditionally means a redundant subscribe-to-node can reach a live connection, which is what this path always did.
  • retryTime is 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".
  • The blob-repair single-flight guard was removed rather than kept: 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's repair_blob_data lever until restart. Concurrent sweeps stack again, as on main.
  • A hopeless blob-repair sweep now runs unpaced after 60 s of consecutive failures instead of stopping: a cluster-wide-lost prefix must not make the repairable records behind it unreachable through the operation. The per-record warn is sampled once unpaced.

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:

Reviewer findings I declined, with the evidence:

  • "refreshPending is 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 old setTimeout for exactly the same entry state, so post-fire behavior is unchanged and refreshPending is purely additive.
  • "The if (!(…)) reformat in the failover path is unrelated and prettier will revert it." The opposite: running this repo's prettier over origin/main's subscriptionManager.ts reports it as unformatted, and the shape in this branch is exactly what prettier emits. Reverting would fail prettier --check.
  • "New test files must not add sinon." That rule is core/AGENTS.md, scoped to core; eight harper-pro replication unit tests already use sinon fake timers, including two this branch edits.
  • "Remove the new comments that narrate history or restate declarations." Raised independently by gemini and codex. Read each named site: backoff.ts's docblock states why full jitter is the default and what budgetMs guarantees against a stalled event loop; knownNodes.ts says why NODE_WATCHER_HEALTHY_UPTIME_MS exists at all; replicationConnection.ts records 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.ts says 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() checks intentionallyUnsubscribed only before the createWebSocket await, and unsubscribe() closes this.socket?, which is undefined during it — so an unsubscribe landing inside a TLS handshake can leave an orphan live session with connected: true.
  • The teardown sites build the connections key with the URL pair in the opposite order to the subscribe path. Identical for every ordinary subscription; under failover (connectToNextWorker subscribes node B over peer A's URL) the teardown lookup has always missed. Behavior preserved, the asymmetry is now documented at the helper.
  • The revive path (unsubscribed = false) resets disconnectedAt and createdAt but not receiveStallReconnectAt, so an unsubscribe/re-add carries the old stall stamp and a connection that reads Receiving without advancing lastReceivedTime is invisible to the stall net until real progress. Same shape as the shouldFireStallKick release this PR adds, in a branch that predates it.

Coverage gap, stated plainly: startOnMainThread encloses onNodeUpdate, onDatabase, connectedToNode and reconcileWorkers, 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 build emits; its ~25 tsc errors are byte-identical to origin/main's, so the exit code is not a usable gate here — see the issue note below). npm run lint:required and prettier --check clean.

  • Unitnpm 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.
  • CI — everything green except Cluster Integration Tests 6/6, which fails on the same txnlogTearReplication assertion that main's own scheduled run fails on today (run 33650188518, all three Node versions, identical message). Not from this branch.
  • Cluster integration, locallynpm run test:integration:cluster: green except cloneReadinessKeySet (QA-762) and txnlogTearReplication, both of which fail identically on origin/main (23b2e51) in this environment — verified by building and running that commit in the same worktree. oversizedFrameCursorSafety flaked once under full-suite load and passes in isolation.
  • The replication: no-backoff subscription-setup retry storm on transient boot-time DNS failure ends in OOM #327 scenario end to endintegrationTests/stress/wedgedPeerSubscribeStorm.test.mjs under HARPER_RUN_STRESS_TESTS=1: passes. That is the suite that asserts a bounded Setting up subscription with leader rate, no MaxListenersExceededWarning, 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.
  • The regression this caughtconnectedBitRestartChurn (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 a BLOCK through two CHANGES to COMMENTS; the Gemini leg failed auth throughout because the worker had no agy login. 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

kriszyp and others added 12 commits September 1, 2026 18:35
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

@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 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.

Comment thread replication/subscriptionManager.ts Outdated
Addresses the Gemini review comment on #800.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HEgBi5zh8QKMbFmeGwFv7w
@kriszyp
kriszyp requested review from cb1kenobi and heskew September 2, 2026 23:01
@kriszyp
kriszyp marked this pull request as ready for review September 2, 2026 23:01
@kriszyp
kriszyp requested a review from a team as a code owner September 2, 2026 23:01
@claude

claude Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

✅ 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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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

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.

2 participants