Files
seaweedfs/sw-block/design/v2-sync-recovery-protocol.md
T
pingqiuandClaude Opus 4.6 59a36013d4 feat: rebuild hardening A1-A5 + session-controlled execution path
A1 Engine kind-routing fix:
  SessionProgressObserved/Completed/Failed now respect active session
  Kind. Rebuild progress no longer leaks into catch-up aggregate.
  sessionKindMismatch guard + observeRebuildProgress helper.
  2 regression tests lock kind isolation.

A2 Retention pin:
  Rebuild session ack drives progress-based WAL retention floor.
  Pin installed at base_lsn on accepted, advances with wal_applied_lsn,
  released on completed/failed/cancelled. rebuildProgressPinFloor
  returns min across all active replicas.
  Retention pin test: 100 blocks fill WAL, 5 flusher cycles with
  20 pinned rebuild entries — all verified correct.

A3 Progress ack emission:
  Automatic sessionAck(running/base_complete/completed/failed) emitted
  from rebuild session lifecycle transitions. sessionAckLocked builds
  ack under session lock. emitRebuildSessionAck callback wired through
  SetOnRebuildSessionAck on BlockVol.
  ObserveReplicaRebuildSessionAck maps acks to core engine events.
  WireLocalReplicaRebuildSessionAcks bridges local callback to server.
  5 server tests proving ack→core, pin advance, pin cleanup.

A4 Deadline/timeout:
  rebuildAckWatch watchdog: armed on accepted/running/base_complete,
  refreshed on each ack, cleared on completed/failed. Timeout
  cancels local session + clears pin + fail-closes.
  2 tests: timeout→fail-close, progress→refresh.

A5 Session-controlled execution path:
  v2bridge.Executor.TransferFullBase now uses session-controlled loop:
  beginControlledFullBase → real sessionControl over TCP →
  transferExtentToSession via RebuildTransportClient →
  PrepareFullBaseRebuild → TryCompleteRebuildSession.
  ReplicaReceiver control channel handles MsgSessionControl alongside
  MsgBarrierReq. Session acks written back on same TCP connection.
  RebuildSessionBase request type separates new per-block stream from
  legacy raw extent stream. Full-base cleanup deferred until success.
  Deadlock fix: ApplyBaseBlock releases session lock before ioMu.
  Hydration skip for full-base sessions.

23 rebuild component tests (all pass):
  11 kernel correctness, 8 transport/runtime, 3 scenario-scale,
  including 1GB primary-initiated with CRC validation.

29 files changed, ~2500 insertions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 14:39:11 -07:00

18 KiB

V2 Sync / Recovery Protocol

Date: 2026-04-07 Status: active design

Purpose

Define the complete replication protocol for sw-block V2. This document covers sync, keepup, catch-up, and rebuild as one unified protocol so the sw agent has full context for implementation.

v2-rebuild-mvp-session-protocol.md is the narrower first-slice spec. This document is the surrounding context and long-term design.

Design Principles

  1. Primary decides everything. Replica only reports facts and executes contracts. Replica never self-escalates to needs_rebuild.

  2. One threshold. applied_lsn >= primary_wal_tail → WAL-recoverable. Otherwise → rebuild. Matches Ceph's last_update >= log_tail.

  3. Deterministic engine. Event in → state + commands + projection out. No side effects inside the engine. Host executes commands.

  4. Three authority layers:

    • Assignment: master → identity (who is primary/replica, epoch, replica set)
    • Session: primary → per-replica recovery contract (keepup/catchup/rebuild)
    • Projection: primary → derived volume mode/health
  5. Failure never auto-escalates. A failed session stays failed. The primary re-decides from fresh syncAck facts. Only the primary can issue a rebuild.

Reference Systems

System Catch-up Rebuild Decision owner Decision input
Ceph PG log replay Full backfill Primary OSD (peering) last_update >= log_tail
Mayastor None Segment copy Control plane Child sync state
Longhorn None Snapshot file sync Controller Revision counters
sw-block V2 WAL replay Flushed extent + live WAL (two-line) Primary applied_lsn >= wal_tail

Protocol Overview

Normal Operation (keepup)

Primary                          Replica
  │                                │
  ├─ WriteLBA ────────────────────►│ (live WAL shipping via ShipAll)
  │                                │ apply to local WAL
  │                                │
  ├─ sync(target_lsn=N) ─────────►│
  │◄─ syncAck(durable=N, applied=N)│
  │                                │
  │  decision: quorum → keepup     │
  │  derive: publish_healthy       │

Catch-up (replica behind but within retained WAL)

Primary                          Replica
  │                                │
  ├─ sync(target_lsn=1000) ──────►│
  │◄─ syncAck(applied=500)         │
  │                                │
  │  decision: 500 >= wal_tail(100)│
  │  → WAL catch-up               │
  │                                │
  ├─ sessionControl(start_catchup  │
  │    start=500, target=1000,     │
  │    pin=500) ─────────────────►│
  │                                │
  │  LINE 1: WAL replay [500..1000]│
  ├─ walReplay(lsn=501...) ──────►│ apply, pin advances
  │                                │
  │  LINE 2: live WAL from 1001+   │
  ├─ walData(lsn=1001...) ───────►│ apply to local WAL
  │                                │
  │◄─ sessionAck(completed,        │
  │    achieved=1050) ─────────────│
  │                                │
  │  replica back in keepup        │

Rebuild (replica beyond retained WAL, or fresh join)

Primary                          Replica
  │                                │
  ├─ sync(target_lsn=5000) ──────►│
  │◄─ syncAck(applied=0)           │
  │                                │
  │  decision: 0 < wal_tail(2000)  │
  │  → rebuild                    │
  │                                │
  ├─ sessionControl(start_rebuild  │
  │    base_lsn=flushedLSN,       │
  │    base_kind=flushed_extent) ─►│
  │                                │
  │  LINE 1: flushed extent blocks │
  ├─ sessionData(chunk...) ──────►│ apply if bitmap clear
  │                                │
  │  LINE 2: live WAL from base_lsn+1
  ├─ walData(lsn=5001...) ───────►│ apply, set bitmap bit
  │                                │
  │  Bitmap: WAL-applied LBA wins  │
  │  over later base block         │
  │                                │
  │◄─ sessionAck(base_complete)    │
  │◄─ sessionAck(completed,        │
  │    achieved=5200) ─────────────│
  │                                │
  │  replica back in keepup        │

Primary Decision Logic

func decide(ack SyncAck, walTail, walHead uint64) SessionKind {
    replicaPos := max(ack.AppliedLSN, ack.DurableLSN)

    if replicaPos >= walHead && replicaPos > 0:
        return keepup       // fully caught up

    if replicaPos >= walTail && replicaPos > 0:
        return catchup      // behind but within retained WAL

    if replicaPos == 0 && walTail <= 1:
        return catchup      // fresh replica, WAL retained from beginning

    return rebuild          // gap exceeds retained WAL
}

This is one function, one threshold. Matches Ceph's last_update >= log_tail.

Normalized Primary-side Sync Facts

The wire protocol still uses sync and syncAck as the bounded control exchange. But inside the primary-owned host/runtime layer, raw wire results and local control-path observations may be normalized into one small fact vocabulary before the primary re-decides the next step.

This normalization is not a new replica-visible message family. It is the primary-owned semantic shape for "what kind of sync fact just arrived?"

Current normalized fact kinds:

Kind Meaning Typical sources
sync_quorum_acked normal sync closure reached wire syncAck(ack_kind=quorum), accepted barrier
sync_quorum_timed_out control-plane sync closure timed out wire syncAck(ack_kind=timed_out), rejected barrier
sync_replay_required replica is behind but replay-recoverable fresh planner classification after sync facts
sync_rebuild_required replica is outside retained replay coverage fresh planner classification after sync facts
sync_replay_failed a replay/catch-up attempt failed catch-up failure callback, replay execution failure

Rules:

  1. normalized sync facts are still facts only, never session recommendations
  2. different producers may map to the same normalized fact kind
  3. only the primary may turn these facts into keepup, catchup, or rebuild
  4. this is the bridge between raw protocol input and primary-owned session authority

Two-Line Recovery Model

Both catch-up and rebuild use two concurrent data lines:

Catch-up: WAL replay + live WAL

  • Line 1: replay retained WAL entries from pin_lsn to target_lsn
  • Line 2: forward live WAL entries from target_lsn+1 onward
  • Pin movement: as replay cursor advances, pin can advance (releases old WAL)
  • No bitmap needed: WAL entries are strictly ordered by LSN, no LBA conflict
  • Completion: replay cursor reaches target → lines merge → keepup

Rebuild: flushed extent base + live WAL

  • Line 1: copy the primary's flushed extent image at base_lsn
  • Line 2: forward live WAL entries newer than base_lsn
  • Bitmap required: base blocks and WAL entries may target the same LBA
  • Bitmap rule: bit set on WAL applied (not received). Base block skipped if bit set.
  • Completion: all base blocks transferred AND wal_applied_lsn >= target_lsn
  • Base boundary rule: base_lsn is a flushed/checkpoint boundary, not a merely committed boundary

Why two lines instead of sequential (base → then catch-up)

Sequential model:

  1. Copy entire flushed extent image
  2. Then replay WAL from base LSN to current
  3. Problem: must pin WAL for duration of snapshot copy (hours for large volumes)
  4. Risk: WAL recycled before replay starts → must restart entire rebuild

Two-line model:

  1. Copy flushed extent AND receive live WAL simultaneously
  2. WAL pin pressure = only gap between current replay and live head (small)
  3. If snapshot copy is slow, WAL line keeps replica current
  4. Crash recovery is safe at any point (bitmap + local WAL)

Bitmap Rules (Rebuild Only)

When to set bit

Set bitmap bit when WAL entry is applied to replica's local WAL:

  • Entry has been written to local WAL file
  • Entry is replayable after crash
  • Does NOT require flush to final extent

When NOT to set bit

Do not set on:

  • Network receive (TCP buffer)
  • Queue but not yet local WAL write

Conflict resolution

When base lane sends a chunk for LBA range:

  • Bitmap clear → write base data
  • Bitmap set → skip (WAL-applied data is newer)

Short form: WAL always wins over base.

Crash safety

At any crash point:

  • Bitmap can be volatile (session-local, in memory)
  • Local WAL is durable → replay recovers all applied entries
  • After crash: fresh sync → primary re-decides → new session if needed
  • The bitmap itself need not persist, but its protected coverage must be re-hydrated from local durable WAL before a new rebuild session opens the base lane
  • No need to persist bitmap across crashes in MVP

Current bounded claim:

  • crash during rebuild is handled by restart rebuild
  • this is not yet a claim of resumable rebuild with durable base_progress
  • hydration of bitmap coverage protects durable WAL facts during the fresh rebuild; it does not by itself resume prior base-copy progress

Issue #3 restart hydration rule

To close the volatile-bitmap restart hole:

  1. a fresh rebuild session must rebuild bitmap coverage from local durable WAL newer than base_lsn before accepted becomes externally visible
  2. the base lane must remain closed until this hydration completes
  3. if the replica's local durable base is already newer than the claimed base_lsn, startup must fail closed instead of accepting a stale rebuild
  4. this is why rebuild starts from a flushed/checkpoint boundary rather than a merely committed boundary: committed data may still live only in WAL, so it is not a safe direct-base image

Replica State Machine

idle → accepted → running → base_complete → completed
                    │              │
                    └──► failed ◄──┘
  • idle: no active session
  • accepted: session contract valid, epoch/session_id accepted
  • running: both base lane and WAL lane active
  • base_complete: base transfer done, WAL lane still running
  • completed: replica reports achieved_lsn >= target_lsn
  • failed: session stopped without completion, reason reported

Failure does NOT auto-escalate. Primary re-decides from next syncAck.

Mode Derivation (Projection)

Primary derives volume mode from all replica states + boundaries:

any replica in rebuild session     → needs_rebuild
any replica session failed         → degraded
any replica in catch-up session    → bootstrap_pending
no replicas assigned               → allocated_only
replica role + receiver ready      → replica_ready
primary + all readiness + durable  → publish_healthy
assigned but not ready             → bootstrap_pending

Failure Handling

Principle

No failure auto-escalates. All failures go through:

  1. Session marked failed with reason
  2. Primary waits for next syncAck from replica
  3. Primary re-decides based on fresh facts

Primary-side normalized reading:

  1. failure may first surface as sync_replay_failed
  2. fresh sync/planner facts may then normalize to either:
    • sync_replay_required
    • sync_rebuild_required
  3. only then does the primary issue the next session contract

Failure scenarios

Scenario Replica does Primary does
Transport lost during session Reports failed(transport_lost) or goes silent Marks session failed, waits for reconnect
Replica crash Restarts, recovers local WAL, hydrates bitmap coverage before any new rebuild base lane opens, then reports facts via syncAck Re-decides: if applied_lsn >= wal_tail → catch-up, else → new rebuild; current claim is fresh restart, not resume of prior base-copy offset
Primary crash Nothing (waits for new primary) New primary elected, fresh epoch, all replicas report via syncAck
Slow progress / timeout Continues trying Can cancel session via cancel_session, then re-decide
WAL recycled during outage Reports applied_lsn which is now < wal_tail Decides rebuild (gap exceeds retained WAL)

Failure reason vocabulary (stable)

  • epoch_mismatch
  • transport_lost
  • progress_stalled
  • deadline_exceeded
  • pin_lost
  • snapshot_unavailable
  • local_wal_corrupt
  • local_extent_corrupt

Catch-up Details (Post-MVP)

Catch-up uses the same session contract shape as rebuild, but without base copy:

sessionControl {
    op: start_catchup
    start_lsn: <replica_applied_lsn>
    target_lsn: <primary_wal_head>
    pin_lsn: <replica_applied_lsn>
}

Replica receives:

  • WAL replay entries [start_lsn .. target_lsn] (line 1)
  • Live WAL entries [target_lsn+1 ..] (line 2)

No bitmap needed because WAL entries are ordered by LSN — no LBA conflict between replay and live.

Pin advances as replay cursor moves forward, releasing old WAL entries.

Future: Range Bitmap / Delta Rebuild

If primary maintains persistent per-checkpoint dirty block tracking:

modified_blocks[checkpoint_lsn_range] → set of dirty LBAs

Then rebuild can skip copying blocks that haven't changed since the replica's last known position. Only modified blocks need to be sent.

This turns rebuild from O(volume_size) to O(changed_blocks), similar to VMware CBTT (Changed Block Tracking) or DRBD activity log.

Implementation: persist DirtyMap snapshot at each flusher checkpoint along with the checkpoint LSN. On rebuild, union of DirtyMap snapshots from replica_applied_lsn to current_checkpoint = minimal copy set.

Future: Resumable Rebuild

Current V2/MVP does not claim resumable rebuild. After crash during rebuild, the protocol restarts from fresh sync and a fresh rebuild session.

A future resumable rebuild path may be added only with explicit durable state:

  1. durable base_progress
  2. durable proof of replica-side applied WAL coverage
  3. primary-side proof that historical change coverage remains reconstructible through retained WAL or primary-owned CBT / changed-block history
  4. a live delta channel for writes that arrive after resume begins

Without those conditions, resume is not a safe current claim.

Component Test Requirements

All tests use real BlockVol with real WAL and extent data. No mocks for storage. Network can be in-process (localhost TCP or direct function call).

Protocol tests (unit, engine only)

  1. syncAck returns facts only, never action
  2. sessionControl(start_rebuild) rejected on epoch mismatch
  3. New session_id supersedes old session
  4. Primary decision: applied >= wal_tail → catch-up
  5. Primary decision: applied < wal_tail → rebuild
  6. Primary decision: applied >= wal_head → keepup
  7. Session failure does not auto-escalate to needs_rebuild
  8. Session failure then fresh syncAck → primary re-decides

Rebuild correctness (component, real BlockVol)

These tests create real primary + replica volumes with actual WAL and extent data:

  1. Two-line convergence: primary writes N blocks, records a flushed base_lsn, starts rebuild session. Base lane copies extent data from that flushed boundary. WAL lane ships live entries. Verify replica has all N blocks correct at end.

  2. WAL wins over base: primary writes block A=1, flushes base, then writes A=2. Rebuild sends base (A=1) and WAL (A=2). Verify replica has A=2 (WAL-applied wins).

  3. Bitmap on applied not received: ship WAL entry but delay local apply. Send base block for same LBA. Base block should land (bitmap not set yet). Then apply WAL entry. Bitmap now set. Final state should be WAL data.

  4. Large rebuild: 1000+ blocks, concurrent base + WAL, verify all blocks correct at completion.

  5. Writes during rebuild: primary continues writing new blocks during rebuild. Verify new blocks reach replica via WAL lane and are preserved at rebuild completion.

Crash / failure (component, real BlockVol)

  1. Crash after WAL receive before apply: restart replica, verify base can still cover the LBA (bitmap was not set).

  2. Crash after WAL apply: restart replica, verify local WAL replay recovers the applied data correctly.

  3. Transport lost during rebuild: session reports failed(transport_lost). Primary re-decides from fresh syncAck. New rebuild session starts.

  4. Rebuild completion does not restore quorum until primary accepts: verify replica reports completed but mode stays needs_rebuild until primary processes the completion event.

Session lifecycle (unit, engine only)

  1. idle → accepted → running → completed → keepup
  2. idle → accepted → running → failed → (syncAck) → re-decide
  3. running → cancel → idle
  4. Session supersede: new session_id replaces old

End-to-end (integration, hardware — after component tests pass)

  1. Fresh replica join on m01/m02: create RF=2, kill replica VS, restart, verify rebuild completes and volume returns to publish_healthy.

  2. Sustained I/O during rebuild: fio running on primary while rebuild progresses. Verify data continuity after rebuild completion.

Implementation Order

  1. Protocol engine (sw-block/protocol/) — already started, 7 events, 398 lines
  2. Rebuild session on blockvol layer — two-line model, bitmap, completion
  3. Session control wiring on volume server — sessionControl/sessionAck
  4. Flushed extent base transfer — extent read + chunk send
  5. Component tests against real BlockVol
  6. Integration test on hardware