Skip to content

Replication W2: Cursor correctness & divergence detection #432

Description

@kriszyp

Workstream W2 of #430 · Foundation · the silent-data-loss safety net

Status update (2026-07-01)

Design direction 1 (explicit error classification) is now substantially shipped as point fixes, and the current-state text below is stale on the details:

What remains is exactly the two items this workstream predicted, and both have live field evidence:

  1. Bounded-retry escalation budget — still missing. 503 is deliberately classified transient with no budget, so a permanently-wedged "transient" source pins the cursor forever. Observed live as the v4→v5 circular-503 wedge (idle-watchdog writes its own PENDING stub → its reads 503 → held forever). The fix shape: count identical-error reconnects per blob, escalate, then advance-and-mark-diverged after a bounded budget — the sweep (Proactive blob backfill: repair already-committed records whose blobs are missing/corrupt (no recovery once the resume cursor advances past them) #388/blobRepair.ts) is the repair loop that makes advance-past safe.
  2. Receive-side sequence-gap detection — still completely absent. Still the highest-value missing safety net.

Summary

Convergence rests on a single per-(db,node) resume cursor whose advance is gated by hand-rolled, narrow error classification. It's a trusted watermark, not a checked one, and it breaks two opposite ways — over-holding (wedge forever) and silent gaps (undetected data loss). There is no receive-side sequence-gap detection, which is the single highest-value missing safety net in replication.

Root cause / current state (see status update for what has since shipped)

  • Over-holding. Any blob/apply error not on the permanent allow-list holds the cursor, trusting that "a reconnect will re-stream." The allow-list has broadened (ENOENT/404/500), but for an error stuck in the transient class (503) the reconnect reproduces the identical error → the cursor is pinned forever. There is no retry budget.
  • Silent gaps. A follower correctly resumes from T, but the leader's per-subscriber send-position diverges and only re-sends the head, so the [T, head] window is never re-sent. Nothing detects the hole because the live tail keeps arriving and connected:true holds (rapid-reconnect stress: follower permanently loses replication backlog after restart churn (data loss; fast-skip ruled out) #426 — observed as ~39% silent data loss).

Design direction

  1. Total, explicit error classification. Every blob/apply error carries a stable .code; classification is a table with a safe default plus a bounded-retry budget. An error that holds the cursor is counted, escalated after N reconnects, and after a bounded number of identical-error reconnects advances-and-marks-diverged rather than holding forever. (Largely shipped — the remaining piece is the bounded-retry budget for the transient class.)
  2. Receive-side sequence-gap detection. Track expected vs received sequence ranges so a [T, head] hole is detected and surfaced (feeds the W8 divergence alarm) instead of masked by live tail traffic.
  3. Reconciliation / backfill pass (Proactive blob backfill: repair already-committed records whose blobs are missing/corrupt (no recovery once the resume cursor advances past them) #388). (Shipped — blobRepair.ts.) Extend it to close the loop behind any new advance-past classes from item 1.

Scope

Retires / advances

Dependencies

Pairs naturally with W1 (which provides the surfacing channel) and W8 (which raises the alarm). The send-side residual pairs with W4.

Effort / risk

M / medium (down from L — classification and backfill shipped; the budget + gap detection remain).

Acceptance criteria

  • A permanently bad blob advances-and-flags after a bounded number of retries (no eternal wedge), including one misclassified as transient (503).
  • A [T, head] gap raises a detectable signal rather than silently persisting.
  • A diverged record/blob past the cursor is repairable.

🤖 Filed by Claude on behalf of Kris.

Activity

  1. added this to the v5.2 milestone on Jun 20, 2026
  2. kriszyp commented on Jun 21, 2026

    @kriszyp
    MemberAuthor

    Suggested acceptance test: a deterministic 4-node "cursorless relayed-origin" divergence repro

    While investigating #426 I confirmed a gap worth capturing as a W2 deliverable: we currently have no deterministic test that reproduces the silent [T, head] gap. The existing regression (integrationTests/cluster/proxiedResumeBacklog.test.mjs) does not — I verified empirically that in its 3-node L→B→M line the follower tracks a real direct cursor to its bridge and re-acquires the backlog regardless of the resume decision, so it self-heals and convergence passes even on the buggy path. (I've corrected that test's misleading claims on the #428 branch — commit 36ecd92 — to label it a resume-decision/smoke check, not a data-loss guard.)

    The only thing that reproduces real loss today is the 4-node backlogRecovery stress test, and only intermittently (it depends on hub-emergence under churn). CI run 27897883840 caught it cleanly: node B frozen at {"A":800,"B":553,"C":800,"D":800}, with B holding a real cursor for peers A/C but start time: 1 (no cursor) for peer D — whose writes had reached B only relayed.

    Proposed W2 acceptance test

    A deterministic cluster test that forces the genuine condition, to serve as the gate for divergence-detection (and as a regression for #428):

    • ≥4 nodes where one origin's writes reach a target node only via a relay (no direct data txn on the target's direct connection to that origin), so the target's per-source cursor for that origin stays at 1.
    • The target is offline across a write window that ages past the non-leader resume floor (now - 60000), then returns with no co-subscription path that re-delivers the aged backlog (the masking path that makes the 3-node test self-heal).
    • Assert record-count convergence (real data), not a log line.

    The hard part is making "this origin is known only via relay" deterministic rather than waiting for emergent hub formation under churn — likely by shaping add_node le/relay topology so the target never opens a direct data subscription to that origin. If we can pin that, it becomes the canonical red-before/green-after test for:

    • W2 here — receive-side sequence-gap detection must catch and heal the [T, head] hole regardless of how the cursor got stuck.
    • W4 (Replication W4: Per-origin transaction-log convergence (keystone) #434) — per-origin cursors should make the start time: 1 state unreachable in the first place (the relayed origin's applied position is tracked, not the proxy's).

    Flagging it here so the divergence-detection work lands with a deterministic harness instead of leaning on the intermittent stress job. Happy to take a crack at the topology-shaping once the foundations are scoped.

    🤖 Filed by Claude on behalf of Kris.

  3. kriszyp commented on Jun 21, 2026

    @kriszyp
    MemberAuthor

    Scoping note: the #426 silent-gap that motivates this workstream is root-caused to HarperFast/rocksdb-js#668 (a committed-watermark pin from an IsBusy-retry orphaned position), fixed upstream in the storage engine. W2's receive-side gap-detection is still the right durable safety-net — it would have caught this class regardless of cause — but the specific #426 data loss is an engine bug, not a replication-protocol one.

  4. added theissue type on Aug 7, 2026
  5. self-assigned this
    on Sep 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area:replicationReplication, cluster sync, peer connectionsenhancementNew feature or request

Type

Fields

Priority

P1

Projects

No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions