Skip to content

fix(replication): full-copy when a source has no resume cursor (#426) - #428

Merged
kriszyp merged 2 commits into
mainfrom
kris/426-proxied-resume-backlog
Jun 22, 2026
Merged

kriszyp merged 2 commits into
mainfrom
kris/426-proxied-resume-backlog

Conversation

@kriszyp

@kriszyp kriszyp commented Jun 19, 2026

Copy link
Copy Markdown
Member

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 reports connected: true.

Reproduced deterministically and (originally) via the rapid-reconnect-adversity stress 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 of now - 60000. 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.

Tests

  • New: 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.
  • Instrumentation: the rapid-reconnect-adversity stress test (the original detector) now logs per-poll convergence, the last-victim index, and an on-divergence cluster_status dump so a future failure is diagnosable.
  • All related replication tests pass: proxiedLeadingDuplicateSkip, leadingDuplicateSkip, addNodeFullCopy.

⚠️ Known limitation — why this is a draft

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 live primaryStore rows and resumes audit replay from copyStartTime — so it does not reconcile deletes:

  • A co-subscribed source that had a valid cursor but missed a delete before copyStartTime can 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).
  • A cursorless source's own pre-copy deletes onto partial stale data are likewise not reconciled.

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

#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>
@gemini-code-assist

Copy link
Copy Markdown

Warning

You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again!

@claude

claude Bot commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Reviewed; no blockers found.

@kriszyp
kriszyp marked this pull request as ready for review June 20, 2026 17:44
@kriszyp
kriszyp requested a review from a team as a code owner June 20, 2026 17:44
@kriszyp kriszyp changed the title fix(replication): full-copy when a source has no resume cursor (#426) [interim] fix(replication): full-copy when a source has no resume cursor (#426) Jun 20, 2026
…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>
@kriszyp

kriszyp commented Jun 21, 2026

Copy link
Copy Markdown
Member Author

Scope correction — keep open, but this does not fix the #426 stress failures

Follow-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 backlog-recovery (CI-faithful OFFLINE=30 / KEYS=800) with the fix applied — it still froze ({"A":800,"B":350,"C":800,"D":800}, gap 450, first run). That failure is bug (b) — the send-side stale-cursor freeze (leader never re-streams [T, head]) — not the cursorless → now-60s path this PR changes. Details + root-cause lead in #426.

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)

@kriszyp
kriszyp merged commit ebd705d into main Jun 22, 2026
31 checks passed
@kriszyp
kriszyp deleted the kris/426-proxied-resume-backlog branch June 22, 2026 13:18
kriszyp added a commit that referenced this pull request Jun 22, 2026
…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>
kriszyp added a commit that referenced this pull request Sep 2, 2026
)

* 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>
github-actions Bot pushed a commit that referenced this pull request Sep 2, 2026
…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
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