mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-27 23:40:41 +01:00
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>
461 lines
18 KiB
Markdown
461 lines
18 KiB
Markdown
# 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
|