Repository navigation
fix(replication): full-copy when a source has no resume cursor (#426) - #428
Conversation
#426) A follower receiving a source's writes relayed through a bridge (a proxied / non-leader subscription) has no top-level resume cursor for that source, so on reconnect the subscription build falls into the "starting from scratch" branch. That branch resumed a non-leader source from `Date.now() - 60000`, silently claiming the follower already holds everything older than a minute. When the follower is actually behind on records now older than that window (it missed them while the bridge was down), they are never re-requested and never delivered — permanent, silent divergence even though the follower reconnects cleanly and reports connected:true. Request a full copy (startTime 0) instead whenever no resume cursor resolves. A full copy is idempotent and the leader collapses redundant requests via the per-connection min(startTime); an occasional extra copy is acceptable versus silent data loss. Adds integrationTests/cluster/proxiedResumeBacklog.test.mjs, a deterministic regression (fails before this change, passes after): a follower misses a batch while its bridge is down, the batch ages past the 60s window, the bridge returns, and the follower must still converge. Also instruments the rapid-reconnect-adversity stress test (the original detector) with per-poll convergence logging, the last-victim index, and an on-divergence cluster_status dump so a future failure is diagnosable. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Warning You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again! |
|
Reviewed; no blockers found. |
…e-decision check, not a data-loss guard (#426) Empirically, this 3-node L→B→M test cannot reproduce the #426 data loss: M tracks a real DIRECT cursor to its bridge B (keyed on the connection node, not the relayed origin L), so even on the pre-fix now-60s path M re-acquires the aged backlog through that direct cursor and converges. Verified by running against the unfixed dist — convergence passes; only the resume-decision log line differs. Correct the misleading header/inline comments (M is NOT cursorless here) and swap the assertion labeling: the full-copy resume-DECISION log check is the real discriminator in this topology, not the record count. Point to the 4-node backlogRecovery.test.mjs as the genuine data-loss guard. Comments only — no behavior change; test passes against the fixed dist. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Scope correction — keep open, but this does not fix the #426 stress failuresFollow-up to the verification work on #426. Re-labeling this PR's scope based on an empirical result: I built this branch into a local dist and ran So the accurate framing for this PR:
Recommend keeping it open as a correct-but-narrow hardening, decoupled from the #426 stress failures. No code change requested here — just aligning the claims with what we can show. — Claude (Opus 4.8) |
…426) Backlog recovery could freeze permanently during a retention-forced base copy. The receive loop ws.pause()s when the apply queue crosses RECEIVE_EVENT_HIGH_WATER_MARK; ws.pause() halts ALL socket reads, including the in-flight BLOB_CHUNK frames. Copy-mode onCommit still blocked on `await Promise.all(outstandingBlobsToFinish)` — waiting on exactly those paused blobs. Circular wait: queue never drains, socket never resumes, follower stuck with a partial copy. Reconnect-proof. The non-copy path already avoids this via an async durable watermark (#1252/#428); copy mode was deliberately left synchronous on the (wrong) assumption only catch-up could deadlock. Generalize the async watermark to copy mode: - onCommit no longer awaits blobs for copy frames. It stages the key-based copy cursor (pendingCopyCursor) and advances the durable watermark only when nothing is in flight and there is no gap. - A new flushDurableCopyCursor() persists the staged copy cursor and finishes the copy, gated on no-outstanding-blob + no-gap. It runs from the same durable-advance points as lastDurableSequenceId: onCommit (when in-flight is empty) and the blob save `.finally` (when the last blob drains). - maybeFinishCopy() now also requires blob durability (no in-flight blob, no held gap) before removing the copy cursor / exiting copy mode, so a crash can never resume past a not-yet-durable copied blob. - COPY_START clears any stale staged cursor. The no-data-loss invariant is preserved: the copy cursor still only advances to a key whose blob (and all earlier blobs) is durable — that durability is now achieved by deferring the cursor write to blob completion instead of by blocking the apply loop. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
) * test(cluster): deterministic relayed-origin resume gap repro (harper-pro#432) Forces, on every run, the condition only the intermittent 4-node backlogRecovery stress test ever caught: a target that holds an origin's rows delivered only through a relay, and so has no resume cursor for that origin, loses the [T, head] backlog written while the relay was dead when its later direct subscription to the origin resolves cursorless. Chain O <- R <- T plus witness W on O, `databases: ['data']` so the system database cannot re-mesh the chain. R is SIGKILLed, O writes the backlog, it ages past the pre-#428 resume floor, and T adds O directly as a non-leader. Green asserts convergence on all four nodes plus the resume decision in T's log (`start time: 1` for O, then the #428 full-copy request). With HARPER_TEST_DISABLE_CURSORLESS_FULL_COPY=1 forwarded to T, the same scenario asserts the loss signature: a live T<-O link, no phase-2 row on T, and T frozen below every peer even after R returns. The hook (`cursorlessResumeStartForTest`) restores the pre-#428 `now - 60s` start for a non-leader cursorless source; it is env-gated, default-off, and unit-covered. Refs #432 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BoGMRMSjqsUHVetXqfrzVL * test(cluster): pin the resume hook off on control nodes, kill R without grace Every Harper child inherits the runner's environment, so red-proof mode was enabling HARPER_TEST_DISABLE_CURSORLESS_FULL_COPY on O, R and W as well as T. The hook is now set explicitly per node ('0' on every control node, '1' only on T), and each control node's log is asserted to carry no legacy-resume line. R is killed with graceMs: 0, and the header no longer claims a plain SIGKILL. Comments trimmed to the why. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BoGMRMSjqsUHVetXqfrzVL * test(cluster): keep the full-copy start inline, await W before the red-proof snapshot The hook now only supplies an override (undefined when unarmed), so the production `startTime = 0` no longer flows through a *ForTest helper that a hook sweep could delete. The red-proof waits for O and W to hold the live write before snapshotting the frozen counts, instead of assuming W's independent socket delivered it as fast as T's. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BoGMRMSjqsUHVetXqfrzVL * test(cluster): name the relay's post-restart catch-up without pinning its mechanism After a zero-grace kill R may resume from its persisted O cursor or full-copy cursorless; the wait description no longer claims the former. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BoGMRMSjqsUHVetXqfrzVL --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…pro#432) Forces, on every run, the condition only the intermittent 4-node backlogRecovery stress test ever caught: a target that holds an origin's rows delivered only through a relay, and so has no resume cursor for that origin, loses the [T, head] backlog written while the relay was dead when its later direct subscription to the origin resolves cursorless. Chain O <- R <- T plus witness W on O, `databases: ['data']` so the system database cannot re-mesh the chain. R is SIGKILLed, O writes the backlog, it ages past the pre-#428 resume floor, and T adds O directly as a non-leader. Green asserts convergence on all four nodes plus the resume decision in T's log (`start time: 1` for O, then the #428 full-copy request). With HARPER_TEST_DISABLE_CURSORLESS_FULL_COPY=1 forwarded to T, the same scenario asserts the loss signature: a live T<-O link, no phase-2 row on T, and T frozen below every peer even after R returns. The hook (`cursorlessResumeStartForTest`) restores the pre-#428 `now - 60s` start for a non-leader cursorless source; it is env-gated, default-off, and unit-covered. Refs #432 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BoGMRMSjqsUHVetXqfrzVL
Fixes the confirmed, common data-loss in #426. Opened as a draft / interim because cross-model review surfaced a foundational interaction (below) that a proper fix must also address — I'm taking that on as a follow-up.
The bug (#426)
A follower receiving a source's writes relayed through a bridge (a proxied / non-leader subscription) has no top-level resume cursor for that source. On reconnect the subscription build fell into the "starting from scratch" branch, which resumed a non-leader source from
Date.now() - 60000— silently claiming the follower already holds everything older than a minute. When the follower is actually behind on records now older than that window (it missed them while the bridge was down), they are never re-requested and never delivered — permanent, silent divergence even though the follower reconnects cleanly and reportsconnected: true.Reproduced deterministically and (originally) via the
rapid-reconnect-adversitystress test ([500,499,304], 39% drift).The fix
replication/replicationConnection.ts: when no resume cursor resolves (startTime === 1), request a full copy (startTime = 0) instead ofnow - 60000. A full copy is idempotent and the leader collapses redundant requests via the per-connectionmin(startTime); an occasional extra copy is acceptable versus silent data loss.Tests
integrationTests/cluster/proxiedResumeBacklog.test.mjs— deterministic regression. A follower misses a batch while its bridge is down, the batch ages past the 60s window, the bridge returns, and the follower must still converge. Fails before this change, passes after.rapid-reconnect-adversitystress test (the original detector) now logs per-poll convergence, the last-victim index, and an on-divergencecluster_statusdump so a future failure is diagnosable.proxiedLeadingDuplicateSkip,leadingDuplicateSkip,addNodeFullCopy.Requesting a full copy for one cursorless source collapses the whole WebSocket to a base-copy via the leader's
currentSequenceId = min(startTime). Base-copy is additive — it ships only the leader's liveprimaryStorerows and resumes audit replay fromcopyStartTime— so it does not reconcile deletes:copyStartTimecan be left with a stale record (Codex flagged this; reachable only in multi-path mesh topologies, not the common bridge case — I could not construct a deterministic repro).Both are refinements of a pre-existing base-copy property (base-copy onto a non-empty follower never reconciled deletes — already true for fresh / predates-retention copies). This change does not introduce that property, but it does exercise it more often. So this fix is a net improvement for the common insert-loss case, not a strict improvement for delete-heavy workloads in rare topologies.
Follow-up (in progress): the proper fix — per-source post-copy resume (don't collapse a cursored co-subscribed source to
copyStartTime) and/or base-copy delete-reconciliation. Tracked on #426.Closes nothing yet — keep #426 open for the foundational follow-up.
🤖 Generated with Claude Code