Skip to content

fix(replication): recover ping-alive copy-stall wedges via a copy-progress watchdog (#453) - #454

Merged
kriszyp merged 2 commits into
mainfrom
kris/copy-progress-watchdog
Jun 25, 2026
Merged

kriszyp merged 2 commits into
mainfrom
kris/copy-progress-watchdog

Conversation

@kriszyp

@kriszyp kriszyp commented Jun 22, 2026 •

Copy link
Copy Markdown
Member

Update (follow-ups + milestone → v5.1). This PR now carries two approved follow-ups for harper-pro#460 on top of the original #453 copy-progress watchdog, and is retargeted from v5.2 to v5.1 as a patch-grade reliability fix. Paired core change: HarperFast/harper#1461 (adds the replication_copyTimeout config param) — the core submodule pointer is bumped to it here, so #1461 must merge / be released before this PR can build against released core.

(b) Separate copy-phase receive timeout (REPLICATION_COPYTIMEOUT, default 300000ms). Distinct from the #453 copy-progress watchdog. The byte-level receiveWatchdog and the client sendPing idle check both timed the receiver out after REPLICATION_PINGTIMEOUT (60s) with no exemption during copy. On the initial copy of a large table (e.g. hdb_analytics, ~2.75M rows) the sender sits in writableNeedDrain backpressure, so no bytes reach the receiver for well over 60s even though the copy is healthy → the watchdog fires → forceReconnect → resume the same checkpoint → loop, starving the copy. Fix: while inCopyMode the byte watchdog uses the wider COPY_TIMEOUT threshold instead of PING_TIMEOUT. createReceiveWatchdog now accepts intervalMs as a number or a function resolved per-arm, so the threshold tracks inCopyMode over the connection's life.

  • Where to look: the synchronous ws.on('message') resetPingTimer() arms the byte watchdog before onWSMessage flips inCopyMode, so the byte watchdog is explicitly re-armed on COPY_START (and narrowed back when the copy finishes in maybeFinishCopy) — otherwise a sender that goes silent immediately after COPY_START would still trip the 60s ping timeout. This re-arm uses stop()+reset() to bypass the per-frame reset throttle. (Found by Codex cross-model review; fixed.)

(a) connected:true / ver=0 mid-copy blind spot — assessed, no code added. findWedgedNodeUrls (subscriptionManager.ts) only re-drives connected===false, so a subscription stuck connected:true / Receiving / version 0 is invisible to it. The #453 copy-progress watchdog already recovers exactly that case — it forces a reconnect on a connected-but-stalled copy. Extending findWedgedNodeUrls to re-drive connected:true entries would risk tearing down healthy slow-but-progressing copies, which the frame-keyed watchdog avoids by design. So (a) is covered by the existing watchdog; documented here rather than coded. This is the recovery-net side of harper-pro#460 (followers' replicated hdb_nodes failing to decode after a deploy reload).

Tests: new unit case in receiveWatchdog.test.mjs pins the function-form intervalMs per-arm resolution (normal → copy-phase widen → fire). The #453 integration test now also sets copyTimeout to prove the byte watchdog does not recover during the stall — the copy-progress watchdog is the recovery path. Both pass locally (full 155-test replication unit suite green; integration test recovers in ~13s, no restart).

The original #453 write-up follows.


Summary

Adds a copy-progress watchdog to the replication receive path that recovers a base copy which stalls while the socket stays ping-alive — a wedge neither existing safety net catches.

Purpose (harper-pro#453)

a customer cluster preprod 5.1.7: a rolling upgrade restart interrupted the system base copy and a follower's receive subscription settled connected:true / lastReceivedStatus:"Receiving" with the copy frozen at version 0 — permanently. Because live audit replay (which carries new hdb_deployment rows) only starts after COPY_COMPLETE, replicated deploys timed out for hours with no self-heal; only a manual staggered restart cleared it.

Both existing recovery mechanisms miss it, and for the same reason — they key off connected:false:

The new watchdog is keyed on received copy app-frames instead of socket bytes (pings are WS control frames, not 'message' events, so they can't suppress it). While in copy mode, if no copy frame arrives for the copy-stall threshold it forces the same close-independent forceReconnect the byte watchdog uses, which restarts the copy from the leader. It's armed on COPY_START and each in-copy frame, and suspended during backpressure pause / stopped on copy finish + close, so a slow-but-progressing or paused copy never trips it.

Proven

New integration test copyProgressWedgeRecovery.test.mjs reproduces the wedge deterministically via a one-shot, env-gated sender stall (maybeStallCopyForTest) that freezes the copy right after COPY_START while pings keep flowing. Ran both ways locally: without the watchdog the subscriber stays wedged and the post-stall write never replicates (test fails); with it, the copy reconnects, converges, and replication resumes — no restart.

Where to look / open questions for the reviewer

Related: #453 (incident), #460 (this PR's follow-ups + recovery-net), #420/#424/#289 (connected:false wedge family), #241/#426 (copy restart-from-zero), #451 + harper#1444 (sibling blob-timeout watchdogs — separate hardening, not this wedge). Paired core: harper#1461.

🤖 Generated by Claude (Opus 4.8). Diff and commit history are the source of truth.


Open item from cross-model review (for the human reviewer)

A Gemini pass flagged that a persistently slow leader (>120s between copy frames, e.g. severe disk/CPU contention scanning a table) could trip the copy-progress watchdog and reconnect. I kept the behavior deliberately: it mirrors the existing byte receiveWatchdog, which also forceReconnects on silence without backoff; the watchdog re-arms on every copy frame, so it only fires on a true ~120s gap with zero copy progress (a genuinely stalled copy); and on a same-version cluster the reconnect resumes from the persisted copy cursor, not from zero. If we'd prefer a reconnect-attempt cap / backoff here, easy to add — flagging it as a conscious tradeoff rather than an oversight.

Open item from the follow-up cross-model review (Gemini, for the human reviewer)

On (a), Gemini did not endorse "covered by the watchdog" and recommended a defense-in-depth global net: have findWedgedNodeUrls also re-drive a subscription stuck connected:true / Receiving / version 0 for, say, >10–15 min — so that if the local watchdog itself fails (software bug, event-loop block, misconfiguration) there is still a reconcile-level recovery. I deliberately left this out of this patch: it's a larger, separately-tunable hardening (re-driving connected:true entries is exactly the teardown-a-healthy-slow-copy risk this PR otherwise avoids), and out of the patch-grade scope for v5.1. Flagging it as a conscious deferral for the reviewer to weigh — a good candidate for a follow-up issue rather than a blocker here.

(Gemini's other findings — a throttle-swallowing bug on stop()+reset(), and regressions from "deleting" the consumer-less-blob skip-pause branch / sender chunk-timeout — do not apply: the reset() throttle already short-circuits on !timer so the transition re-arm is not swallowed (proven by the existing pause→resume unit test plus the new per-arm test), and this PR does not touch sendBlob or the blob-deadlock paths at all.)

@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 copy-progress watchdog to resolve a replication wedge issue (harper-pro#453) where a stalled base copy remains frozen while keepalive pings prevent the byte-level receive watchdog from firing. The changes include adding a new watchdog keyed on application-level copy frames, integrating it into the WebSocket replication lifecycle, and adding a comprehensive integration test with a test-only fault injection hook to verify the recovery behavior. I have no feedback to provide as there are no review comments.

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.

@claude

claude Bot commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Reviewed; no blockers found.

@kriszyp
kriszyp force-pushed the kris/copy-progress-watchdog branch from 86940a8 to 1a6a8ba Compare June 22, 2026 21:44
@kriszyp
kriszyp marked this pull request as ready for review June 22, 2026 21:57
@kriszyp
kriszyp requested a review from a team as a code owner June 22, 2026 21:57
@kriszyp
kriszyp force-pushed the kris/copy-progress-watchdog branch from 1a6a8ba to 0a9ba18 Compare June 23, 2026 13:27
@kriszyp kriszyp added this to the v5.2 milestone Jun 23, 2026
@kriszyp kriszyp modified the milestones: v5.2, v5.1 Jun 23, 2026
@kriszyp kriszyp modified the milestones: v5.1, v5.2 Jun 23, 2026
@kriszyp
kriszyp force-pushed the kris/copy-progress-watchdog branch from 552b606 to b09ec9b Compare June 24, 2026 15:54
kriszyp added a commit that referenced this pull request Jun 24, 2026
…ng copy-stall wedge

Add findStalledReceivingNodeUrls as a defense-in-depth companion to
findWedgedNodeUrls: the reconcile only re-drives connected:false entries, so a
base copy parked connected:true with the received-version watermark frozen
(ping-alive, harper-pro#453) is invisible to it. The worker-local copy-progress
watchdog (#454) is the primary recovery; this main-thread net only matters if
that watchdog itself fails.

Progress is measured by the per-record RECEIVED_TIME watermark read from the
process-shared status buffer (advances during a healthy copy even while version
is suppressed), so a slow-but-progressing copy is never torn down. Recovery is
forceReconnectToNode (a re-subscribe is a no-op for a still-connected entry).
Threshold is 15min, far longer than the worker watchdogs; re-drives only fire
once apply progress resumes since the last forced reconnect, so a kick that
changed nothing (caught-up/cosmetic Receiving) does not churn the connection.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
kriszyp added a commit that referenced this pull request Jun 25, 2026
…ng copy-stall wedge

Add findStalledReceivingNodeUrls as a defense-in-depth companion to
findWedgedNodeUrls: the reconcile only re-drives connected:false entries, so a
base copy parked connected:true with the received-version watermark frozen
(ping-alive, harper-pro#453) is invisible to it. The worker-local copy-progress
watchdog (#454) is the primary recovery; this main-thread net only matters if
that watchdog itself fails.

Progress is measured by the per-record RECEIVED_TIME watermark read from the
process-shared status buffer (advances during a healthy copy even while version
is suppressed), so a slow-but-progressing copy is never torn down. Recovery is
forceReconnectToNode (a re-subscribe is a no-op for a still-connected entry).
Threshold is 15min, far longer than the worker watchdogs; re-drives only fire
once apply progress resumes since the last forced reconnect, so a kick that
changed nothing (caught-up/cosmetic Receiving) does not churn the connection.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@kriszyp
kriszyp force-pushed the kris/copy-progress-watchdog branch from b09ec9b to 55b52cd Compare June 25, 2026 17:43
kriszyp and others added 2 commits June 25, 2026 12:07
…tall wedges (#453)

A base copy interrupted mid-flight could leave a follower's receive subscription
connected:true / lastReceivedStatus:"Receiving" with the copy frozen — permanently.
Two existing safety nets both miss it because both key off connected:false:
  - findWedgedNodeUrls only re-drives connected:false entries;
  - the byte-level receiveWatchdog never fires because keepalive pings keep
    ws._socket.bytesRead advancing.
So live audit replay (which carries new hdb_deployment rows) never starts and
replicated deploys time out, with no self-heal — observed on a customer cluster
(5.1.7, hours wedged, cleared only by a manual staggered restart; harper-pro#453).

Add a copy-progress watchdog keyed on received COPY frames (COPY_START, the copy
record batches via isCopyFrame, and copy BLOB_CHUNKs) instead of socket bytes:
pings are WS control frames (not 'message' events) so they can't suppress it, and
scoping to copy frames keeps unrelated bidirectional traffic from masking a stall.
While in copy mode, if no copy frame arrives for the copy-stall threshold
(REPLICATION_BLOBTIMEOUT, guarded against a 0/invalid value) it forces the same
close-independent reconnect the byte watchdog uses, restarting the copy. Armed on
copy frames; suspended during backpressure pause; stopped on COPY_COMPLETE and
close, so a legitimately slow-but-progressing or paused copy never trips it.

Reproduced and proven with a new deterministic integration test
(copyProgressWedgeRecovery.test.mjs) via a one-shot, env-gated sender stall that
freezes the copy right after COPY_START while pings keep flowing: without the
watchdog the subscriber stays wedged and the post-stall write never replicates
(test fails); with it the copy reconnects, converges, and replication resumes.

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

Follow-ups to the copy-progress watchdog (#453), for harper-pro#460.

(b) Separate copy-phase receive timeout (REPLICATION_COPYTIMEOUT, default 300000ms).
The byte-level receiveWatchdog and the client sendPing idle check both timed out the
receiver after REPLICATION_PINGTIMEOUT (60s) with no exemption during copy. On the
initial copy of a large table the sender's writableNeedDrain backpressure can keep
bytes from reaching the receiver for >60s even though the copy is healthy, so the
watchdog fired → forceReconnect → resume same checkpoint → loop, starving the copy.
While inCopyMode the byte watchdog now uses the wider COPY_TIMEOUT threshold instead.
createReceiveWatchdog takes intervalMs as a number OR a function resolved per-arm, so
the threshold can track inCopyMode over the connection's life. Because the synchronous
ws.on('message') reset() arms the watchdog BEFORE onWSMessage flips inCopyMode, the byte
watchdog is explicitly re-armed on COPY_START (and narrowed back when the copy finishes)
so a sender that goes silent immediately after COPY_START gets the wide threshold, not
the ping timeout. New core config param added in harper PR (core submodule bump).

(a) connected:true / ver=0 re-drive blind spot. findWedgedNodeUrls only re-drives
connected:false subscriptions, so a copy stuck connected:true / Receiving / version 0 is
invisible to it. Assessed: the #453 copy-progress watchdog already recovers exactly that
case (it forces a reconnect on a stalled-but-connected copy), so no additional code — the
two watchdogs together cover the connected:true stall. Documented rather than coded.

Tests: new unit case pinning the function-form intervalMs per-arm resolution; the #453
integration test now also sets copyTimeout to prove the byte watchdog does not recover
during the stall (copy-progress watchdog is the recovery path).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@kriszyp
kriszyp force-pushed the kris/copy-progress-watchdog branch from 55b52cd to edce532 Compare June 25, 2026 18:07
@kriszyp
kriszyp merged commit 30f2beb into main Jun 25, 2026
45 of 47 checks passed
@kriszyp
kriszyp deleted the kris/copy-progress-watchdog branch June 25, 2026 18:36
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.

1 participant