Repository navigation
fix(replication): recover ping-alive copy-stall wedges via a copy-progress watchdog (#453) - #454
Conversation
There was a problem hiding this comment.
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.
|
Reviewed; no blockers found. |
86940a8 to
1a6a8ba
Compare
1a6a8ba to
0a9ba18
Compare
552b606 to
b09ec9b
Compare
…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>
…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>
b09ec9b to
55b52cd
Compare
…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>
55b52cd to
edce532
Compare
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
systembase copy and a follower's receive subscription settledconnected:true/lastReceivedStatus:"Receiving"with the copy frozen at version 0 — permanently. Because live audit replay (which carries newhdb_deploymentrows) only starts afterCOPY_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:findWedgedNodeUrls(the Replication wedges permanently after simultaneous cluster restart (reconciler skips open-but-idle sockets) → blocks replicated deploys #420/fix(replication): recover open-but-idle wedged subscriptions via watchdog-driven reconnect #424 wedge-reconcile) only re-drivesconnected:falseentries.receiveWatchdognever fires because keepalive pings keepws._socket.bytesReadadvancing.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-independentforceReconnectthe byte watchdog uses, which restarts the copy from the leader. It's armed onCOPY_STARTand 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.mjsreproduces the wedge deterministically via a one-shot, env-gated sender stall (maybeStallCopyForTest) that freezes the copy right afterCOPY_STARTwhile 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
REPLICATION_BLOBTIMEOUT(default 120s) — the existing "tolerate a stalled replication transfer" knob. This is the a customer cluster preprod 5.1.7: replicated deploys wedge after rolling upgrade — stalled system blob send (#450/harper#1443) blocks COPY_COMPLETE; fixes absent from 5.1.7 (needs 5.1.8) #453 frame-keyed watchdog and is separate from the newREPLICATION_COPYTIMEOUT(b) above, which governs the byte-level idle watchdog during copy. Worth a sanity check that the two thresholds (120s frame-stall vs 300s byte-idle) read sensibly together.forceReconnect→ restart copy. With the system base-copy gated on huge hdb_analytics (analytics.replicate:true) blocks hdb_deployment convergence → deploys fail after any copy #421 cross-version cursor a reconnect restarts the copy from scratch; it converges once one attempt completes uninterrupted. The watchdog gives it repeated clean attempts rather than silently waiting forever.maybeStallCopyForTestmirrors the existingarmReplicationWedgeForTestpattern (env-gated, one-shot, never arms in production).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 alsoforceReconnects 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
findWedgedNodeUrlsalso re-drive a subscription stuckconnected: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-drivingconnected:trueentries 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: thereset()throttle already short-circuits on!timerso 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 touchsendBlobor the blob-deadlock paths at all.)