mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-08-21 06:36:54 +00:00
V2 stabilization: 144/144 hardware actions PASS + design docs + SmartWAL prototype
Hardware scenarios (all PASS on m01/m02, 25Gbps RoCE): - I-V3 auto-failover: 43/43 (create→write→kill→promote→verify IO) - I-R8 rebuild-rejoin: 58/58 (failover→write→restart→1GB rebuild in 2s→verify data) - Fast rejoin: 43/43 (kill replica→3s→restart→recovery→data verified) Performance: V2 RF=1 = 46,666 IOPS vs V1.5 RF=1 = 47,233 IOPS (-1.2%, noise) New test scenarios: - v2-rebuild-rejoin.yaml: full failover→rebuild→second failover→data integrity - v2-fast-rejoin-catchup.yaml: replica kill→fast restart→recovery - v2-rebuild-failure-retry.yaml: kill during rebuild→restart→data verified - rf1-perf-compare.yaml: RF=1 perf baseline for V1.5 vs V2 comparison Design documents: - protocol-anti-patterns.md: 7 anti-patterns with cases from SeaweedFS/Ceph/DRBD - smartwal-design-memo.md: extent-first write algorithm research (BlueStore/ZFS/DRBD) - smartwal-prototype-spec.md: prototype spec with 16/16 crash tests PASS - v3-clean-recovery-draft.md: V3 semantic cleanup principles - v2-integration-matrix.md: 25-row integration coverage map - v2-acceptance-evidence.md: gap analysis for remaining work SmartWAL prototype (16/16 tests PASS): - smartwal.go, smartwal_record.go, smartwal_recovery.go: core implementation - smartwal_test.go: 9 single-node crash tests - smartwal_repl_test.go: 7 two-node replication crash tests Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
5279bd3945
commit
8ecc506452
@@ -0,0 +1,496 @@
|
||||
# Protocol Anti-Patterns in Distributed Storage Systems
|
||||
|
||||
Date: 2026-04-09
|
||||
Status: reference document
|
||||
|
||||
## Purpose
|
||||
|
||||
This document catalogs recurring protocol anti-patterns found in distributed
|
||||
storage systems. Each anti-pattern is defined, then illustrated with concrete
|
||||
cases from real codebases: SeaweedFS (upstream object store), SeaweedFS sw-block
|
||||
(block storage extension), Ceph RADOS, and DRBD.
|
||||
|
||||
The goal is to provide a reusable reference for protocol review and design
|
||||
decisions. When a new protocol change is proposed, check it against this list.
|
||||
|
||||
---
|
||||
|
||||
## A1: Heartbeat Timing Defines Recovery Semantics
|
||||
|
||||
**Definition**: A timer or heartbeat interval directly determines a semantic
|
||||
outcome (node death, volume reassignment, recovery type) without collecting
|
||||
independent facts.
|
||||
|
||||
**Why it is wrong**: Heartbeat is transport-layer liveness observation. It
|
||||
measures "when did I last hear from you," not "what is your data state." Using
|
||||
it as the sole input for semantic decisions conflates observation latency with
|
||||
data truth.
|
||||
|
||||
**Correct principle**: Timers trigger observation. Facts determine semantics.
|
||||
|
||||
### Case: SeaweedFS upstream — dead node detection
|
||||
|
||||
`topology/volume_location_list.go:69-86`
|
||||
|
||||
When `LastSeen < freshThreshHold` (hardcoded 60 seconds), the node is removed
|
||||
from volume location lists. When the heartbeat gRPC stream drops
|
||||
(`master_grpc_server.go:67-101`), the defer block immediately unregisters the
|
||||
node and marks all volumes unavailable.
|
||||
|
||||
A 60-second network hiccup causes immediate volume unavailability. No probe,
|
||||
no corroboration, no fact collection. One missed heartbeat cycle = topology
|
||||
change.
|
||||
|
||||
### Case: SeaweedFS sw-block — fast replica rejoin missed
|
||||
|
||||
When a replica restarts faster than the master's heartbeat detection (~5
|
||||
seconds), the master never detects the disconnect. No `failoverBlockVolumes`
|
||||
fires, no `recoverBlockVolumes` fires, no primary refresh is sent. The
|
||||
primary's shipper holds a dead TCP connection and the volume stays degraded
|
||||
indefinitely.
|
||||
|
||||
The system's recovery depends on the *timing* of the restart relative to the
|
||||
heartbeat cycle, not on the *fact* of the restart.
|
||||
|
||||
### Case: Ceph — OSD flapping storms
|
||||
|
||||
Early Ceph used OSD heartbeat timeout (20s default) as the trigger for PG
|
||||
peering. Network congestion caused heartbeat delays without actual OSD failure,
|
||||
leading to "flapping" — OSDs bouncing between up and down, triggering
|
||||
unnecessary recovery storms that made congestion worse.
|
||||
|
||||
Fix: Ceph separated heartbeat (liveness signal) from PG peering (data-plane
|
||||
fact collection). The monitor marks a node down, but PG peering independently
|
||||
collects `pg_info` from all OSDs before deciding recovery. The
|
||||
`osd_heartbeat_grace` and `mon_osd_min_down_reporters` tuning knobs are
|
||||
artifacts of this evolution.
|
||||
|
||||
### Case: DRBD — ping timeout triggers resync
|
||||
|
||||
DRBD's `ping-timeout` and `connect-int` timers originally drove state
|
||||
transitions directly. A slow network caused DRBD to declare the peer dead and
|
||||
start resync — but the peer was alive, just slow.
|
||||
|
||||
Fix: DRBD 8.4+ added fencing — you need an external fencing agent to confirm
|
||||
death before split-brain resolution proceeds. The timer triggers the check;
|
||||
the fencing agent provides the fact.
|
||||
|
||||
### Fix pattern
|
||||
|
||||
```
|
||||
Timer fires → trigger probe/observation
|
||||
Probe collects bounded facts (R, S, H, epoch, endpoint)
|
||||
Facts determine semantic action (no-op, catchup, rebuild, promote)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## A2: Transport Failure Directly Defines Recovery Type
|
||||
|
||||
**Definition**: A single transport-layer error (TCP reset, write timeout, dial
|
||||
failure) directly triggers a semantic state change (rebuild required, volume
|
||||
degraded, write failed) without distinguishing transient from permanent failure.
|
||||
|
||||
**Why it is wrong**: Transport failure and data-plane recovery are different
|
||||
truth domains. A TCP reset means "I cannot reach you right now," not "your data
|
||||
is divergent and you need a full rebuild."
|
||||
|
||||
**Correct principle**: Transport failure changes reachability truth. Recovery
|
||||
type requires bounded data-plane facts.
|
||||
|
||||
### Case: SeaweedFS upstream — one replica failure fails entire write
|
||||
|
||||
`topology/store_replicate.go:73-137`
|
||||
|
||||
`DistributedOperation` fans out writes to all replicas in parallel. If ANY
|
||||
replica returns an error, the entire write fails and returns error to the
|
||||
client. No distinction between:
|
||||
- Transient network hiccup (retry would succeed)
|
||||
- Replica disk full (permanent, needs different handling)
|
||||
- Replica truly unreachable (degrade and continue)
|
||||
|
||||
A single TCP error from one replica out of three fails the write for the
|
||||
client.
|
||||
|
||||
### Case: SeaweedFS sw-block — Ship() 3s deadline causes NeedsRebuild
|
||||
|
||||
The WAL shipper's `Ship()` sets a 3-second write deadline on the TCP
|
||||
connection. Under sustained qd=16 writes, the replica couldn't drain the TCP
|
||||
buffer fast enough. The deadline fired, `markDegraded()` was called, and
|
||||
subsequent WAL recycling made the gap unrecoverable — escalating to
|
||||
NeedsRebuild.
|
||||
|
||||
A 3-second TCP write timeout caused a full 1GB rebuild. The transport timeout
|
||||
was over-interpreted as a semantic failure.
|
||||
|
||||
### Case: DRBD — TCP drop triggers full resync
|
||||
|
||||
In DRBD 8.x, a single TCP connection failure could trigger a full resync. The
|
||||
activity log bitmap was supposed to track only changed blocks, but connection
|
||||
drops during heavy I/O could corrupt the bitmap tracking, causing a full resync
|
||||
of the entire device.
|
||||
|
||||
Fix: DRBD 9 introduced quorum — transport failure alone doesn't trigger resync.
|
||||
You need quorum loss (confirmed by multiple paths) before the system decides
|
||||
the peer needs rebuilding.
|
||||
|
||||
### Case: Ceph — transport loss vs acting set change
|
||||
|
||||
Ceph's PG peering distinguishes transport loss from acting set changes.
|
||||
A transport failure moves the OSD out of the acting set, but the PG doesn't
|
||||
start recovery until peering collects actual data-plane facts (pg_info
|
||||
exchange). Transport loss changes reachability; recovery requires facts.
|
||||
|
||||
### Fix pattern
|
||||
|
||||
```
|
||||
Transport error → mark reachability lost
|
||||
Keep probing last known endpoint (harmless retries)
|
||||
When reachable again → collect facts (R, S, H)
|
||||
Facts determine: no recovery, catchup, or rebuild
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## A3: Ack Arrival Defines Terminal Success
|
||||
|
||||
**Definition**: An acknowledgment message (ack, barrier response, completion
|
||||
signal) is treated as terminal success before the full protocol round-trip
|
||||
closes. The system announces success, then discovers the operation is not
|
||||
actually complete.
|
||||
|
||||
**Why it is wrong**: Observed completion is not the same as closed recovery
|
||||
authority. An ack means "the remote side received and processed your request."
|
||||
It does not mean "all side effects are complete, all cleanup is done, and the
|
||||
system is in a consistent terminal state."
|
||||
|
||||
**Correct principle**: Terminal success is emitted only after executor/session
|
||||
closure completes, not on ack arrival.
|
||||
|
||||
### Case: SeaweedFS sw-block — double event on rebuild completion
|
||||
|
||||
The remote rebuild ack callback emitted `SessionCompleted` immediately on
|
||||
receiving `SessionAckCompleted` from the replica. The V2 core transitioned to
|
||||
`publish_healthy`. Then the executor's cleanup path encountered a "sender
|
||||
stopped" error and emitted `SessionFailed`, knocking the mode back to
|
||||
`degraded`. Two contradictory terminal events from the same operation.
|
||||
|
||||
Root cause: ack arrival was treated as terminal truth, but the executor was
|
||||
still unwinding.
|
||||
|
||||
Fix: Ack updates observed facts only. Terminal success is emitted after
|
||||
executor/session closure succeeds.
|
||||
|
||||
### Case: SeaweedFS upstream — local write before replica ack
|
||||
|
||||
`topology/store_replicate.go:49-64`
|
||||
|
||||
The primary writes locally first, then replicates to remote locations. If local
|
||||
write succeeds but remote replication fails, the primary has data the replicas
|
||||
don't. There is no reconciliation mechanism. The write is partially committed.
|
||||
|
||||
If the client connection drops after local write but before replication error
|
||||
is returned, the client may see success (from a previous response) while the
|
||||
replicas don't have the data.
|
||||
|
||||
### Case: Ceph — committed before fsync
|
||||
|
||||
Ceph had a bug where the primary OSD declared a write "committed" after
|
||||
receiving replica acks, but the replica crashed before fsyncing. The fix was
|
||||
`min_size` enforcement — the write isn't committed until the required number
|
||||
of replicas confirm durable persistence via journal/WAL fsync.
|
||||
|
||||
### Fix pattern
|
||||
|
||||
```
|
||||
Ack arrives → update observed facts (progress, achieved LSN)
|
||||
Do NOT emit terminal success yet
|
||||
Executor completes cleanup (sender close, session close)
|
||||
Then emit terminal success
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## A4: Event Ordering Determines Semantics
|
||||
|
||||
**Definition**: The same set of facts produces different semantic outcomes
|
||||
depending on which event arrives first. Two orderings of the same events lead
|
||||
to different recovery paths or different final states.
|
||||
|
||||
**Why it is wrong**: If the system has the same facts, it should make the same
|
||||
decision. Event order should affect convergence latency, not semantic result.
|
||||
|
||||
**Correct principle**: Same facts, same decision, regardless of arrival order.
|
||||
|
||||
### Case: SeaweedFS sw-block — NeedsRebuildObserved removes sender before rebuild starts
|
||||
|
||||
`handleReplicaProbeResult` calls `applyCoreEvent(NeedsRebuildObserved)` which
|
||||
emits `InvalidateSessionCommand`. The command removes the sender's session
|
||||
from the orchestrator registry. Then `installSession` tries to re-create it,
|
||||
and `RebuildStarted` fires. But by the time the first ack arrives, a
|
||||
registry reconciliation (triggered by `syncProtocolExecutionState` inside
|
||||
`applyCoreEvent`) has removed the sender again.
|
||||
|
||||
The sender survives or dies depending on the exact ordering of:
|
||||
1. InvalidateSessionCommand dispatch
|
||||
2. installSession ProcessAssignment
|
||||
3. syncProtocolExecutionState reconciliation
|
||||
4. First ack arrival
|
||||
|
||||
Different timings of these four events produce different outcomes (rebuild
|
||||
succeeds or fails with "sender not found").
|
||||
|
||||
### Case: SeaweedFS upstream — volume growth race
|
||||
|
||||
`topology/volume_growth.go:288-349`
|
||||
|
||||
Concurrent `Assign` requests race on volume slot reservation. Thread A
|
||||
reserves node1+node2+node3. Thread B sees node3 full (A got it), fails, and
|
||||
releases its reservations. Under high concurrency, different request ordering
|
||||
produces different capacity decisions.
|
||||
|
||||
### Case: Ceph — PG peering collects all facts first
|
||||
|
||||
Ceph's solution to this anti-pattern: the PG peering state machine has
|
||||
`GetInfo` and `GetLog` states that wait for responses from ALL peers before
|
||||
advancing to `Active`. Event order within the collection phase doesn't matter
|
||||
because no decision is made until all facts are present.
|
||||
|
||||
### Case: Raft — log position, not arrival order
|
||||
|
||||
The Raft consensus algorithm is the canonical solution. All decisions are based
|
||||
on log position (term + index), not arrival order. If two messages carry the
|
||||
same log position information, the same decision is made regardless of which
|
||||
arrives first.
|
||||
|
||||
### Fix pattern
|
||||
|
||||
```
|
||||
Collect all required facts before making decisions
|
||||
Use monotonic identifiers (epoch, LSN, sessionID) to order facts
|
||||
Decision function: f(facts) → action (deterministic, no ordering dependency)
|
||||
R >= H → no recovery
|
||||
R >= S → catchup
|
||||
R < S → rebuild
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## A5: Projection Drives Control Truth
|
||||
|
||||
**Definition**: A derived status field (mode, health, state label) is read
|
||||
back as input to control decisions. The projection is supposed to be a report
|
||||
of truth, but it becomes a hidden control input that creates feedback loops.
|
||||
|
||||
**Why it is wrong**: Projection must be derived only. When a derived field
|
||||
is used as input, the system creates a circular dependency: state determines
|
||||
projection, projection determines state.
|
||||
|
||||
**Correct principle**: Projection reports truth. It must never create truth.
|
||||
|
||||
### Case: SeaweedFS upstream — ReadOnly status drives assignment
|
||||
|
||||
`topology/volume_layout.go:267-271`
|
||||
|
||||
Volume `ReadOnly` status (self-reported by the volume server) directly controls
|
||||
whether the master adds the volume to the writable list and whether it is
|
||||
eligible for new write assignments. The master has no independent verification.
|
||||
|
||||
If a volume server incorrectly marks itself readonly (bug, misconfiguration),
|
||||
the master stops all writes to that volume. The volume server's self-reported
|
||||
status becomes authoritative for the master's assignment decisions.
|
||||
|
||||
### Case: SeaweedFS upstream — volume compacting status
|
||||
|
||||
`topology/topology_vacuum.go:347-349`
|
||||
|
||||
The master skips vacuum for volumes marked readonly — but readonly is itself
|
||||
derived from the volume server's report. A volume server can prevent vacuuming
|
||||
by claiming readonly, even if the volume should be vacuumed.
|
||||
|
||||
### Case: Kubernetes — Pod phase feedback loop
|
||||
|
||||
Pod `status.phase` (Running, Pending, Failed) is supposed to be a derived
|
||||
report. But many controllers use `status.phase` as input for reconciliation
|
||||
loops, creating feedback cycles. The Kubernetes community has been migrating
|
||||
toward `conditions` (explicit fact-based fields) instead of `phase` (derived
|
||||
summary).
|
||||
|
||||
### Case: Ceph — PG state as recovery scheduler input
|
||||
|
||||
Ceph's PG `state` (active+clean, active+degraded) was used internally to
|
||||
prioritize recovery scheduling. But `degraded` was itself derived from the
|
||||
recovery state — creating a loop. Fix: the recovery scheduler works from
|
||||
`pg_missing` (actual missing objects), not the PG state label.
|
||||
|
||||
### Fix pattern
|
||||
|
||||
```
|
||||
Projection = f(facts) — pure derivation, no side effects
|
||||
Control decisions read facts directly, not projections
|
||||
If you find a control path reading a projection, refactor to read the
|
||||
underlying fact instead
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## A6: Timing Workaround States Accumulate in the Engine
|
||||
|
||||
**Definition**: New states, flags, or enum values are added to the semantic
|
||||
engine to survive a specific race condition or timing issue. Over time, the
|
||||
engine becomes a patch history rather than a semantic model.
|
||||
|
||||
**Why it is wrong**: Each workaround state increases the state machine's
|
||||
surface area without adding semantic meaning. The state exists for timing
|
||||
reasons, not because the problem space has a new distinction. Future developers
|
||||
cannot tell which states are essential and which are workarounds.
|
||||
|
||||
**Correct principle**: New state enters the engine only if it expresses a new
|
||||
semantic distinction that cannot be represented by existing dimensions.
|
||||
|
||||
### Case: SeaweedFS upstream — phantom volume server Counter
|
||||
|
||||
`data_node.go:24`
|
||||
|
||||
```go
|
||||
Counter int // in race condition, the previous dataNode was not dead
|
||||
```
|
||||
|
||||
When a VS reconnects before the old heartbeat stream's defer runs, two streams
|
||||
exist for the same node. The `Counter` field tracks active streams and only
|
||||
unregisters when it reaches 0. This is a timing workaround for the race
|
||||
between stream closure and new stream registration. The comment explicitly
|
||||
says "in race condition."
|
||||
|
||||
### Case: SeaweedFS upstream — reservation timeout
|
||||
|
||||
`topology/node.go:294`
|
||||
|
||||
```go
|
||||
const reservationTimeout = 5 * time.Minute // TODO: make this configurable
|
||||
```
|
||||
|
||||
Reservations are cleaned up after an arbitrary 5-minute timeout. This is a
|
||||
workaround for failed volume creation that doesn't release its reservation.
|
||||
The timeout exists because there is no proper completion/failure signal for
|
||||
the reservation lifecycle.
|
||||
|
||||
### Case: Ceph — PG peering state explosion
|
||||
|
||||
Ceph's PG peering state machine grew from ~8 states in early versions to ~20+
|
||||
states. Many intermediate states (`Stray`, `GetMissing`, `WaitUpThru`,
|
||||
`Incomplete`) were introduced for specific race conditions between OSD restart
|
||||
timing and monitor OSDMap propagation.
|
||||
|
||||
`WaitUpThru` exists solely because the monitor's OSDMap epoch update can
|
||||
arrive before or after PG peering completes — it is a timing workaround state
|
||||
that has persisted for over a decade.
|
||||
|
||||
### Case: DRBD — connection state proliferation
|
||||
|
||||
DRBD's original 4 connection states (StandAlone, Connecting, Connected,
|
||||
Disconnecting) grew to include `NetworkFailure`, `Unconnected`, `Timeout`,
|
||||
`BrokenPipe`, `TearDown` — many of which handle specific TCP failure modes
|
||||
rather than semantic differences.
|
||||
|
||||
### Fix pattern
|
||||
|
||||
```
|
||||
Before adding a new state, ask:
|
||||
1. Does this express a new semantic distinction? (keep)
|
||||
2. Or does it exist to survive a specific timing race? (reject)
|
||||
|
||||
If the answer is (2), fix the timing issue in the runtime layer
|
||||
(retry, epoch comparison, idempotent processing) rather than adding
|
||||
a new state to the semantic engine.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## A7: Transport Mechanics Leak Into the Semantic Engine
|
||||
|
||||
**Definition**: TCP connection management, retry logic, goroutine scheduling,
|
||||
or wire protocol details appear inside the semantic state machine instead of
|
||||
being handled by runtime adapters.
|
||||
|
||||
**Why it is wrong**: The semantic engine should process facts and produce
|
||||
decisions. It should not know or care about TCP, gRPC, goroutines, or wire
|
||||
formats. When transport mechanics leak in, the engine becomes coupled to a
|
||||
specific runtime implementation and cannot be tested or reasoned about
|
||||
independently.
|
||||
|
||||
**Correct principle**: The engine processes facts. Runtime adapters handle
|
||||
transport.
|
||||
|
||||
### Case: SeaweedFS sw-block — sender registry as transport gate
|
||||
|
||||
The V2 engine's sender registry (`Orchestrator.Registry`) serves dual purpose:
|
||||
semantic session tracking AND transport connection management. When
|
||||
`InvalidateSessionCommand` fires, it nulls the sender's session (semantic)
|
||||
and the same sender is used by the ack observation path to validate incoming
|
||||
TCP frames (transport). The semantic invalidation kills the transport path.
|
||||
|
||||
This coupling caused the "sender not found" rebuild failure: the engine's
|
||||
semantic state change (invalidate session) removed the transport path needed
|
||||
by the rebuild coordinator.
|
||||
|
||||
### Case: Ceph — messenger abstraction
|
||||
|
||||
Ceph explicitly separates the semantic layer (PG peering state machine) from
|
||||
the transport layer (msgr2 messenger). The PG state machine never directly
|
||||
touches TCP connections. It produces messages that the messenger delivers.
|
||||
Connection failures are reported to the state machine as facts, not as
|
||||
transport errors.
|
||||
|
||||
### Fix pattern
|
||||
|
||||
```
|
||||
Engine interface:
|
||||
Input: facts (epoch, R, S, H, ack kind, session ID)
|
||||
Output: decisions (no-op, catchup, rebuild, promote)
|
||||
|
||||
Runtime adapter:
|
||||
Translates TCP events → facts
|
||||
Translates decisions → TCP actions
|
||||
Handles retry, timeout, connection lifecycle
|
||||
|
||||
The engine never imports net, io, or sync packages.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary Table
|
||||
|
||||
| Anti-Pattern | SeaweedFS upstream | sw-block V2 | Ceph | DRBD |
|
||||
|---|---|---|---|---|
|
||||
| A1: Timer defines semantics | Dead node detection (60s) | Fast rejoin missed | OSD flapping storms | Ping timeout resync |
|
||||
| A2: Transport = recovery | One replica fails all | Ship() 3s deadline | — | TCP drop = full resync |
|
||||
| A3: Ack = terminal success | Local write before ack | Double SessionCompleted/Failed | Committed before fsync | — |
|
||||
| A4: Ordering determines result | Volume growth race | Sender removed before rebuild | Solved: collect all facts first | — |
|
||||
| A5: Projection as control | ReadOnly drives assignment | — | PG state as scheduler input | — |
|
||||
| A6: Workaround states | Counter field, reservation timeout | — | WaitUpThru, 20+ PG states | 9+ connection states |
|
||||
| A7: Transport in engine | — | Sender registry dual-purpose | Solved: messenger abstraction | — |
|
||||
|
||||
---
|
||||
|
||||
## Using This Document
|
||||
|
||||
When reviewing a protocol change:
|
||||
|
||||
1. Check if the change matches any anti-pattern above
|
||||
2. If it does, ask: can the same goal be achieved without the anti-pattern?
|
||||
3. Apply the "fix pattern" suggested for that anti-pattern
|
||||
4. If no clean alternative exists, document why the exception is necessary
|
||||
|
||||
When designing a new protocol:
|
||||
|
||||
1. Separate identity truth (who), reachability truth (can I reach), recovery
|
||||
truth (what data action), and execution (transport mechanics)
|
||||
2. Ensure each truth domain has a single authority
|
||||
3. Ensure timers trigger observations, never semantic decisions
|
||||
4. Ensure same facts produce same decisions regardless of event order
|
||||
5. Keep transport mechanics in runtime adapters, not the semantic engine
|
||||
|
||||
---
|
||||
|
||||
*This document is a living reference. Add new cases as they are discovered.*
|
||||
@@ -0,0 +1,469 @@
|
||||
# SmartWAL Design Memo
|
||||
|
||||
Date: 2026-04-10
|
||||
Status: design research
|
||||
Scope: BlockVol write path, crash recovery, and replicated recovery
|
||||
|
||||
## 1. What SmartWAL Is
|
||||
|
||||
SmartWAL changes what the WAL carries. Instead of storing the full data
|
||||
payload for every write, the WAL stores only a small sequencing/recovery
|
||||
descriptor. The actual data goes directly to the extent store.
|
||||
|
||||
```
|
||||
Current: WriteLBA(lba, data) → WAL(LSN, lba, data[4KB]) → flusher → extent
|
||||
SmartWAL: WriteLBA(lba, data) → extent(lba, data[4KB]) → WAL(LSN, lba, checksum[32B])
|
||||
```
|
||||
|
||||
The WAL becomes a metadata journal: ordered, durable, replayable, but small.
|
||||
|
||||
## 2. What SmartWAL Is Not
|
||||
|
||||
SmartWAL is not "skip the WAL." The WAL still exists. It still provides:
|
||||
|
||||
- Crash recovery ordering
|
||||
- Durable commit proof
|
||||
- Replication sequencing (LSN)
|
||||
- Recoverability classification
|
||||
|
||||
SmartWAL changes the write encoding, not the recovery authority.
|
||||
|
||||
## 3. Why SmartWAL Matters for BlockVol
|
||||
|
||||
The current WAL carries full 4KB payloads. A 64MB WAL holds ~16K entries.
|
||||
Under sustained qd=16 writes, the WAL fills in seconds. This creates:
|
||||
|
||||
1. WAL exhaustion under burst writes (the 30s dd_write timeout)
|
||||
2. WAL retention tension (pin for replication vs recycle for space)
|
||||
3. Catch-up vs rebuild forced by WAL recycling, not by actual data divergence
|
||||
4. Flusher becoming the critical path (must copy data from WAL to extent
|
||||
before WAL can recycle)
|
||||
|
||||
With SmartWAL, a 64MB WAL holds ~1M entries (64B each). WAL pressure
|
||||
effectively disappears. The flusher is no longer needed as a data copier
|
||||
(data already in extent). The retention problem vanishes because the WAL
|
||||
is small enough to retain indefinitely.
|
||||
|
||||
## 4. The Algorithm
|
||||
|
||||
### 4.1 Write Path (Single Node)
|
||||
|
||||
```
|
||||
WriteLBA(lba, data):
|
||||
1. ALLOCATE extent space for lba (may be existing location or new CoW location)
|
||||
2. WRITE data to extent at allocated location
|
||||
3. BARRIER: fdatasync on extent fd — data is now durable on storage
|
||||
4. WRITE WAL record: {LSN, epoch, lba, physical_offset, length, crc32(data)}
|
||||
5. WAL record is in the WAL ring buffer (durable after next WAL fsync)
|
||||
6. Return success to SCSI/iSCSI layer
|
||||
|
||||
SyncCache (SCSI SYNCHRONIZE CACHE):
|
||||
1. fsync the WAL — all pending WAL records become durable
|
||||
2. The SyncCache response proves: all writes up to this LSN are
|
||||
both data-durable (step 3) and metadata-durable (this fsync)
|
||||
```
|
||||
|
||||
### 4.2 Crash Recovery (Single Node)
|
||||
|
||||
```
|
||||
Recovery:
|
||||
1. Open extent file (data store). It may contain writes that completed
|
||||
step 2-3 but not step 4 (data durable, no WAL record). These are
|
||||
invisible — the WAL doesn't reference them, so they are harmlessly
|
||||
overwritten later.
|
||||
|
||||
2. Open WAL. Find the last valid record (checksum-verified, contiguous
|
||||
from WAL start or last checkpoint).
|
||||
|
||||
3. For each committed WAL record from oldest to newest:
|
||||
a. Read data from extent at the physical_offset in the record.
|
||||
b. Verify crc32 matches the WAL record's stored checksum.
|
||||
c. If match: this write is recovered. Update the in-memory dirty
|
||||
map / extent index.
|
||||
d. If mismatch: DATA-BEFORE-METADATA INVARIANT VIOLATED. This means
|
||||
the barrier at step 3 was ineffective. Log critical error. Mark
|
||||
this LBA as damaged.
|
||||
|
||||
4. Rebuild allocator free-space from the extent index (not from a
|
||||
persistent free-space map). Any blocks not referenced by the index
|
||||
are free — this reclaims orphaned allocations from step 1-2 where
|
||||
step 4 never completed.
|
||||
|
||||
5. Set nextLSN = last recovered LSN + 1.
|
||||
6. Resume normal operation.
|
||||
```
|
||||
|
||||
### 4.3 Write Path (Replicated)
|
||||
|
||||
```
|
||||
WriteLBA on primary:
|
||||
1-5. Same as single-node write path (data to extent, metadata to WAL)
|
||||
6. SHIP to replica: {LSN, epoch, lba, data[4KB], crc32}
|
||||
Note: the wire message carries the FULL data payload. SmartWAL
|
||||
optimizes local WAL, not the replication wire. The replica needs
|
||||
the data bytes.
|
||||
7. Return success to SCSI layer (write-back: after step 5)
|
||||
|
||||
Ship to replica:
|
||||
The shipper reads the data from the extent (not from the WAL) and
|
||||
sends it to the replica over the data channel. The wire format is
|
||||
unchanged from today.
|
||||
|
||||
On the replica:
|
||||
1. Receive {LSN, epoch, lba, data, crc32} from the data channel
|
||||
2. Write data to local extent at lba
|
||||
3. Barrier: fdatasync on local extent
|
||||
4. Write local WAL record: {LSN, epoch, lba, physical_offset, crc32}
|
||||
5. The replica's WAL is also SmartWAL — small records only
|
||||
|
||||
Barrier (SyncCache / sync_all):
|
||||
Primary sends BarrierReq(target_lsn) to replica via ctrl channel.
|
||||
Replica verifies receivedLSN >= target_lsn, calls fdatasync on its
|
||||
WAL, returns flushedLSN.
|
||||
This proves: all entries up to flushedLSN are both data-durable and
|
||||
metadata-durable on the replica.
|
||||
```
|
||||
|
||||
### 4.4 Replicated Crash Recovery
|
||||
|
||||
```
|
||||
Primary crashes and restarts:
|
||||
1. Local recovery (section 4.2) — extent + WAL replay
|
||||
2. Primary discovers its replicas via master assignment
|
||||
3. Probe each replica:
|
||||
a. Replica reports its flushedLSN = R
|
||||
b. Primary's WAL start = S, head = H
|
||||
c. If R >= S: WAL catch-up from R to H (small WAL records,
|
||||
but data must be re-read from extent and re-shipped)
|
||||
d. If R < S: extent-based delta recovery (LBA dirty map)
|
||||
4. Catch-up or delta recovery completes → replica back to keepup
|
||||
|
||||
Replica crashes and restarts:
|
||||
1. Local recovery (section 4.2) on replica
|
||||
2. Replica reconnects to primary
|
||||
3. Primary probes: replica's R vs primary's S and H
|
||||
4. Same decision: WAL catch-up or extent delta
|
||||
|
||||
Key difference from current system:
|
||||
- WAL holds ~1M entries instead of ~16K
|
||||
- WAL recycling is rare (WAL is small, entries are tiny)
|
||||
- Catch-up from WAL covers much longer outages
|
||||
- Full rebuild becomes rare — only when WAL is truly exhausted
|
||||
(days of accumulated writes, not seconds)
|
||||
```
|
||||
|
||||
## 5. The Invariants
|
||||
|
||||
### I1: Data Before Metadata
|
||||
|
||||
The extent write (step 2) must be durable (fdatasync, step 3) BEFORE the
|
||||
WAL record (step 4) is written. If this is violated, crash recovery finds
|
||||
a WAL record pointing to unwritten or partially written extent data.
|
||||
|
||||
This is the #1 source of bugs in production SmartWAL implementations:
|
||||
|
||||
- Ceph BlueStore: early releases had missing barriers between extent write
|
||||
and RocksDB metadata commit. Resulted in data corruption on power loss.
|
||||
|
||||
- ZFS ZIL: bug where indirect data blocks were referenced by ZIL records
|
||||
before the data block's fdatasync completed. Fixed by adding explicit
|
||||
flush between indirect write and ZIL record write.
|
||||
|
||||
- LVM thin: metadata journal entries referenced data blocks that were
|
||||
not yet durable. Fixed by adding bio flush flags.
|
||||
|
||||
For BlockVol: the barrier is a single fdatasync call on the extent fd
|
||||
between step 2 and step 4. This must never be elided, even under
|
||||
performance optimization pressure.
|
||||
|
||||
### I2: WAL Record Atomicity
|
||||
|
||||
Each WAL record must be self-contained and checksummed. On recovery, any
|
||||
record that fails its checksum is treated as not-written. All records
|
||||
after a failed checksum are also discarded (preserving sequence order).
|
||||
|
||||
The WAL ring buffer already provides this via per-entry CRC. No change
|
||||
needed for SmartWAL — the record format changes (smaller payload) but
|
||||
the atomicity mechanism is the same.
|
||||
|
||||
### I3: Monotonic Sequence
|
||||
|
||||
WAL records have strictly increasing LSNs. On recovery, if a gap is
|
||||
detected, all records after the gap are discarded. This prevents
|
||||
a scenario where a later write is visible but an earlier write is lost.
|
||||
|
||||
### I4: Orphan Safety
|
||||
|
||||
Allocated-but-unreferenced extent blocks (data written but WAL record
|
||||
not committed) must be harmlessly reclaimable. The allocator rebuilds
|
||||
its free-space from the extent index at mount time.
|
||||
|
||||
For BlockVol with fixed-size 4KB blocks and a 1GB extent: the extent is
|
||||
pre-allocated. There is no dynamic allocation. Orphaned writes are simply
|
||||
blocks that will be overwritten by the next write to that LBA. No special
|
||||
reclamation needed.
|
||||
|
||||
### I5: No In-Place Overwrite During Recovery
|
||||
|
||||
During recovery, if the WAL indicates LBA X was written, the recovery
|
||||
process must not assume the current extent data at LBA X is correct.
|
||||
It must verify via the CRC in the WAL record.
|
||||
|
||||
In normal operation, writes CAN overwrite in-place (same LBA, same
|
||||
extent offset) because the WAL provides the recovery mechanism. But
|
||||
during crash recovery, the extent may contain a partial write from a
|
||||
crash during step 2. The CRC check detects this.
|
||||
|
||||
### I6: Replication Wire Carries Full Data
|
||||
|
||||
SmartWAL optimizes the LOCAL WAL, not the replication wire. The replica
|
||||
needs the full data payload to apply the write. The shipper reads data
|
||||
from the extent (not from the WAL) and ships it.
|
||||
|
||||
This means:
|
||||
- The shipper must read from extent, not WAL (new behavior)
|
||||
- The extent data at a given LBA must be stable while being shipped
|
||||
(no concurrent overwrite to the same physical location during ship)
|
||||
- The dirty map tracks which LBAs need shipping
|
||||
|
||||
### I7: Replica Applies Data Before WAL
|
||||
|
||||
On the replica, the received data must be written to the local extent
|
||||
and fsynced BEFORE the local WAL record is written. The replica's crash
|
||||
recovery must be able to distinguish "data received and durable" from
|
||||
"data received but not durable." The local WAL record is the proof of
|
||||
durability.
|
||||
|
||||
## 6. Gotchas and Mitigations
|
||||
|
||||
### G1: Barrier Cost
|
||||
|
||||
The fdatasync between extent write and WAL write adds latency on every
|
||||
write. For a single 4KB write on NVMe SSD, fdatasync costs ~50-100us.
|
||||
|
||||
Mitigation: group commit. Batch multiple extent writes, issue one
|
||||
fdatasync, then write all corresponding WAL records. The SyncCache
|
||||
path already does group commit for the WAL fsync. Extend it to the
|
||||
extent fdatasync.
|
||||
|
||||
```
|
||||
Group commit with SmartWAL:
|
||||
Accumulate N writes to extent (no barrier yet)
|
||||
fdatasync extent (one barrier for N writes)
|
||||
Write N WAL records
|
||||
On SyncCache: fsync WAL
|
||||
```
|
||||
|
||||
This amortizes the barrier cost across N writes. With qd=16, the
|
||||
effective barrier cost per write is ~6us instead of ~100us.
|
||||
|
||||
### G2: Concurrent Read During Write
|
||||
|
||||
If a read arrives for an LBA that has been written to the extent (step 2)
|
||||
but whose WAL record has not been committed (step 4), what should the
|
||||
read return?
|
||||
|
||||
Answer: the new data. The extent is the source of truth for reads. The
|
||||
WAL is only for crash recovery. A read after step 2 sees the new data
|
||||
regardless of whether the WAL record exists yet.
|
||||
|
||||
This is consistent with write-back semantics: the write is "admitted"
|
||||
after step 2, and reads see admitted data.
|
||||
|
||||
### G3: Crash Between Extent Write and WAL Write
|
||||
|
||||
If the system crashes after step 2-3 but before step 4:
|
||||
- The extent has the new data at the LBA
|
||||
- The WAL has no record of this write
|
||||
- On recovery, the WAL doesn't know about this write
|
||||
- The next write to this LBA will overwrite it
|
||||
- If a replica has received this write (shipped between step 2 and crash),
|
||||
the replica has data the primary lost
|
||||
|
||||
Mitigation: This is the same as the current system. In write-back mode,
|
||||
a crash after WAL append but before fsync can lose the write. SmartWAL
|
||||
does not change the durability contract — only SyncCache provides the
|
||||
durable proof.
|
||||
|
||||
### G4: Shipper Reads Stale Extent Data
|
||||
|
||||
The shipper reads data from the extent for replication. If a second
|
||||
write to the same LBA arrives between the first write and the shipper
|
||||
reading the first write's data, the shipper may read the second write's
|
||||
data instead.
|
||||
|
||||
Mitigation: The shipper should read the data at the time of the write
|
||||
(inline with WriteLBA), not lazily from the extent later. This means
|
||||
the ship path is:
|
||||
|
||||
```
|
||||
WriteLBA:
|
||||
1. Write data to extent
|
||||
2. Barrier
|
||||
3. Write WAL record
|
||||
4. Ship(LSN, lba, data) ← data is the same buffer from step 1
|
||||
```
|
||||
|
||||
The data buffer is passed directly to Ship, not re-read from extent.
|
||||
This eliminates the stale-read race.
|
||||
|
||||
For the LBA dirty map based recovery path (catch-up from extent after
|
||||
an outage), stale reads are handled differently: the primary freezes
|
||||
a consistent snapshot (checkpoint) before reading extent data for the
|
||||
delta transfer. The checkpoint LSN defines which data is authoritative.
|
||||
|
||||
### G5: WAL Wrap-Around With Small Records
|
||||
|
||||
With ~1M entries in a 64MB WAL, the WAL ring buffer wraps much more
|
||||
slowly. But it still wraps eventually. When it does, old records must
|
||||
be safe to overwrite.
|
||||
|
||||
Mitigation: the same recycling logic applies. A record can be recycled
|
||||
when:
|
||||
1. It has been fsynced (part of a durable SyncCache)
|
||||
2. All replicas have confirmed receipt (if using WAL-based catch-up)
|
||||
3. Or the replica uses LBA dirty map for catch-up (no WAL retention
|
||||
needed for replication)
|
||||
|
||||
With LBA dirty map based recovery, condition 2 is eliminated. The WAL
|
||||
recycles based only on condition 1 (local durability). This is the clean
|
||||
model.
|
||||
|
||||
### G6: SmartWAL and the LBA Dirty Map Interaction
|
||||
|
||||
SmartWAL and LBA dirty map are complementary:
|
||||
|
||||
- SmartWAL eliminates WAL pressure (small records, huge capacity)
|
||||
- LBA dirty map eliminates WAL retention for replication (catch-up from
|
||||
extent, not from WAL)
|
||||
|
||||
Together:
|
||||
- WAL does one job: local crash recovery sequencing
|
||||
- Extent does one job: authoritative data store
|
||||
- LBA dirty map does one job: per-replica catch-up tracking
|
||||
- No tension between any of them
|
||||
|
||||
### G7: Replica Crash Recovery With SmartWAL
|
||||
|
||||
The replica also uses SmartWAL locally. On replica crash:
|
||||
|
||||
1. Replay local WAL records → verify extent data via CRC
|
||||
2. Report flushedLSN to primary on reconnect
|
||||
3. Primary sends delta (from flushedLSN to current) via extent reads
|
||||
4. Replica applies delta, writes to local extent, writes local WAL records
|
||||
5. Replica is caught up
|
||||
|
||||
The replica's local SmartWAL recovery is identical to the primary's.
|
||||
The replication catch-up is identical to today except the source of
|
||||
catch-up data is the extent (via dirty map) instead of the WAL.
|
||||
|
||||
### G8: Overwrite Ordering Under Concurrent Writers
|
||||
|
||||
If multiple SCSI commands target the same LBA range simultaneously:
|
||||
|
||||
```
|
||||
Thread A: WriteLBA(100, dataA) → extent write → WAL(LSN=5, lba=100)
|
||||
Thread B: WriteLBA(100, dataB) → extent write → WAL(LSN=6, lba=100)
|
||||
```
|
||||
|
||||
The WAL ordering (LSN 5 before LSN 6) must match the extent ordering
|
||||
(dataA before dataB). If Thread B's extent write completes first, the
|
||||
extent has dataB, but Thread A's WAL record (LSN=5) references the same
|
||||
physical location with a CRC of dataA. On recovery, the CRC mismatch
|
||||
for LSN=5 indicates corruption.
|
||||
|
||||
Mitigation: WriteLBA must hold the per-LBA write lock through both
|
||||
extent write and WAL record write. This ensures that for the same LBA,
|
||||
extent writes and WAL records are in the same order. The existing ioMu
|
||||
(or per-LBA lock in the dirty map) provides this.
|
||||
|
||||
### G9: Performance Implication of Extent-First Write
|
||||
|
||||
Current: WriteLBA → WAL append (sequential, fast) → return
|
||||
SmartWAL: WriteLBA → extent pwrite (random, slower) → barrier → WAL append
|
||||
|
||||
The extent write is a random pwrite to a pre-allocated file. On NVMe SSD,
|
||||
this is ~10-20us for 4KB. The WAL append is sequential. Adding the extent
|
||||
pwrite before the WAL append increases per-write latency by ~10-20us.
|
||||
|
||||
Mitigation: This is offset by:
|
||||
1. No flusher needed (data already in extent) — saves the copy
|
||||
2. WAL is tiny — SyncCache fsync is faster
|
||||
3. Group commit amortizes the barrier across N writes
|
||||
4. The flusher was already doing pwrite to extent — we're just moving
|
||||
it earlier in the pipeline
|
||||
|
||||
Net effect: similar or slightly better throughput, slightly higher
|
||||
single-write latency, significantly better burst write behavior (no
|
||||
WAL exhaustion).
|
||||
|
||||
## 7. Size Threshold
|
||||
|
||||
For BlockVol with 4KB block size, the threshold decision is simple:
|
||||
|
||||
- All writes are exactly 4KB (one block) or multiples of 4KB
|
||||
- A 4KB data payload + 32B WAL record = 4096B + 32B overhead
|
||||
- A metadata-only WAL record = 32-64B
|
||||
|
||||
At 4KB, the SmartWAL overhead is 99.2% savings per WAL entry.
|
||||
There is no reason to inline 4KB payloads in the WAL.
|
||||
|
||||
Recommendation: ALL writes use SmartWAL (extent-first, metadata-WAL).
|
||||
No inline path needed for block storage with fixed block size.
|
||||
|
||||
For comparison:
|
||||
- ZFS ZIL threshold: 32KB (filesystem with variable record sizes)
|
||||
- Ceph BlueStore: 4-16KB (object store with variable sizes)
|
||||
- BlockVol: 4KB fixed — always SmartWAL
|
||||
|
||||
## 8. Implementation Phases
|
||||
|
||||
### Phase 1: Extent-first write path
|
||||
- WriteLBA writes to extent before WAL
|
||||
- WAL record format changes to metadata-only (LSN, lba, crc32, flags)
|
||||
- Flusher becomes a checkpoint mechanism (periodic metadata snapshot)
|
||||
instead of a data copier
|
||||
- Crash recovery replays WAL records, verifies extent data via CRC
|
||||
- All existing tests must pass with the new write path
|
||||
|
||||
### Phase 2: LBA dirty map for replication catch-up
|
||||
- Per-replica bitmap: which LBAs changed since last confirmed position
|
||||
- On catch-up: read dirty LBAs from extent, ship to replica
|
||||
- WAL no longer retained for replication — recycles freely
|
||||
- Catch-up and rebuild become the same operation (extent delta), just
|
||||
different sizes
|
||||
|
||||
### Phase 3: Group commit for extent barrier
|
||||
- Batch N extent writes, one fdatasync, N WAL records
|
||||
- Amortize barrier cost across concurrent writes
|
||||
- This is the performance optimization phase
|
||||
|
||||
## 9. Relationship to V3 Engine
|
||||
|
||||
SmartWAL is a data algorithm change, not an engine change. The V3 engine
|
||||
should not know whether writes are WAL-inlined or extent-first. The engine
|
||||
sees:
|
||||
|
||||
- RecoverabilityClass: inline | extent_referenced | snapshot_based
|
||||
- DurabilityProof: WAL fsync LSN + barrier confirmation
|
||||
- RecoveryDecision: no_recovery | catch_up | delta_rebuild | full_rebuild
|
||||
|
||||
SmartWAL changes how RecoverabilityClass is produced (by the storage
|
||||
layer), but not how the engine uses it (for decisions).
|
||||
|
||||
Rule: SmartWAL changes write encoding and recoverability classes, but
|
||||
must not change the core truth domains of V3.
|
||||
|
||||
## 10. Production References
|
||||
|
||||
| System | Pattern | Years in Production | Key Lesson |
|
||||
|--------|---------|--------------------|-|
|
||||
| Ceph BlueStore | Extent + RocksDB metadata journal | 6+ (since Luminous 2017) | Barrier bugs were the #1 corruption source |
|
||||
| ZFS ZIL | Inline < 32KB, indirect >= 32KB | 15+ (since 2005) | Indirect block lifecycle must be explicit |
|
||||
| LVM thin | Data device + metadata journal | 10+ (since kernel 3.2, 2012) | Metadata journal rebuild on mount is essential |
|
||||
| DRBD | Activity log bitmap (no data WAL) | 20+ (since 2004) | Bitmap-only tracking is sufficient for block devices |
|
||||
| PostgreSQL | Full-page images in WAL | 25+ (since 1996) | Opposite direction — but proves WAL atomicity matters |
|
||||
|
||||
The common lesson: the algorithm is well-understood and proven. The bugs
|
||||
are in barrier enforcement and lifecycle management, not in the algorithm
|
||||
design itself.
|
||||
@@ -0,0 +1,638 @@
|
||||
# SmartWAL Prototype Specification
|
||||
|
||||
Date: 2026-04-10
|
||||
Status: prototype spec
|
||||
Goal: prove crash stability on single node and two-node replication
|
||||
|
||||
## 1. Prototype Scope
|
||||
|
||||
Build a minimal SmartWAL implementation in `blockvol` that replaces the
|
||||
current WAL-inline write path with extent-first writes. The WAL becomes
|
||||
a metadata-only sequencing journal. Prove it works through crash tests.
|
||||
|
||||
NOT in scope: group commit optimization, LBA dirty map, V3 engine
|
||||
integration, iSCSI/NVMe-oF, master assignment. This is pure storage
|
||||
algorithm validation.
|
||||
|
||||
## 2. The Logical LSN Storage Model
|
||||
|
||||
WAL + extent together form a single logical LSN-ordered storage. Each
|
||||
LSN maps to exactly one block write. The WAL provides ordering and
|
||||
crash-recovery metadata. The extent provides the authoritative data.
|
||||
|
||||
```
|
||||
Logical view:
|
||||
LSN 1 → {lba=0x100, data=4KB} ← extent has data, WAL has metadata
|
||||
LSN 2 → {lba=0x200, data=4KB}
|
||||
LSN 3 → {lba=0x100, data=4KB} ← overwrites LSN 1 at same LBA
|
||||
...
|
||||
|
||||
Physical layout:
|
||||
Extent file: [block 0][block 1]...[block N] ← random access, data lives here
|
||||
WAL file: [rec1: LSN=1,lba=0x100,crc=X][rec2: LSN=2,lba=0x200,crc=Y][...]
|
||||
|
||||
On crash recovery:
|
||||
Replay WAL records → verify extent blocks via CRC → consistent state
|
||||
```
|
||||
|
||||
## 3. WAL Record Format
|
||||
|
||||
```go
|
||||
// SmartWALRecord is the metadata-only WAL entry.
|
||||
// 32 bytes fixed size. No data payload.
|
||||
type SmartWALRecord struct {
|
||||
LSN uint64 // monotonic sequence number
|
||||
Epoch uint64 // fencing epoch
|
||||
LBA uint32 // logical block address (block index, not byte offset)
|
||||
Flags uint8 // 0x01=write, 0x02=trim, 0x04=barrier_marker
|
||||
_pad [3]byte
|
||||
DataCRC32 uint32 // crc32 of the 4KB data block in extent
|
||||
}
|
||||
// Total: 8+8+4+1+3+4 = 28 bytes. Pad to 32 for alignment.
|
||||
```
|
||||
|
||||
Compare to current: each WAL entry is 4KB+ (header + full data payload).
|
||||
SmartWAL entry is 32 bytes. Ratio: 128:1 capacity improvement.
|
||||
|
||||
A 64MB WAL holds:
|
||||
- Current: ~16K entries (4KB each)
|
||||
- SmartWAL: ~2M entries (32B each)
|
||||
|
||||
## 4. Write Path
|
||||
|
||||
```go
|
||||
func (v *BlockVol) WriteLBA(lba uint32, data []byte) error {
|
||||
v.lbaMu.Lock(lba) // per-LBA lock (see invariant I8)
|
||||
defer v.lbaMu.Unlock(lba)
|
||||
|
||||
// Step 1: write data to extent at LBA position
|
||||
offset := int64(lba) * int64(v.blockSize)
|
||||
if _, err := v.extentFD.WriteAt(data, offset); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 2: compute CRC of the data we just wrote
|
||||
crc := crc32.ChecksumIEEE(data)
|
||||
|
||||
// Step 3: append metadata record to WAL
|
||||
lsn := v.nextLSN.Add(1)
|
||||
rec := SmartWALRecord{
|
||||
LSN: lsn,
|
||||
Epoch: v.epoch,
|
||||
LBA: lba,
|
||||
Flags: FlagWrite,
|
||||
DataCRC32: crc,
|
||||
}
|
||||
if err := v.wal.AppendRecord(rec); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 4: mark LBA dirty in dirty map (for replication tracking)
|
||||
v.dirtyMap.Mark(lba, lsn)
|
||||
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
Note: NO fdatasync between extent write and WAL append in the fast path.
|
||||
The barrier comes at SyncCache time (group commit). This is safe because:
|
||||
- If crash before SyncCache: both extent write and WAL record are lost.
|
||||
Consistent (write was never acknowledged as durable).
|
||||
- SyncCache does: fdatasync(extent) → fdatasync(WAL) → ack.
|
||||
After SyncCache: both extent and WAL are durable. Consistent.
|
||||
|
||||
This is the group-commit-by-default model. Individual writes are
|
||||
write-back (fast, no barrier). SyncCache is the durability fence.
|
||||
|
||||
## 5. SyncCache (Durability Fence)
|
||||
|
||||
```go
|
||||
func (v *BlockVol) SyncCache() error {
|
||||
// Step 1: flush extent to disk
|
||||
if err := v.extentFD.Sync(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 2: flush WAL to disk
|
||||
// After this, all WAL records (and their referenced extent data)
|
||||
// are durable.
|
||||
if err := v.wal.Sync(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
The ordering matters: extent sync BEFORE WAL sync. This ensures that
|
||||
when a WAL record is durable, the extent data it references is also
|
||||
durable (Invariant I1).
|
||||
|
||||
## 6. Crash Recovery
|
||||
|
||||
```go
|
||||
func (v *BlockVol) Recover() error {
|
||||
// Step 1: scan WAL from start to find last valid record
|
||||
records, lastValidLSN, err := v.wal.ScanValidRecords()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 2: verify each record's extent data
|
||||
var recovered, damaged int
|
||||
for _, rec := range records {
|
||||
if rec.Flags == FlagTrim {
|
||||
// Trim: zero the block, mark as free
|
||||
v.zeroBlock(rec.LBA)
|
||||
recovered++
|
||||
continue
|
||||
}
|
||||
|
||||
// Read the extent data at this LBA
|
||||
data := make([]byte, v.blockSize)
|
||||
offset := int64(rec.LBA) * int64(v.blockSize)
|
||||
if _, err := v.extentFD.ReadAt(data, offset); err != nil {
|
||||
return fmt.Errorf("recovery: read lba %d: %w", rec.LBA, err)
|
||||
}
|
||||
|
||||
// Verify CRC
|
||||
actualCRC := crc32.ChecksumIEEE(data)
|
||||
if actualCRC != rec.DataCRC32 {
|
||||
// DATA-BEFORE-METADATA VIOLATION or torn write
|
||||
// The WAL record is committed but extent data doesn't match.
|
||||
//
|
||||
// This happens when:
|
||||
// a) Write-back mode: crash before SyncCache. The WAL record
|
||||
// was in the ring buffer (not fsynced), but somehow survived
|
||||
// (partial WAL page flush). The extent data was also not
|
||||
// fsynced. One or both are torn.
|
||||
// b) Bug: barrier ordering wrong in SyncCache.
|
||||
//
|
||||
// Recovery action: use the PREVIOUS version of this LBA.
|
||||
// Since we replay in LSN order, and the CRC doesn't match,
|
||||
// we skip this record. The extent may have stale data from
|
||||
// an earlier write or zeros.
|
||||
log.Printf("recovery: CRC mismatch at LSN=%d LBA=%d "+
|
||||
"(expected=%08x actual=%08x) — skipping",
|
||||
rec.LSN, rec.LBA, rec.DataCRC32, actualCRC)
|
||||
damaged++
|
||||
continue
|
||||
}
|
||||
|
||||
recovered++
|
||||
}
|
||||
|
||||
v.nextLSN.Store(lastValidLSN + 1)
|
||||
log.Printf("recovery: %d records recovered, %d damaged, nextLSN=%d",
|
||||
recovered, damaged, lastValidLSN+1)
|
||||
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
### Recovery correctness argument
|
||||
|
||||
For writes acknowledged via SyncCache (durable):
|
||||
- Extent was fsynced first, then WAL was fsynced
|
||||
- Both are durable on disk
|
||||
- CRC will match → record recovered correctly
|
||||
|
||||
For writes NOT acknowledged via SyncCache (write-back, in-flight):
|
||||
- Case A: neither extent nor WAL reached disk → no WAL record found → write lost → correct (never acknowledged)
|
||||
- Case B: extent reached disk but WAL didn't → no WAL record → write lost → correct (never acknowledged). Extent has "future" data that will be overwritten.
|
||||
- Case C: WAL reached disk but extent didn't → WAL record found, CRC mismatch → record skipped → correct (write lost, never acknowledged). Extent has stale data.
|
||||
- Case D: both reached disk (lucky flush) → CRC matches → record recovered → bonus recovery of unacknowledged write → safe (data was written, just not acked)
|
||||
|
||||
All cases are consistent. No data corruption possible.
|
||||
|
||||
## 7. WAL Ring Buffer Changes
|
||||
|
||||
The current WAL ring buffer stores variable-size entries (header + data).
|
||||
SmartWAL entries are fixed 32 bytes. The ring buffer becomes simpler:
|
||||
|
||||
```go
|
||||
type SmartWALBuffer struct {
|
||||
fd *os.File
|
||||
buf []byte // mmap'd ring buffer
|
||||
capacity int // number of 32-byte slots
|
||||
head atomic.Uint64 // next write position (slot index)
|
||||
tail atomic.Uint64 // oldest valid position
|
||||
synced atomic.Uint64 // last fsynced position
|
||||
}
|
||||
|
||||
func (w *SmartWALBuffer) AppendRecord(rec SmartWALRecord) error {
|
||||
slot := w.head.Add(1) - 1
|
||||
idx := slot % uint64(w.capacity)
|
||||
offset := idx * 32
|
||||
rec.encode(w.buf[offset : offset+32])
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *SmartWALBuffer) Sync() error {
|
||||
if err := w.fd.Sync(); err != nil {
|
||||
return err
|
||||
}
|
||||
w.synced.Store(w.head.Load())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *SmartWALBuffer) ScanValidRecords() ([]SmartWALRecord, uint64, error) {
|
||||
// Scan from tail to head, validate each record's per-entry CRC
|
||||
// (separate from data CRC — this CRC protects the WAL record itself)
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
The ring buffer is simpler because entries are fixed size. No
|
||||
fragmentation, no partial-entry handling. Slot-based indexing.
|
||||
|
||||
## 8. Replication Integration
|
||||
|
||||
### 8.1 Ship Path
|
||||
|
||||
```go
|
||||
func (v *BlockVol) ShipEntry(shipper *WALShipper, lsn uint64, lba uint32, data []byte) {
|
||||
// data is passed from WriteLBA (same buffer, no re-read from extent)
|
||||
entry := WALEntry{
|
||||
LSN: lsn,
|
||||
Epoch: v.epoch,
|
||||
LBA: lba,
|
||||
Data: data, // full 4KB payload on the wire
|
||||
}
|
||||
shipper.Ship(&entry)
|
||||
}
|
||||
```
|
||||
|
||||
The wire format is UNCHANGED. Replicas receive full data payloads.
|
||||
SmartWAL is a local optimization.
|
||||
|
||||
### 8.2 Replica Apply Path
|
||||
|
||||
```go
|
||||
func (v *BlockVol) ApplyReplicaEntry(entry *WALEntry) error {
|
||||
v.lbaMu.Lock(entry.LBA)
|
||||
defer v.lbaMu.Unlock(entry.LBA)
|
||||
|
||||
// Write data to local extent
|
||||
offset := int64(entry.LBA) * int64(v.blockSize)
|
||||
if _, err := v.extentFD.WriteAt(entry.Data, offset); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Write local SmartWAL record
|
||||
crc := crc32.ChecksumIEEE(entry.Data)
|
||||
rec := SmartWALRecord{
|
||||
LSN: entry.LSN,
|
||||
Epoch: entry.Epoch,
|
||||
LBA: entry.LBA,
|
||||
Flags: FlagWrite,
|
||||
DataCRC32: crc,
|
||||
}
|
||||
return v.wal.AppendRecord(rec)
|
||||
}
|
||||
```
|
||||
|
||||
The replica also uses SmartWAL locally.
|
||||
|
||||
### 8.3 Barrier on Replica
|
||||
|
||||
```go
|
||||
func (v *BlockVol) BarrierSync(targetLSN uint64) (uint64, error) {
|
||||
// Same as SyncCache: extent first, then WAL
|
||||
if err := v.extentFD.Sync(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := v.wal.Sync(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return v.wal.SyncedLSN(), nil
|
||||
}
|
||||
```
|
||||
|
||||
## 9. Prototype Test Plan
|
||||
|
||||
### 9.1 Single Node Crash Tests
|
||||
|
||||
```
|
||||
Test 1: Basic crash recovery
|
||||
Write 1000 blocks → SyncCache → kill -9 → recover → verify all 1000 blocks
|
||||
Expected: all CRCs match, all data correct
|
||||
|
||||
Test 2: Crash before SyncCache
|
||||
Write 1000 blocks → (no SyncCache) → kill -9 → recover
|
||||
Expected: 0 to 1000 records recovered (whatever flushed to disk)
|
||||
All recovered records have matching CRCs
|
||||
No corruption
|
||||
|
||||
Test 3: Crash during SyncCache
|
||||
Write 1000 blocks → start SyncCache → kill -9 mid-fsync → recover
|
||||
Expected: some records recovered (up to the fsync progress point)
|
||||
All recovered records have matching CRCs
|
||||
|
||||
Test 4: Overwrite crash
|
||||
Write block at LBA=100 (dataA) → SyncCache
|
||||
Write block at LBA=100 (dataB) → kill -9 before SyncCache → recover
|
||||
Expected: LBA=100 contains dataA (the durable version)
|
||||
The WAL may or may not have the LSN for dataB, but if it does,
|
||||
CRC will match dataB (both were in write-back cache and flushed
|
||||
together, or neither was)
|
||||
|
||||
Test 5: WAL wrap-around
|
||||
Write enough blocks to wrap the WAL ring buffer twice
|
||||
SyncCache periodically
|
||||
Kill -9 → recover
|
||||
Expected: all synced data intact, WAL tail correctly advanced
|
||||
|
||||
Test 6: Sustained burst crash
|
||||
fio randwrite 4k qd=16 for 10 seconds → kill -9 → recover
|
||||
Expected: recovery completes without corruption
|
||||
Some recent writes may be lost (not synced)
|
||||
All recovered writes have matching CRCs
|
||||
|
||||
Test 7: Mixed read-write crash
|
||||
Concurrent reads and writes → kill -9 → recover
|
||||
Expected: reads never see corrupted data
|
||||
Recovery produces consistent state
|
||||
```
|
||||
|
||||
### 9.2 Two-Node Replication Crash Tests
|
||||
|
||||
```
|
||||
Test 8: Primary crash, replica has all data
|
||||
Primary: write 1000 blocks → SyncCache → barrier(replica) → kill -9 primary
|
||||
Replica: should have all 1000 blocks durable
|
||||
Recover primary → compare extent data with replica
|
||||
Expected: identical
|
||||
|
||||
Test 9: Primary crash, replica partially caught up
|
||||
Primary: write 1000 blocks → SyncCache → ship 500 → kill -9 primary
|
||||
Replica: has 500 blocks
|
||||
Recover primary → primary has 1000, replica has 500
|
||||
Expected: delta = 500 blocks. Catch-up from primary's extent.
|
||||
|
||||
Test 10: Replica crash during receive
|
||||
Primary: write 1000 blocks → SyncCache → ship all → kill -9 replica
|
||||
Recover replica → replay local WAL → verify extent
|
||||
Expected: some entries recovered (those that were SyncCached on replica)
|
||||
Unrecovered entries will be re-shipped by primary on reconnect
|
||||
|
||||
Test 11: Both crash simultaneously
|
||||
Primary + replica: write 500 blocks → SyncCache on both → barrier OK
|
||||
Write 500 more → kill -9 both before SyncCache
|
||||
Recover both → compare
|
||||
Expected: both have the first 500 durable. Second 500 may be
|
||||
partially recovered on either node. No corruption.
|
||||
|
||||
Test 12: Failover data integrity
|
||||
Primary: write 1000 blocks at known LBAs with known data → SyncCache
|
||||
Barrier succeeds (replica has all 1000 durable)
|
||||
Kill primary
|
||||
Promote replica to primary
|
||||
Read all 1000 blocks from new primary
|
||||
Expected: all data matches original writes (byte-for-byte)
|
||||
|
||||
Test 13: Rebuild after SmartWAL gap
|
||||
Primary: write 10000 blocks → SyncCache → WAL wraps (old records recycled)
|
||||
Replica was down during writes
|
||||
Replica reconnects → primary's WAL doesn't cover the gap
|
||||
Expected: full extent delta transfer (LBA dirty map), not WAL catch-up
|
||||
After rebuild: extent data matches on both nodes
|
||||
|
||||
Test 14: Sustained replication under crash
|
||||
Primary: fio randwrite 4k qd=16 for 30 seconds
|
||||
Replica: receiving and applying
|
||||
At t=15s: kill -9 replica
|
||||
At t=20s: restart replica
|
||||
At t=30s: stop fio → SyncCache → barrier
|
||||
Expected: barrier succeeds. All synced data matches.
|
||||
```
|
||||
|
||||
### 9.3 Correctness Assertions
|
||||
|
||||
Every test must verify:
|
||||
|
||||
```
|
||||
1. No CRC mismatch after recovery (invariant I1)
|
||||
2. WAL LSN sequence is monotonic after recovery (invariant I3)
|
||||
3. Recovered LSN <= last SyncCache'd LSN (no future data)
|
||||
4. Extent data at each LBA matches the last WAL record for that LBA
|
||||
5. On two-node: barrier-confirmed data is identical on both nodes
|
||||
6. On two-node: after catch-up/rebuild, extent data matches
|
||||
```
|
||||
|
||||
## 10. Prototype File Structure
|
||||
|
||||
```
|
||||
weed/storage/blockvol/
|
||||
smartwal.go ← SmartWALBuffer: ring buffer, append, sync, scan
|
||||
smartwal_record.go ← SmartWALRecord: encode, decode, CRC
|
||||
smartwal_recovery.go ← Recover(): replay WAL, verify extent CRCs
|
||||
smartwal_test.go ← Single-node crash tests (1-7)
|
||||
smartwal_repl_test.go ← Two-node replication crash tests (8-14)
|
||||
```
|
||||
|
||||
The prototype is self-contained within `blockvol`. It does not change
|
||||
the existing WAL implementation — it's a parallel implementation that
|
||||
can be tested independently. Once proven stable, it replaces the current
|
||||
WAL path.
|
||||
|
||||
## 11. Success Criteria
|
||||
|
||||
The prototype is successful when:
|
||||
|
||||
1. All 14 crash tests pass with -race flag
|
||||
2. No CRC mismatches in any crash scenario
|
||||
3. WAL capacity: 2M entries in 64MB (128:1 improvement over current)
|
||||
4. Single-node write latency: within 10% of current WAL-inline path
|
||||
5. Two-node barrier latency: within 10% of current barrier path
|
||||
6. Recovery time: faster than current (no data to replay, just metadata)
|
||||
7. Code size: < 500 lines for the core SmartWAL implementation
|
||||
|
||||
## 12. What This Proves
|
||||
|
||||
If the prototype passes all crash tests, it proves:
|
||||
|
||||
1. **The logical LSN storage model works**: WAL (metadata) + extent (data)
|
||||
together form a consistent, crash-recoverable, LSN-ordered store.
|
||||
|
||||
2. **Extent-first write is safe**: with proper barrier ordering at
|
||||
SyncCache, extent-first writes do not introduce corruption.
|
||||
|
||||
3. **SmartWAL eliminates WAL pressure**: 128:1 capacity improvement
|
||||
makes WAL exhaustion effectively impossible.
|
||||
|
||||
4. **Replication is compatible**: the wire format is unchanged, replicas
|
||||
use SmartWAL locally, barrier semantics are preserved.
|
||||
|
||||
5. **The algorithm is ready for production**: the same algorithm runs
|
||||
in Ceph BlueStore and ZFS ZIL, now proven in our codebase with our
|
||||
crash test suite.
|
||||
|
||||
## 13. Prototype Implementation (2026-04-10)
|
||||
|
||||
### Files
|
||||
|
||||
| File | Lines | Purpose |
|
||||
|------|-------|---------|
|
||||
| `smartwal_record.go` | 82 | 32-byte record: encode/decode with magic byte + record CRC |
|
||||
| `smartwal.go` | 170 | Ring buffer: slot-based append, sync, scan valid records |
|
||||
| `smartwal_recovery.go` | 275 | `SmartWALVolume`: WriteLBA, ReadLBA, TrimLBA, SyncCache, Recover |
|
||||
| `smartwal_test.go` | 440 | 9 single-node crash tests |
|
||||
| `smartwal_repl_test.go` | 360 | 7 two-node replication crash tests |
|
||||
|
||||
### Record wire format (32 bytes)
|
||||
|
||||
```
|
||||
[magic:1][flags:1][pad:2][lba:4][lsn:8][epoch:8][dataCRC:4][recCRC:4]
|
||||
```
|
||||
|
||||
- `magic = 0xAC`: distinguishes valid records from zeroed slots
|
||||
- `recCRC`: CRC32 of bytes 0..27, protects the WAL record itself
|
||||
- `dataCRC`: CRC32 of the 4KB data block in the extent
|
||||
|
||||
### Test results: 16/16 PASS
|
||||
|
||||
Single-node:
|
||||
|
||||
| # | Test | What it proves |
|
||||
|---|------|---------------|
|
||||
| 1 | BasicCrashRecovery | Write → sync → crash → recover → all 100 blocks verified |
|
||||
| 2 | CrashBeforeSyncCache | Write 50 → no sync → crash → no corruption (some data lost, all valid) |
|
||||
| 3 | OverwriteCrash | Write A → sync → write B → crash → data is A or B, never corrupted |
|
||||
| 4 | WALWrapAround | 200 writes into 64-slot WAL (wraps 3x) → sync → crash → all correct |
|
||||
| 5 | RecordRoundTrip | Encode/decode preserves all fields |
|
||||
| 6 | InvalidRecordDetection | Zeros and corrupted CRC both rejected |
|
||||
| 7 | SustainedRandomWriteCrash | 700 random writes, sync every 100, crash mid-batch → synced data intact |
|
||||
| 8 | TrimRecovery | Write → sync → trim → sync → verify zeros |
|
||||
| 9 | ConcurrentWrites | 8 goroutines × 50 writes → no race, sync succeeds |
|
||||
|
||||
Two-node replication:
|
||||
|
||||
| # | Test | What it proves |
|
||||
|---|------|---------------|
|
||||
| 10 | PrimaryCrashAfterBarrier | Primary crashes after barrier — replica has all data, recovered primary matches |
|
||||
| 11 | PrimaryCrashPartialCatchUp | Primary has 100, replica has 50 — catch-up ships delta, data converges |
|
||||
| 12 | ReplicaCrashDuringReceive | Replica crashes without sync — recover + re-ship → data matches |
|
||||
| 13 | BothCrashSimultaneously | Synced range intact on both, unsynced range is valid (data or zeros, never corrupt) |
|
||||
| 14 | FailoverDataIntegrity | Kill primary, promote replica, read all blocks → byte-for-byte match |
|
||||
| 15 | RebuildAfterWALGap | 200 writes wrap 32-slot WAL, replica was down — dirty map delta rebuild → data matches |
|
||||
| 16 | SustainedReplicationCrash | 300 writes, kill replica mid-stream, recover + catch-up → all 512 LBAs match |
|
||||
|
||||
## 14. Key Design Questions and Answers
|
||||
|
||||
### Q1: What is the minimum commit record?
|
||||
|
||||
**Record**: 32 bytes `{LSN, Epoch, LBA, Flags, DataCRC32, RecordCRC}`.
|
||||
|
||||
**Durable commit unit**: all writes covered by one SyncCache call.
|
||||
Individual writes are write-back (not durable). SyncCache does
|
||||
`fdatasync(extent)` then `fdatasync(WAL)` — this is the durability fence.
|
||||
|
||||
### Q2: What are the crash recovery semantics?
|
||||
|
||||
Recovery scans WAL records, builds last-writer-wins map per LBA,
|
||||
verifies extent data via CRC. Four cases:
|
||||
|
||||
| Case | Extent | WAL | Recovery |
|
||||
|------|--------|-----|----------|
|
||||
| A | not flushed | not flushed | no record → write lost (never acked) ✓ |
|
||||
| B | flushed | not flushed | no WAL record → write lost ✓ (extent has untracked data) |
|
||||
| C | not flushed | flushed | WAL found, CRC mismatch → skipped ✓ |
|
||||
| D | both flushed | both flushed | CRC matches → recovered ✓ |
|
||||
|
||||
**Case B subtlety**: the extent may contain data ahead of the WAL's
|
||||
durable frontier. Recovery does NOT undo it. The data sits until
|
||||
overwritten. Safe for block storage (client never got ack).
|
||||
|
||||
Recovery does NOT guarantee `extent[LBA] == lastDurableWAL[LBA]`.
|
||||
It guarantees: if a WAL record exists AND CRC matches, data is correct.
|
||||
|
||||
### Q3: What is the reference resolvability contract?
|
||||
|
||||
**Inline WAL**: `WAL[LSN] → data` (self-contained, always resolvable).
|
||||
|
||||
**SmartWAL**: `WAL[LSN] → (LBA, CRC)`. Data lives at `extent[LBA]`.
|
||||
But the extent only holds the LATEST version per LBA:
|
||||
|
||||
```
|
||||
LSN=5: write LBA=100, data=A, CRC=crc(A)
|
||||
LSN=8: write LBA=100, data=B, CRC=crc(B)
|
||||
extent[100] = B. WAL record LSN=5 → LBA=100 is UNRESOLVABLE.
|
||||
```
|
||||
|
||||
**Contract**: a SmartWAL reference is resolvable if and only if no
|
||||
later write has overwritten that LBA.
|
||||
|
||||
**For recovery**: fine — last-writer-wins, only latest per LBA matters.
|
||||
|
||||
**For replication catch-up**: NOT fine — catch-up needs every write in
|
||||
LSN order including overwritten versions. SmartWAL cannot provide this.
|
||||
This is the fundamental trade-off (see Q5).
|
||||
|
||||
### Q4: What do GC / pin / retention need?
|
||||
|
||||
| Consumer | Needs old WAL records? | SmartWAL answer |
|
||||
|----------|----------------------|-----------------|
|
||||
| Crash recovery | Only latest per LBA | Ring buffer sufficient |
|
||||
| Replication catch-up | Cannot use WAL (no data) | Dirty map + extent instead |
|
||||
| Rebuild | No — reads extent | Dirty map provides LBA set |
|
||||
| Scrub | No — reads extent | WAL CRCs for verification |
|
||||
|
||||
**Result**: WAL retention pins are eliminated. The ring buffer has no
|
||||
external consumers that need old records. GC is automatic (ring
|
||||
overwrites old slots).
|
||||
|
||||
**The dirty map becomes load-bearing**: it must track every LBA changed
|
||||
since each replica's last sync point. If lost on crash, must be
|
||||
rebuildable from WAL records or default to "rebuild all."
|
||||
|
||||
### Q5: Impact on catch-up / rebuild recoverability class
|
||||
|
||||
Current three-state model:
|
||||
```
|
||||
replicaFlushedLSN → in-sync → KeepUp
|
||||
→ gap < WAL retained → CatchUp (replay WAL entries)
|
||||
→ gap > WAL retained → Rebuild (full extent copy)
|
||||
```
|
||||
|
||||
SmartWAL collapses to two states:
|
||||
```
|
||||
replicaFlushedLSN → in-sync → KeepUp (live ship from write buffer)
|
||||
→ behind → DeltaSync (ship dirty LBAs from extent)
|
||||
```
|
||||
|
||||
No catch-up vs rebuild distinction. Both are "send dirty blocks from
|
||||
extent." Cost scales with dirty LBA count, not WAL gap size.
|
||||
|
||||
**Trade-off**: current catch-up replays WAL entries in order (preserving
|
||||
write history). SmartWAL delta-sync ships current extent state (loses
|
||||
write history). For block storage, history doesn't matter — only the
|
||||
latest version per LBA counts.
|
||||
|
||||
### Q6: What is algorithm-only vs engine-aware?
|
||||
|
||||
**Algorithm-only (no engine changes)**:
|
||||
- Extent-first write path
|
||||
- 32-byte metadata WAL record format
|
||||
- SyncCache barrier ordering
|
||||
- Ring buffer management
|
||||
- Crash recovery (scan + CRC verify)
|
||||
- Concurrent write safety
|
||||
|
||||
**Requires engine awareness**:
|
||||
|
||||
| Change | Why |
|
||||
|--------|-----|
|
||||
| Shipper reads from extent, not WAL | Catch-up replay changes: can't replay WAL, must ship dirty LBAs from extent |
|
||||
| RecoveryDriver / PlanRecovery | No `IsRecoverable(startLSN, endLSN)`. Check dirty map size instead. No catch-up vs rebuild — always delta-sync |
|
||||
| Dirty map as persistent state | Must survive crash or be rebuildable. Becomes replication source of truth |
|
||||
| Flusher eliminated | No WAL→extent copy. SyncCache replaces flusher. GroupCommitter drives SyncCache |
|
||||
| WAL retention pins eliminated | No consumers need old records. Shipper retention floor gone |
|
||||
| RebuildSourceDecision simplified | No snapshot-tail vs full-base. Always delta from extent |
|
||||
|
||||
**The key engine decision**: the engine currently models replication as
|
||||
an ordered LSN stream. SmartWAL doesn't change live shipping (writes
|
||||
still shipped with full data at write time via G4). But recovery changes
|
||||
from "replay WAL from LSN X" to "ship dirty LBAs from extent." The
|
||||
engine's recovery/session framework needs to understand extent-based
|
||||
delta sync instead of WAL-based ordered replay.
|
||||
@@ -0,0 +1,228 @@
|
||||
# V2 Acceptance Evidence Map
|
||||
|
||||
Date: 2026-04-09
|
||||
Status: active
|
||||
|
||||
## Purpose
|
||||
|
||||
This note maps the current V2 evidence across three layers:
|
||||
|
||||
1. protocol/simulation proof
|
||||
2. `weed/` component and server proof
|
||||
3. real-hardware operational proof
|
||||
|
||||
It is intentionally not another scenario backlog. Its goal is to answer:
|
||||
|
||||
1. what is already proven
|
||||
2. where the proof lives
|
||||
3. what is still missing enough that it should drive next work
|
||||
|
||||
## 1. Protocol And Simulation Coverage
|
||||
|
||||
Primary design references:
|
||||
|
||||
1. `sw-block/design/v2-acceptance-criteria.md`
|
||||
2. `sw-block/design/v2_scenarios.md`
|
||||
|
||||
Current state:
|
||||
|
||||
1. `A1` through `A12` are defined at the protocol/design layer in `v2-acceptance-criteria.md`.
|
||||
2. `v2_scenarios.md` currently marks `S1` through `S20` as covered in `distsim` and related model/protocol tests.
|
||||
3. This means the broad semantic matrix is already mostly closed at the simulation layer.
|
||||
|
||||
What this proves:
|
||||
|
||||
1. committed-prefix and lineage rules are modeled
|
||||
2. stale epoch and stale traffic rejection are modeled
|
||||
3. failover, restart, partition, and mixed-state rules are modeled
|
||||
4. restart-during-catchup and restart-during-rebuild are modeled
|
||||
|
||||
What this does not prove by itself:
|
||||
|
||||
1. real `weed/` runtime integration
|
||||
2. real `BlockVol` data installation and WAL replay behavior
|
||||
3. control-plane convergence latency on real heartbeat paths
|
||||
4. operational comparisons between `V1`, `V1.5`, and `V2`
|
||||
|
||||
## 2. weed Runtime And Component Coverage
|
||||
|
||||
These tests provide engine-to-runtime and storage/backend proof beyond the pure protocol layer.
|
||||
|
||||
### Component-Level Proof
|
||||
|
||||
Relevant paths:
|
||||
|
||||
1. `weed/storage/blockvol/test/component/rebuild_failover_rejoin_test.go`
|
||||
2. `weed/storage/blockvol/test/component/rebuild_matrix_gaps_test.go`
|
||||
3. `weed/storage/blockvol/test/component/fast_rejoin_catchup_test.go`
|
||||
4. `weed/storage/blockvol/test/component/rebuild_e2e_test.go`
|
||||
5. `weed/storage/blockvol/test/component/rebuild_realistic_test.go`
|
||||
|
||||
Current high-value coverage:
|
||||
|
||||
1. failover -> old primary rejoins -> rebuild -> data converges
|
||||
2. stale replica restart beyond retained WAL -> rebuild
|
||||
3. connection drop mid-base -> partial rebuild fails closed -> fresh rebuild converges
|
||||
4. quick rejoin with retained WAL -> catch-up chosen instead of rebuild
|
||||
5. rebuild transport and realistic volume rebuild cases
|
||||
|
||||
New explicit complement added in this pass:
|
||||
|
||||
1. `TestFastRejoinCatchUp_FailoverRejoinUsesRetainedWAL`
|
||||
- proves the old primary can return after failover and still use bounded catch-up when retained WAL covers the gap
|
||||
- proves the real shipper catch-up path converges without starting rebuild
|
||||
|
||||
### Server-Level Recovery Proof
|
||||
|
||||
Relevant path:
|
||||
|
||||
1. `weed/server/block_recovery_test.go`
|
||||
|
||||
Current high-value coverage:
|
||||
|
||||
1. live-path catch-up planning and execution
|
||||
2. fact-driven escalation from catch-up to rebuild
|
||||
3. probe-driven rebuild session installation
|
||||
4. command-driven rebuild start on the live path
|
||||
5. fail-closed behavior when no fresh `start_rebuild` command exists
|
||||
|
||||
New explicit retry-stability proof added in this pass:
|
||||
|
||||
1. `TestP16B_RunRebuild_ExecutionFailureAllowsFreshRetry`
|
||||
- proves a rebuild execution failure does not wedge the sender/runtime
|
||||
- proves a fresh rebuild session can be installed afterward
|
||||
- proves the second rebuild can still reach `InSync`
|
||||
|
||||
## 3. Integration And Scenario Evidence
|
||||
|
||||
Relevant paths:
|
||||
|
||||
1. `weed/storage/blockvol/testrunner/scenarios/internal/v2-fast-rejoin-catchup.yaml`
|
||||
2. `weed/storage/blockvol/testrunner/scenarios/internal/v2-rebuild-failure-retry.yaml`
|
||||
3. `weed/storage/blockvol/testrunner/scenarios/internal/v2-rebuild-rejoin.yaml`
|
||||
|
||||
Current status:
|
||||
|
||||
1. `v2-rebuild-rejoin.yaml` is the full real-system rebuild-rejoin story.
|
||||
2. `v2-fast-rejoin-catchup.yaml` now asserts that rejoin converges via catch-up and that no remote rebuild start was sent.
|
||||
3. `v2-rebuild-failure-retry.yaml` now asserts that the retry path includes more than one rebuild attempt, instead of only checking the final data state.
|
||||
|
||||
Why these matter:
|
||||
|
||||
1. they bridge the gap between narrow unit/component tests and real multi-process orchestration
|
||||
2. they expose heartbeat, assignment, probe, and retry timing effects that protocol tests do not see
|
||||
|
||||
## 4. Real-Hardware Evidence
|
||||
|
||||
Current proved run:
|
||||
|
||||
1. `v2-rebuild-rejoin`
|
||||
|
||||
Observed result:
|
||||
|
||||
1. full lifecycle passed end-to-end on real hardware
|
||||
2. create -> write -> failover -> write more -> old primary rejoin -> rebuild -> verify
|
||||
3. rebuilt replica preserved both the pre-failover and post-failover checkpoints
|
||||
|
||||
Important interpretation:
|
||||
|
||||
1. the data-plane rebuild itself was fast
|
||||
2. the larger wall-clock recovery time was dominated by control-plane cadence:
|
||||
heartbeat propagation, assignment refresh, probe timing, and master-visible healthy convergence
|
||||
|
||||
So the current real-hardware result proves:
|
||||
|
||||
1. the primary-direct remote rebuild route works
|
||||
2. the replica-side install path works
|
||||
3. data continuity survives the full failover/rejoin cycle
|
||||
|
||||
It does not yet fully characterize:
|
||||
|
||||
1. control-plane latency breakdown as an explicit acceptance artifact
|
||||
2. fast-rejoin catch-up on real hardware as distinct from rebuild-rejoin
|
||||
3. RF=3 multi-replica operational behavior on hardware
|
||||
|
||||
## 5. Remaining Meaningful Gaps
|
||||
|
||||
These are the remaining gaps worth prioritizing. They are not generic matrix expansion.
|
||||
|
||||
### Gap 1: Operational Catch-Up After Failover
|
||||
|
||||
Needed:
|
||||
|
||||
1. explicit real integration evidence for old-primary quick return after failover where retained WAL is still sufficient
|
||||
|
||||
Why it matters:
|
||||
|
||||
1. it is the natural complement to rebuild-rejoin
|
||||
2. it proves the system does not over-escalate to rebuild when bounded replay is enough
|
||||
|
||||
Current state:
|
||||
|
||||
1. component proof exists
|
||||
2. scenario proof now exists at the testrunner level
|
||||
3. hardware-grade acceptance evidence for this exact path is still worth collecting
|
||||
|
||||
### Gap 2: Control-Plane Convergence Visibility
|
||||
|
||||
Needed:
|
||||
|
||||
1. explicit breakdown for restart -> first heartbeat
|
||||
2. assignment refresh -> probe start
|
||||
3. probe start -> session accepted
|
||||
4. session completed -> master healthy observed
|
||||
|
||||
Why it matters:
|
||||
|
||||
1. current operational latency is dominated by control-plane cadence, not data transfer
|
||||
2. optimization work should target measured convergence phases, not the rebuild data plane blindly
|
||||
|
||||
### Gap 3: V1 / V1.5 / V2 Operational Comparisons
|
||||
|
||||
Still called out by `v2_scenarios.md`:
|
||||
|
||||
1. changed-address restart
|
||||
2. same-address transient outage
|
||||
3. slow reassignment recovery
|
||||
|
||||
Why it matters:
|
||||
|
||||
1. these are operational regressions/improvements, not just protocol semantics
|
||||
2. they explain why V2 is better, not only that V2 is internally consistent
|
||||
|
||||
### Gap 4: RF=3 Operational Recovery Evidence
|
||||
|
||||
Needed:
|
||||
|
||||
1. targeted proof that the same recovery rules hold when more than one replica exists
|
||||
2. especially for one stale replica, one healthy replica, and one active primary during rejoin/rebuild
|
||||
|
||||
Why it matters:
|
||||
|
||||
1. many current proofs are strongest in RF=2
|
||||
2. the design explicitly supports RF=3 and per-replica recovery facts
|
||||
|
||||
## 6. Recommended Next Evidence Work
|
||||
|
||||
Order:
|
||||
|
||||
1. collect one hardware-grade fast-rejoin catch-up run
|
||||
2. add explicit control-plane latency timestamps to recovery scenarios
|
||||
3. add the highest-value `V1` / `V1.5` / `V2` operational comparison cases
|
||||
4. add one RF=3 recovery acceptance slice before broadening anything else
|
||||
|
||||
## 7. Bottom Line
|
||||
|
||||
Current position:
|
||||
|
||||
1. protocol matrix: broadly covered
|
||||
2. `weed` runtime/component proof: strong and improving
|
||||
3. real-hardware rebuild-rejoin proof: achieved
|
||||
|
||||
So the next work should focus on:
|
||||
|
||||
1. the fast-rejoin catch-up operational path
|
||||
2. control-plane convergence evidence
|
||||
3. a small set of operational comparison cases
|
||||
|
||||
The project does not currently need a broad new matrix. It needs sharper proof on the remaining operational edges.
|
||||
@@ -0,0 +1,196 @@
|
||||
# V2 Integration Matrix
|
||||
|
||||
Date: 2026-04-08
|
||||
Status: active
|
||||
|
||||
## Purpose
|
||||
|
||||
This matrix maps real end-to-end integration scenarios across common and edge
|
||||
product paths. It is the operational counterpart to `v2-validation-matrix.md`:
|
||||
|
||||
- **Validation matrix**: is this capability covered at all?
|
||||
- **Integration matrix**: have we exercised enough real scenarios with real data,
|
||||
real topology, real failure, and real final-state validation?
|
||||
|
||||
## Cross-Link Rule
|
||||
|
||||
Use each integration row as the last step in a chain:
|
||||
|
||||
`protocol -> capability tier -> validation row -> integration scenario -> evidence`
|
||||
|
||||
The extra columns below make that chain explicit:
|
||||
|
||||
1. `Capability` points back to `v2-capability-map.md`
|
||||
2. `Validation` points back to `v2-validation-matrix.md`
|
||||
3. `Proof tier` states whether the row is currently covered by `component`,
|
||||
`integration`, or `hardware` evidence
|
||||
|
||||
## Inventory: Existing V1/V1.5 Integration Scenarios
|
||||
|
||||
The testrunner has 72 internal + 44 external scenarios. Below is the
|
||||
categorized inventory with V2 reuse assessment.
|
||||
|
||||
### Category 1: Bootstrap / Smoke (reuse unchanged for V2)
|
||||
|
||||
| Scenario | File | What it tests | V2 reuse |
|
||||
|---|---|---|---|
|
||||
| `smoke-iscsi` | `scenarios/smoke-iscsi.yaml` | Basic iSCSI create + write + read | Reuse unchanged |
|
||||
| `p0-validation` | `scenarios/internal/p0-validation.yaml` | Minimal cluster + volume lifecycle | Reuse unchanged |
|
||||
| `recovery-bootstrap-closure` | `scenarios/internal/recovery-bootstrap-closure.yaml` | Stage 0: create → fence → publish_healthy | Reuse unchanged |
|
||||
| `coord-smoke-iscsi` | `scenarios/internal/coord-smoke-iscsi.yaml` | Coordinator mode smoke | Reuse unchanged |
|
||||
|
||||
### Category 2: HA / Failover (reuse with V2 observation)
|
||||
|
||||
| Scenario | File | What it tests | V2 reuse |
|
||||
|---|---|---|---|
|
||||
| `recovery-baseline-failover` | `scenarios/internal/recovery-baseline-failover.yaml` | Auto-failover + data continuity | Reuse — add V2 projection checks |
|
||||
| `ha-failover` | `scenarios/ha-failover.yaml` | HA failover basic | Reuse with adapter |
|
||||
| `ha-full-lifecycle` | `scenarios/ha-full-lifecycle.yaml` | Create → write → failover → verify | Reuse with adapter |
|
||||
| `ha-io-continuity` | `scenarios/ha-io-continuity.yaml` | I/O continuity through failover | Reuse unchanged |
|
||||
| `ha-rebuild` | `scenarios/ha-rebuild.yaml` | Rebuild after failover | Reuse with V2 session |
|
||||
| `ha-rf3-failover` | `scenarios/ha-rf3-failover.yaml` | RF3 failover | Reuse with adapter |
|
||||
| `suite-ha-failover` | `scenarios/internal/suite-ha-failover.yaml` | HA suite | Reuse with adapter |
|
||||
| `cp11b3-manual-promote` | `scenarios/cp11b3-manual-promote.yaml` | Manual promote + rejoin | Reuse with adapter |
|
||||
| `cp11b3-auto-failover` | `scenarios/cp11b3-auto-failover.yaml` | Auto failover | Reuse unchanged |
|
||||
| `ha-multi-client-failover` | `scenarios/internal/ha-multi-client-failover.yaml` | Multi-client during failover | Reuse unchanged |
|
||||
| `ha-read-load-failover` | `scenarios/internal/ha-read-load-failover.yaml` | Read load during failover | Reuse unchanged |
|
||||
| `ha-nvme-failover` | `scenarios/internal/ha-nvme-failover.yaml` | NVMe-oF failover | Reuse unchanged |
|
||||
|
||||
### Category 3: Rebuild / Recovery (key V2 upgrade area)
|
||||
|
||||
| Scenario | File | What it tests | V2 reuse |
|
||||
|---|---|---|---|
|
||||
| `ha-rebuild` | `scenarios/ha-rebuild.yaml` | V1 rebuild path | Replace with V2 session-controlled rebuild |
|
||||
| `ha-failover-during-rebuild` | `scenarios/internal/ha-failover-during-rebuild.yaml` | Failover during active rebuild | Replace — V2 rebuild is different path |
|
||||
| `ha-wal-pressure-failover` | `scenarios/internal/ha-wal-pressure-failover.yaml` | WAL pressure during failover | Reuse — now with CP13-6 disabled |
|
||||
| `recovery-baseline-crash` | `scenarios/internal/recovery-baseline-crash.yaml` | Crash recovery | Reuse unchanged |
|
||||
| `recovery-baseline-restart` | `scenarios/internal/recovery-baseline-restart.yaml` | Restart recovery | Reuse unchanged |
|
||||
| `recovery-baseline-partition` | `scenarios/internal/recovery-baseline-partition.yaml` | Network partition recovery | Reuse with adapter |
|
||||
| `robust-reconnect-catchup` | `scenarios/internal/robust-reconnect-catchup.yaml` | Reconnect + catch-up | Reuse with V2 session |
|
||||
| `robust-gap-failover` | `scenarios/internal/robust-gap-failover.yaml` | Gap-based failover | Reuse with adapter |
|
||||
| `robust-shipper-lifecycle` | `scenarios/internal/robust-shipper-lifecycle.yaml` | Shipper state machine | Replace with V2 session lifecycle |
|
||||
| `robust-shipper-reconnect` | `scenarios/internal/robust-shipper-reconnect.yaml` | Shipper reconnect | Replace with V2 session |
|
||||
| `robust-slow-replica` | `scenarios/internal/robust-slow-replica.yaml` | Slow replica handling | Reuse — CP13-6 disabled |
|
||||
| `ec3-fast-reconnect-skips-failover` | `scenarios/internal/ec3-fast-reconnect-skips-failover.yaml` | Fast reconnect | Reuse unchanged |
|
||||
| `ec5-wrong-primary-master-restart` | `scenarios/internal/ec5-wrong-primary-master-restart.yaml` | Wrong primary after master restart | Reuse unchanged |
|
||||
|
||||
### Category 4: Stability / Chaos (reuse unchanged)
|
||||
|
||||
| Scenario | File | What it tests | V2 reuse |
|
||||
|---|---|---|---|
|
||||
| `cp85-chaos-disk-full` | `scenarios/cp85-chaos-disk-full.yaml` | Disk full fault | Reuse unchanged |
|
||||
| `cp85-chaos-partition` | `scenarios/cp85-chaos-partition.yaml` | Network partition chaos | Reuse unchanged |
|
||||
| `cp85-chaos-primary-kill-loop` | `scenarios/cp85-chaos-primary-kill-loop.yaml` | Repeated primary kills | Reuse unchanged |
|
||||
| `cp85-chaos-replica-kill-loop` | `scenarios/cp85-chaos-replica-kill-loop.yaml` | Repeated replica kills | Reuse unchanged |
|
||||
| `cp85-role-flap` | `scenarios/cp85-role-flap.yaml` | Rapid role changes | Reuse with adapter |
|
||||
| `cp85-session-storm` | `scenarios/cp85-session-storm.yaml` | Session storm | Reuse unchanged |
|
||||
| `cp85-snapshot-stress` | `scenarios/cp85-snapshot-stress.yaml` | Snapshot stress | Reuse unchanged |
|
||||
| `stable-degraded-mode` | `scenarios/internal/stable-degraded-mode.yaml` | Degraded mode stability | Reuse unchanged |
|
||||
| `stable-degraded-best-effort` | `scenarios/internal/stable-degraded-best-effort.yaml` | Best-effort degraded | Reuse unchanged |
|
||||
| `stable-degraded-sync-quorum` | `scenarios/internal/stable-degraded-sync-quorum.yaml` | Sync quorum degraded | Reuse unchanged |
|
||||
|
||||
### Category 5: Performance / Soak (reuse unchanged)
|
||||
|
||||
| Scenario | File | What it tests | V2 reuse |
|
||||
|---|---|---|---|
|
||||
| `cp85-perf-baseline` | `scenarios/cp85-perf-baseline.yaml` | Performance baseline | Reuse unchanged |
|
||||
| `cp103-perf-baseline` | `scenarios/cp103-perf-baseline.yaml` | Phase 10 perf | Reuse unchanged |
|
||||
| `cp84-soak-4h` | `scenarios/cp84-soak-4h.yaml` | 4-hour soak | Reuse unchanged |
|
||||
| `cp85-soak-24h` | `scenarios/cp85-soak-24h.yaml` | 24-hour soak | Reuse unchanged |
|
||||
| `cp103-soak-iscsi-1h` | `scenarios/internal/cp103-soak-iscsi-1h.yaml` | 1-hour iSCSI soak | Reuse unchanged |
|
||||
| `cp103-soak-nvme-1h` | `scenarios/internal/cp103-soak-nvme-1h.yaml` | 1-hour NVMe soak | Reuse unchanged |
|
||||
| `benchmark-pgbench` | `scenarios/internal/benchmark-pgbench.yaml` | pgbench workload | Reuse unchanged |
|
||||
|
||||
### Category 6: Snapshot / Expand / Operations (reuse unchanged)
|
||||
|
||||
| Scenario | File | What it tests | V2 reuse |
|
||||
|---|---|---|---|
|
||||
| `cp83-snapshot-expand` | `scenarios/cp83-snapshot-expand.yaml` | Snapshot + expand | Reuse unchanged |
|
||||
| `cp85-expand-failover` | `scenarios/cp85-expand-failover.yaml` | Expand during failover | Reuse unchanged |
|
||||
| `cp11a4-snapshot-export-import` | `scenarios/internal/cp11a4-snapshot-export-import.yaml` | Snapshot export/import | Reuse unchanged |
|
||||
| `op-csi-lifecycle` | `scenarios/op-csi-lifecycle.yaml` | CSI full lifecycle | Reuse unchanged |
|
||||
| `op-failure-injection` | `scenarios/op-failure-injection.yaml` | Operator failure injection | Reuse unchanged |
|
||||
| `op-mini-soak` | `scenarios/op-mini-soak.yaml` | Mini soak test | Reuse unchanged |
|
||||
| `lease-expiry-write-gate` | `scenarios/lease-expiry-write-gate.yaml` | Lease/write gate | Reuse unchanged |
|
||||
| `consistency-epoch` | `scenarios/consistency-epoch.yaml` | Epoch consistency | Reuse unchanged |
|
||||
| `consistency-lease` | `scenarios/consistency-lease.yaml` | Lease consistency | Reuse unchanged |
|
||||
|
||||
## V2 Integration Matrix
|
||||
|
||||
### Rebuild Integration
|
||||
|
||||
| ID | Stage | Capability | Validation | Proof tier | Scenario | Topology | Entry trigger | Workload | Failure | Expected path | Data validation | Status | File | Evidence |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| `I-R1` | Rebuild | Tier 3 | `R1` | component | Fresh replica join | 2-node RF2 | Master assigns new replica | 50+ blocks pre-existing | None | syncAck → rebuild → converge | Block-by-block compare | Covered | `rebuild_matrix_gaps_test.go` | `TestRebuild_R1_SyncAckDrivenDecision` — TCP session control |
|
||||
| `I-R2` | Rebuild | Tier 3 | `R2` | component | Large rebuild with live writes | 2-node RF2 | Manual rebuild trigger | 1GB + 5000 live writes | None | Two-line rebuild, flusher active | SHA-256 extent match | Covered | `rebuild_primary_initiated_test.go` | `TestRebuild_PrimaryInitiated_1GB_WithLiveWrites` |
|
||||
| `I-R3` | Rebuild | Tier 3 | `R3` | component | Stale replica restart | 2-node RF2 | Replica restart with old data | 200+ blocks, WAL recycled | Replica crash + restart | syncAck → rebuild → converge | Block-by-block compare | Covered | `rebuild_matrix_gaps_test.go` | `TestRebuild_R3_StaleReplicaRestartBeyondWAL` |
|
||||
| `I-R4` | Rebuild | Tier 3 | `R10`, `V8` | component | Failover rejoin | 2-node RF2 | Old primary restarts as replica | 200 initial + 500 post-failover | Primary kill | Role swap → rebuild → CRC match | SHA-256 extent match | Covered | `rebuild_failover_rejoin_test.go` | `TestRebuild_R10_FailoverRejoinRebuild` |
|
||||
| `I-R5` | Rebuild | Tier 3 | `R5` | component | Mid-rebuild disconnect | 2-node RF2 | Connection drop at 50% | 100 blocks | TCP kill mid-transfer | Cancel → fresh rebuild → converge | Block-by-block compare | Covered | `rebuild_matrix_gaps_test.go` | `TestRebuild_R5_ConnectionDropMidBase` |
|
||||
| `I-R6` | Rebuild | Tier 3 | `R11` | component | Divergent replica overwrite | 2-node RF2 | Replica has different data | 100 blocks divergent | None | Full overwrite → CRC match | SHA-256 extent match | Covered | `rebuild_r11_r12_test.go` | `TestRebuild_R11_DivergentReplicaFullOverwrite` |
|
||||
| `I-R7` | Rebuild | Tier 3 | `R12` | component | Crash mid-rebuild restart | 2-node RF2 | Crash after 50% base + partial WAL | 50 blocks | Replica crash mid-session | Fresh session → converge | Block-by-block compare | Covered | `rebuild_r11_r12_test.go` | `TestRebuild_R12_CrashMidRebuild_FreshSessionConverges` |
|
||||
| `I-R8` | Rebuild | Tier 3 | `R2`, `R10`, `V8` | hardware | Hardware rebuild via runner | 2-node m01/m02 | sw-test-runner suite | 1GB volume | Real hardware | Full rebuild → extent match | Extent compare on runner path | Needs V2 scenario | — | V1 scenario exists, needs V2 session control |
|
||||
|
||||
### Restore Integration
|
||||
|
||||
| ID | Stage | Capability | Validation | Proof tier | Scenario | Topology | Entry trigger | Workload | Failure | Expected path | Data validation | Status | File | Evidence |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| `I-S1` | Restore | Tier 5 | `S5` | component | Snapshot-tail rebuild | 2-node RF2 | Snapshot at LSN N + WAL N+1..M | 100 base + 50 tail | None | Base + tail converge | Block-by-block compare | Covered | `restore_ready_test.go` | `TestRestore_S5_SnapshotTailRebuild` |
|
||||
| `I-S2` | Restore | Tier 5 | `S7` | component | Crash between base and tail | 2-node RF2 | Crash after base, before tail complete | 40 blocks | Replica crash | Fresh rebuild → converge | Block-by-block compare | Covered | `restore_ready_test.go` | `TestRestore_S7_CrashBetweenBaseAndTail` |
|
||||
| `I-S3` | Restore | Tier 5 | `S8` | component | Concurrent writes during restore | 2-node RF2 | Writes during snapshot base copy | 80 base + 30 live | None | Bitmap protects live writes | SHA-256 compare | Covered | `restore_ready_test.go` | `TestRestore_S8_SnapshotUnderConcurrentWrites` |
|
||||
| `I-S4` | Restore | Tier 5 | `S1`-`S4` | hardware | Hardware snapshot export/import | 2-node m01/m02 | Runner scenario | Real volume | None | Export → import → verify | Extent compare | Covered (V1) | `cp11a4-snapshot-export-import.yaml` | V1 scenario |
|
||||
|
||||
### V2 Protocol Integration
|
||||
|
||||
| ID | Stage | Capability | Validation | Proof tier | Scenario | Topology | Entry trigger | Workload | Failure | Expected path | Data validation | Status | File | Evidence |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| `I-V1` | V2 | Tier 2 | `V1` | hardware | Bootstrap to publish_healthy | 2-node m01/m02 | Create RF2 sync_all | 4K write + fsync | None | Bootstrap fence → publish_healthy | Mode = publish_healthy | Covered | `recovery-bootstrap-closure.yaml` | Stage 0 PASS |
|
||||
| `I-V2` | V2 | Tier 2 | `V2` | hardware | Sustained write + barrier | 2-node m01/m02 | fio 10s + dd_write fsync | 10s randwrite + 2MB fsync | None | Barrier succeeds after bootstrap | dd checksum match | Covered | `recovery-baseline-failover.yaml` | Stage 1 32/33 |
|
||||
| `I-V3` | V2 | Tier 3 | `V3` | hardware | Auto-failover | 2-node m01/m02 | Kill primary VS | Pre-write + failover | Primary kill | Master promotes replica | New primary serves I/O | Needs fix | `recovery-baseline-failover.yaml` | Stage 1 last failure: master didn't promote |
|
||||
| `I-V4` | V2 | Tier 3 / Tier 6 | `V8` | integration | Failover rejoin full stack | 2-node m01/m02 | Old primary restarts | Post-failover writes | Primary kill + restart | Rejoin → rebuild → health surfaces correct | Projection + extent | Missing | — | Needs V8 integration scenario |
|
||||
| `I-V5` | V2 | Tier 3 / Tier 8 | `V11` | integration | Long-haul write through recovery | 2-node m01/m02 | Sustained fio + fault mid-way | 30min+ workload | Replica kill + restart mid-run | Recovery → resume → final verify | SHA-256 extent match | Missing | — | V11 integration scenario |
|
||||
| `I-V6` | V2 | Tier 6 | `V13` | integration | Observability coherence | 2-node m01/m02 | Recovery event | During rebuild/catchup | None | Logs + projection + debug agree | Surface comparison | Missing | — | V13 integration scenario |
|
||||
|
||||
### Chaos / Stability Integration (V1 reuse)
|
||||
|
||||
| ID | Stage | Capability | Validation | Proof tier | Scenario | Topology | Entry trigger | Workload | Failure | Expected path | Data validation | Status | File | Evidence |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| `I-C1` | Chaos | Tier 8 | regression reuse | hardware | Repeated primary kills | 2-node m01/m02 | Kill loop | Sustained writes | 5+ kills | Each recovery converges | Data integrity | Covered (V1) | `cp85-chaos-primary-kill-loop.yaml` | V1 scenario |
|
||||
| `I-C2` | Chaos | Tier 8 | regression reuse | hardware | Repeated replica kills | 2-node m01/m02 | Kill loop | Sustained writes | 5+ kills | Shipper degrades, recovers | Data integrity | Covered (V1) | `cp85-chaos-replica-kill-loop.yaml` | V1 scenario |
|
||||
| `I-C3` | Chaos | Tier 8 | regression reuse | hardware | Network partition | 2-node m01/m02 | iptables partition | Sustained writes | 30s partition | Degraded → recover | Data integrity | Covered (V1) | `cp85-chaos-partition.yaml` | V1 scenario |
|
||||
| `I-C4` | Chaos | Tier 8 | regression reuse | hardware | Disk full | 2-node m01/m02 | fallocate fill | Writes during fill | Disk full | Fail-closed, recover after space | No corruption | Covered (V1) | `cp85-chaos-disk-full.yaml` | V1 scenario |
|
||||
|
||||
### Performance Integration (V1 reuse)
|
||||
|
||||
| ID | Stage | Capability | Validation | Proof tier | Scenario | Topology | Entry trigger | Workload | Failure | Expected path | Data validation | Status | File | Evidence |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| `I-P1` | Perf | Tier 8 | regression reuse | hardware | iSCSI baseline | 2-node m01/m02 | Benchmark run | fio sweep | None | IOPS/latency recorded | Baseline comparison | Covered (V1) | `cp85-perf-baseline.yaml` | V1 scenario |
|
||||
| `I-P2` | Perf | Tier 8 | regression reuse | hardware | NVMe-oF baseline | 2-node m01/m02 | Benchmark run | fio sweep | None | IOPS/latency recorded | Baseline comparison | Covered (V1) | `cp103-perf-baseline.yaml` | V1 scenario |
|
||||
| `I-P3` | Perf | Tier 8 | regression reuse | hardware | 4-hour soak | 2-node m01/m02 | Long-running write | 4h sustained | None | No degradation | Stable metrics | Covered (V1) | `cp84-soak-4h.yaml` | V1 scenario |
|
||||
|
||||
## Summary
|
||||
|
||||
| Stage | Total | Covered | Needs V2 scenario | Missing |
|
||||
|---|---|---|---|---|
|
||||
| Rebuild | 8 | 7 (component) | 1 (I-R8: hardware) | 0 |
|
||||
| Restore | 4 | 4 | 0 | 0 |
|
||||
| V2 Protocol | 6 | 2 (hardware) | 1 (I-V3: fix) | 3 (I-V4, I-V5, I-V6) |
|
||||
| Chaos | 4 | 4 (V1 reuse) | 0 | 0 |
|
||||
| Performance | 3 | 3 (V1 reuse) | 0 | 0 |
|
||||
| **Total** | **25** | **20** | **2** | **3** |
|
||||
|
||||
## V1 Scenarios That Need V2 Adaptation
|
||||
|
||||
These V1 scenarios exist and work but need modification for V2:
|
||||
|
||||
1. `ha-rebuild.yaml` → replace V1 direct rebuild with V2 session-controlled path
|
||||
2. `ha-failover-during-rebuild.yaml` → adapt for V2 rebuild session lifecycle
|
||||
3. `robust-shipper-lifecycle.yaml` → replace with V2 session lifecycle test
|
||||
4. `robust-shipper-reconnect.yaml` → replace with V2 sync/recovery path
|
||||
5. `cp85-role-flap.yaml` → add V2 projection assertions
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Fix `I-V3` (auto-failover) — the Stage 1 last remaining failure
|
||||
2. Create `I-V4` (failover rejoin full stack) — the V8 integration scenario
|
||||
3. Adapt `ha-rebuild.yaml` for V2 session control
|
||||
4. Run V1 chaos/stability scenarios on V2 binary to verify no regressions
|
||||
@@ -0,0 +1,780 @@
|
||||
# V3 Clean Recovery Draft
|
||||
|
||||
Date: 2026-04-09
|
||||
Status: draft
|
||||
|
||||
## 1. Why V3
|
||||
|
||||
`V2` has already proven several important things:
|
||||
|
||||
1. the primary/replica recovery model is workable
|
||||
2. rebuild and catch-up can be driven from bounded facts
|
||||
3. master and primary roles can be separated more clearly than in the original `V1` logic
|
||||
4. execution muscles in `weed/` can increasingly be treated as runtime adapters rather than semantic authority
|
||||
|
||||
But `V2` also exposed a class of problems that should not be fixed forever by local patches:
|
||||
|
||||
1. heartbeat timing affects recovery behavior too much
|
||||
2. transport failures are too easily over-interpreted as semantic failures
|
||||
3. event ordering still changes recovery paths in some cases
|
||||
4. completion/failure authority can split across ack arrival, executor unwind, and projection updates
|
||||
5. engine changes can drift toward workaround states and temporary semantics
|
||||
|
||||
This draft proposes a `V3` direction that is **not** a feature expansion.
|
||||
|
||||
`V3` is a semantic cleanup effort:
|
||||
|
||||
1. reduce time-polluted semantics
|
||||
2. minimize engine authority to the true problem space
|
||||
3. keep execution in runtime/backend muscles
|
||||
4. establish a strict audit rule for future engine changes
|
||||
|
||||
## 2. Core Thesis
|
||||
|
||||
`V3` should be built around one statement:
|
||||
|
||||
**Timers trigger observation. Facts determine semantics.**
|
||||
|
||||
Expanded:
|
||||
|
||||
1. timeouts, heartbeats, retry loops, and poll intervals may trigger a reconcile
|
||||
2. they must not by themselves define recovery truth
|
||||
3. recovery truth must come from bounded facts such as `epoch`, `endpointVersion`, `sessionID`, `R`, `S`, `H`, `DurableLSN`, and `AckKind`
|
||||
|
||||
Where:
|
||||
|
||||
1. `R` = replica achieved/applied boundary
|
||||
2. `S` = primary retained start boundary
|
||||
3. `H` = primary head/target boundary
|
||||
|
||||
## 3. Scope Of The Engine
|
||||
|
||||
The engine should be allowed to grow only when the problem space itself requires it.
|
||||
|
||||
The question is not whether the engine is 500 lines or 2500 lines.
|
||||
The question is whether the engine still only solves semantic problems.
|
||||
|
||||
### 3.1 Engine owns semantic truth
|
||||
|
||||
Engine-owned truth domains:
|
||||
|
||||
1. identity truth
|
||||
2. reachability truth
|
||||
3. recovery truth
|
||||
4. session validity truth
|
||||
5. durability/publication truth
|
||||
6. outward projection
|
||||
|
||||
### 3.2 Engine does not own execution details
|
||||
|
||||
The following must stay outside the semantic engine:
|
||||
|
||||
1. TCP connection management
|
||||
2. retry sleep durations
|
||||
3. heartbeat polling cadence
|
||||
4. goroutine scheduling and drain details
|
||||
5. block install mechanics
|
||||
6. WAL streaming mechanics
|
||||
7. storage backend quirks
|
||||
8. log ordering and transport timing workarounds
|
||||
|
||||
## 4. Truth Domains
|
||||
|
||||
`V3` should explicitly separate several truth domains that are too easy to mix.
|
||||
|
||||
### 4.1 Identity Truth
|
||||
|
||||
Authority: `master`
|
||||
|
||||
Contains:
|
||||
|
||||
1. who is primary
|
||||
2. which replica slots exist for the volume
|
||||
3. which `replicaID` occupies each slot
|
||||
4. current endpoint for each replica
|
||||
5. `endpointVersion`
|
||||
6. `epoch`
|
||||
|
||||
Meaning:
|
||||
|
||||
1. who belongs to the roster
|
||||
2. where they are expected to be reached
|
||||
3. which version of membership is authoritative
|
||||
|
||||
### 4.2 Reachability Truth
|
||||
|
||||
Authority: `primary`
|
||||
|
||||
Contains:
|
||||
|
||||
1. reachable / unreachable / probing
|
||||
2. last successful contact
|
||||
3. last probe result
|
||||
4. last endpoint attempted
|
||||
|
||||
Meaning:
|
||||
|
||||
1. can the primary currently talk to this replica transport
|
||||
2. should it keep probing
|
||||
|
||||
It does **not** determine recovery type by itself.
|
||||
|
||||
### 4.3 Recovery Truth
|
||||
|
||||
Authority: `primary`
|
||||
|
||||
Contains:
|
||||
|
||||
1. replica boundary `R`
|
||||
2. primary retained start `S`
|
||||
3. primary head or target `H`
|
||||
4. recovery decision:
|
||||
1. no recovery
|
||||
2. catch-up
|
||||
3. rebuild
|
||||
4. undetermined
|
||||
|
||||
Meaning:
|
||||
|
||||
1. which semantic recovery path is required
|
||||
2. why that path was chosen
|
||||
|
||||
### 4.4 Session / Execution Truth
|
||||
|
||||
Authority: recovery executor/session owner
|
||||
|
||||
Contains:
|
||||
|
||||
1. active `sessionID`
|
||||
2. session kind
|
||||
3. target boundary
|
||||
4. achieved boundary
|
||||
5. running / failed / completed
|
||||
|
||||
Meaning:
|
||||
|
||||
1. which recovery contract is active
|
||||
2. whether that contract actually closed successfully
|
||||
|
||||
### 4.5 Projection
|
||||
|
||||
Authority: derived only
|
||||
|
||||
Contains operator-facing state such as:
|
||||
|
||||
1. `publish_healthy`
|
||||
2. `degraded`
|
||||
3. `needs_rebuild`
|
||||
4. `recovery_in_progress`
|
||||
|
||||
Projection is not semantic authority.
|
||||
It may report truth, but it must never create truth.
|
||||
|
||||
## 5. Role Split
|
||||
|
||||
### 5.1 Master
|
||||
|
||||
Master is responsible for:
|
||||
|
||||
1. liveness of volume servers at topology level
|
||||
2. leader failover and primary assignment
|
||||
3. replica roster membership
|
||||
4. replica endpoint refresh when a VS joins or changes address
|
||||
|
||||
Master is not responsible for:
|
||||
|
||||
1. deciding catch-up vs rebuild
|
||||
2. executing recovery sessions
|
||||
3. interpreting data-plane progress
|
||||
|
||||
### 5.2 Primary
|
||||
|
||||
Primary is responsible for:
|
||||
|
||||
1. transport connectivity to replicas
|
||||
2. collecting recovery facts
|
||||
3. deciding keep-up / catch-up / rebuild
|
||||
4. executing recovery
|
||||
5. re-integrating a replica into the active data path
|
||||
|
||||
Primary is not responsible for:
|
||||
|
||||
1. inventing identity truth
|
||||
2. deciding the replica roster independently of master
|
||||
|
||||
## 6. Minimal Principles
|
||||
|
||||
These principles should be treated as normative.
|
||||
|
||||
### P1. Timers trigger, facts decide
|
||||
|
||||
1. heartbeat timeout may trigger roster reconsideration
|
||||
2. transport timeout may trigger a probe
|
||||
3. ack timeout may trigger a failure or retry path
|
||||
4. but none of these timers define recovery truth directly
|
||||
|
||||
### P2. Single authority per truth domain
|
||||
|
||||
1. identity truth belongs to `master`
|
||||
2. recovery truth belongs to `primary`
|
||||
3. terminal session success/failure belongs to the session/executor close path
|
||||
4. durability truth belongs to barrier/quorum proof
|
||||
|
||||
### P3. Projection is derived only
|
||||
|
||||
1. `healthy`, `degraded`, and `needs_rebuild` are reports
|
||||
2. they must not be used as hidden control inputs
|
||||
|
||||
### P4. Recovery choice must be deterministic
|
||||
|
||||
For the same bounded facts, the system must choose the same recovery path regardless of event order.
|
||||
|
||||
Examples:
|
||||
|
||||
1. if `R >= H`, do not recover
|
||||
2. if `R >= S` and `R < H`, catch-up
|
||||
3. if `R < S`, rebuild
|
||||
|
||||
### P5. Monotonic facts win
|
||||
|
||||
1. old `epoch` must never override new `epoch`
|
||||
2. old `sessionID` must never override new `sessionID`
|
||||
3. old `endpointVersion` must never override new endpoint truth
|
||||
4. old completion/failure signals must never roll back newer semantic truth
|
||||
|
||||
### P6. Transport failure is not semantic overreach
|
||||
|
||||
1. ship/barrier/probe failure may mark reachability loss
|
||||
2. it must not by itself decide rebuild
|
||||
3. rebuild requires bounded recovery facts
|
||||
|
||||
### P7. Execution is a muscle
|
||||
|
||||
1. streaming blocks
|
||||
2. replaying WAL
|
||||
3. applying blocks
|
||||
4. wiring sessions
|
||||
|
||||
These are implementation muscles, not semantic authority.
|
||||
|
||||
### P8. Engine size is not the target
|
||||
|
||||
1. the engine may grow if the problem space requires it
|
||||
2. a larger engine is acceptable if it remains semantically clean
|
||||
3. a smaller engine is not better if it hides complexity in ad-hoc workarounds
|
||||
|
||||
## 7. Anti-Patterns
|
||||
|
||||
The following patterns are explicitly rejected in `V3`.
|
||||
|
||||
### A1. Heartbeat timing defines recovery semantics
|
||||
|
||||
Examples:
|
||||
|
||||
1. missed heartbeat means rebuild is now required
|
||||
2. reconnect heartbeat timing decides whether recovery occurs at all
|
||||
|
||||
Rejected because:
|
||||
|
||||
1. heartbeat is identity observation latency, not data truth
|
||||
|
||||
### A2. Barrier failure directly defines rebuild
|
||||
|
||||
Examples:
|
||||
|
||||
1. one failed barrier implies `needs_rebuild`
|
||||
2. one transport error implies semantic replacement
|
||||
|
||||
Rejected because:
|
||||
|
||||
1. transport loss and recovery type are different truth domains
|
||||
|
||||
### A3. Ack arrival defines terminal success
|
||||
|
||||
Examples:
|
||||
|
||||
1. `SessionAckCompleted` immediately becomes final success even if executor unwind is not complete
|
||||
|
||||
Rejected because:
|
||||
|
||||
1. observed completion is not the same as fully closed recovery authority
|
||||
|
||||
### A4. Event ordering determines semantics
|
||||
|
||||
Examples:
|
||||
|
||||
1. roster refresh arrives before probe => one path
|
||||
2. probe arrives before roster refresh => another path
|
||||
|
||||
Rejected because:
|
||||
|
||||
1. same facts must produce same decision
|
||||
|
||||
### A5. Projection drives control truth
|
||||
|
||||
Examples:
|
||||
|
||||
1. outward mode remains healthy so recovery is deferred
|
||||
2. `needs_rebuild` projection doubles as internal transport state
|
||||
|
||||
Rejected because:
|
||||
|
||||
1. projection must remain derived only
|
||||
|
||||
### A6. Timing-specific workaround states accumulate in the engine
|
||||
|
||||
Examples:
|
||||
|
||||
1. one-off states created to survive a single race
|
||||
2. special enums that exist only because one timer arrived before another
|
||||
|
||||
Rejected because:
|
||||
|
||||
1. this turns the engine into a patch history rather than a semantic model
|
||||
|
||||
## 8. Engine Change Audit
|
||||
|
||||
Every engine change in `V3` should pass an audit.
|
||||
|
||||
### 8.1 Required questions
|
||||
|
||||
1. does this change introduce a new semantic truth, or only an implementation workaround
|
||||
2. if it is a workaround, why is it not staying in runtime/adapter/executor
|
||||
3. does this change introduce a new authority conflict
|
||||
4. does this change make protocol meaning depend on timer behavior or event order
|
||||
5. can the same effect be expressed by existing state dimensions or existing events
|
||||
6. what invariant does this change preserve or introduce
|
||||
|
||||
### 8.2 Default stance
|
||||
|
||||
1. new state is rejected by default
|
||||
2. new event is rejected by default
|
||||
3. new semantic transition is rejected by default
|
||||
|
||||
The burden of proof is on the change.
|
||||
|
||||
### 8.3 Acceptance bar for a new state
|
||||
|
||||
A new state may enter the engine only if all are true:
|
||||
|
||||
1. it expresses a new semantic distinction
|
||||
2. that distinction cannot be represented by existing orthogonal dimensions
|
||||
3. it is not timer-driven
|
||||
4. it is not transport-detail-driven
|
||||
5. it supports a new invariant that can be stated clearly
|
||||
|
||||
### 8.4 Acceptance bar for a new event
|
||||
|
||||
A new event may enter the engine only if all are true:
|
||||
|
||||
1. it represents a new independent fact source
|
||||
2. it cannot be modeled as parameters of an existing event category
|
||||
3. it does not create dual authority
|
||||
4. it is not introduced only to compensate for runtime ordering
|
||||
|
||||
## 9. V2 Versus V3 Example Cases
|
||||
|
||||
### Case 1: Same-address transient replica restart
|
||||
|
||||
Problem in time-polluted systems:
|
||||
|
||||
1. if master does not observe the disconnect in time, recovery may not restart
|
||||
|
||||
`V3` expectation:
|
||||
|
||||
1. primary marks reachability lost
|
||||
2. primary keeps probing last known endpoint
|
||||
3. if same address returns, probe succeeds
|
||||
4. bounded facts decide catch-up or rebuild
|
||||
5. master refresh is helpful but not required for same-address recovery
|
||||
|
||||
### Case 2: Changed-address restart
|
||||
|
||||
Problem in time-polluted systems:
|
||||
|
||||
1. primary keeps probing old endpoint forever
|
||||
2. event ordering between heartbeat and probe changes semantics
|
||||
|
||||
`V3` expectation:
|
||||
|
||||
1. primary keeps probing old endpoint harmlessly
|
||||
2. master observes new VS heartbeat and issues newer endpoint truth
|
||||
3. primary adopts newer endpoint version
|
||||
4. recovery proceeds from refreshed identity truth
|
||||
|
||||
### Case 3: Rebuild completion race
|
||||
|
||||
Problem in time-polluted systems:
|
||||
|
||||
1. completion ack arrives
|
||||
2. core announces success
|
||||
3. executor later fails during cleanup
|
||||
4. system emits contradictory terminal outcomes
|
||||
|
||||
`V3` expectation:
|
||||
|
||||
1. completion ack updates observed fact only
|
||||
2. terminal success is emitted only after executor/session closure
|
||||
|
||||
### Case 4: Heartbeat miss versus barrier failure ordering
|
||||
|
||||
Problem in time-polluted systems:
|
||||
|
||||
1. whichever loop notices first can drive different semantics
|
||||
|
||||
`V3` expectation:
|
||||
|
||||
1. master updates identity truth
|
||||
2. primary updates reachability truth
|
||||
3. primary computes recovery truth from bounded facts
|
||||
4. event order changes convergence latency, not semantic result
|
||||
|
||||
## 10. Relationship To Current V2
|
||||
|
||||
This draft does not say `V2` should be discarded.
|
||||
|
||||
Instead, it provides:
|
||||
|
||||
1. a semantic target to evaluate future `V2` fixes
|
||||
2. a way to distinguish durable improvements from local workaround accumulation
|
||||
3. a future migration path if `V3` becomes an implementation effort
|
||||
|
||||
The intended use is:
|
||||
|
||||
1. future recovery changes should be checked against this draft
|
||||
2. when a bug is found, first ask whether it is:
|
||||
1. a missing fact
|
||||
2. a wrong authority
|
||||
3. a time-polluted semantic rule
|
||||
4. a runtime muscle bug
|
||||
3. only the first three belong in the semantic engine
|
||||
|
||||
## 11. Spec And Runtime Split
|
||||
|
||||
`V3` should prefer a split between:
|
||||
|
||||
1. a human-reviewable and machine-readable semantic spec
|
||||
2. one or more runtime implementations
|
||||
|
||||
This is recommended because:
|
||||
|
||||
1. semantic truth should not be trapped inside one language implementation
|
||||
2. future Go and Rust engines should be able to load and enforce the same recovery model
|
||||
3. auditability improves when states, events, invariants, and case expectations are explicit data rather than implied only by code structure
|
||||
|
||||
### 11.1 What may be machine-readable
|
||||
|
||||
The following are good candidates for a machine-readable spec format such as `YAML`, `JSON`, or `TOML`:
|
||||
|
||||
1. truth domains
|
||||
2. state vocabularies
|
||||
3. event vocabularies
|
||||
4. command vocabularies
|
||||
5. projection field definitions
|
||||
6. invariant declarations
|
||||
7. transition legality tables
|
||||
8. conformance cases and expected outcomes
|
||||
|
||||
These are suitable because they are:
|
||||
|
||||
1. declarative
|
||||
2. reviewable by humans
|
||||
3. useful across implementations
|
||||
|
||||
### 11.2 What must remain code
|
||||
|
||||
The following should remain in the engine runtime implementation:
|
||||
|
||||
1. fact merge logic
|
||||
2. monotonic precedence rules
|
||||
3. bounded recovery decision logic
|
||||
4. stale rejection logic
|
||||
5. terminal success/failure authority logic
|
||||
6. any logic that would otherwise require embedding a mini-language in metadata
|
||||
|
||||
The goal is to avoid building a hidden `DSL` inside `YAML`.
|
||||
|
||||
`V3` should not become:
|
||||
|
||||
1. a metadata interpreter for arbitrary semantic logic
|
||||
2. a configuration-driven workaround engine
|
||||
|
||||
### 11.3 Recommended layering
|
||||
|
||||
The preferred shape is:
|
||||
|
||||
1. `schema`
|
||||
1. event kinds
|
||||
2. state kinds
|
||||
3. command kinds
|
||||
4. projection kinds
|
||||
2. `spec`
|
||||
1. invariants
|
||||
2. transition constraints
|
||||
3. prohibited patterns
|
||||
4. conformance examples
|
||||
3. `runtime`
|
||||
1. applies events
|
||||
2. merges facts
|
||||
3. enforces invariants
|
||||
4. emits commands and projections
|
||||
4. `adapter`
|
||||
1. bridges runtime to `weed/` execution muscles
|
||||
|
||||
### 11.4 Go And Rust Implication
|
||||
|
||||
If `V3` later has:
|
||||
|
||||
1. a Go semantic engine
|
||||
2. a Rust semantic engine
|
||||
|
||||
then both should:
|
||||
|
||||
1. consume the same machine-readable spec
|
||||
2. pass the same conformance cases
|
||||
3. produce the same command/projection results for the same input facts
|
||||
|
||||
This gives the project:
|
||||
|
||||
1. a migration path without semantic drift
|
||||
2. a way to benchmark a Rust loader/runtime against the established Go behavior
|
||||
3. a stronger guarantee that semantics stay stable while execution internals evolve
|
||||
|
||||
### 11.5 Design rule
|
||||
|
||||
Machine-readable metadata is encouraged only for:
|
||||
|
||||
1. semantic specification
|
||||
2. conformance specification
|
||||
3. auditability
|
||||
|
||||
It is not encouraged for:
|
||||
|
||||
1. procedural recovery logic
|
||||
2. retry logic
|
||||
3. transport sequencing
|
||||
4. execution-muscle behavior
|
||||
|
||||
The guiding rule is:
|
||||
|
||||
**metadata should describe the semantic surface, while code executes the semantic core.**
|
||||
|
||||
## 12. V3 Acceptance Criteria
|
||||
|
||||
`V3` is not acceptable merely because its internals are cleaner.
|
||||
|
||||
`V3` is acceptable only when it preserves the required semantic behavior and
|
||||
eliminates the known timing-polluted failure classes.
|
||||
|
||||
### 12.1 Minimum replacement bar
|
||||
|
||||
The minimum operational bar for a `V3` engine/runtime pair is to pass the same
|
||||
high-value scenarios that currently define the useful `V2` benchmark:
|
||||
|
||||
1. `I-V3`
|
||||
2. `I-R8`
|
||||
3. `fast-rejoin`
|
||||
4. `rebuild-retry`
|
||||
|
||||
These scenarios are not optional polish. They are the minimum replacement gate.
|
||||
|
||||
### 12.2 Semantic acceptance bar
|
||||
|
||||
In addition to scenario pass/fail, `V3` must satisfy the following:
|
||||
|
||||
1. the same bounded facts produce the same recovery decision regardless of event order
|
||||
2. transport failure alone does not choose rebuild
|
||||
3. terminal recovery success is emitted from one authority only
|
||||
4. terminal recovery failure is emitted from one authority only
|
||||
5. projection never acts as hidden control input
|
||||
6. partial roster observations cannot delete an active replica session without explicit newer authoritative truth
|
||||
|
||||
### 12.3 Conformance acceptance bar
|
||||
|
||||
For the same semantic spec and the same event stream:
|
||||
|
||||
1. the Go runtime and any future Rust runtime must produce the same commands
|
||||
2. the Go runtime and any future Rust runtime must produce the same projections
|
||||
3. stale events must be rejected consistently
|
||||
4. monotonic facts must converge identically
|
||||
|
||||
### 12.4 Explicit failure classes that V3 must close
|
||||
|
||||
`V3` is not ready if it still permits these failure classes:
|
||||
|
||||
1. heartbeat timing decides whether recovery starts
|
||||
2. one barrier/ship failure semantically escalates to rebuild
|
||||
3. completion ack races with executor cleanup and creates double terminal outcomes
|
||||
4. sender/session identity disappears during an active recovery contract
|
||||
5. event arrival order changes recovery semantics instead of only latency
|
||||
|
||||
## 13. Non-Goals
|
||||
|
||||
This draft intentionally does **not** attempt to solve every future storage
|
||||
problem at once.
|
||||
|
||||
### 13.1 Not a full system rewrite
|
||||
|
||||
This draft does not require:
|
||||
|
||||
1. immediate replacement of all `weed/` runtime code
|
||||
2. immediate replacement of `blockvol`
|
||||
3. immediate replacement of the existing transport stack
|
||||
|
||||
### 13.2 Not a feature expansion document
|
||||
|
||||
This draft does not require, as part of the clean core itself:
|
||||
|
||||
1. `SmartWAL`
|
||||
2. `LBAMap`
|
||||
3. richer rebuild source selection beyond the current clean recovery contract
|
||||
4. new durability modes beyond the current semantic envelope
|
||||
|
||||
These may become future extensions, but they are not prerequisites for the
|
||||
core `V3` semantic cleanup.
|
||||
|
||||
### 13.3 Not a timing optimization document
|
||||
|
||||
This draft does not itself optimize:
|
||||
|
||||
1. heartbeat intervals
|
||||
2. probe cadence
|
||||
3. retry backoff
|
||||
4. control-plane wall-clock convergence
|
||||
|
||||
Those are valid later concerns, but `V3` first defines what the semantics must
|
||||
be independent of those timings.
|
||||
|
||||
## 14. Migration Stance
|
||||
|
||||
`V3` should be introduced as a semantically cleaner implementation path, not as
|
||||
an ideological fork from `V2`.
|
||||
|
||||
### 14.1 Benchmark stance
|
||||
|
||||
`V2` remains the operational benchmark until:
|
||||
|
||||
1. its known benchmark-blocking bugs are closed
|
||||
2. its benchmark scenarios are green
|
||||
3. it provides a stable behavior reference for `V3`
|
||||
|
||||
`V3` should not be judged against abstract elegance only. It should be judged
|
||||
against a real, scenario-backed benchmark.
|
||||
|
||||
### 14.2 Side-by-side stance
|
||||
|
||||
When possible, `V3` should be introduced in a side-by-side conformance style:
|
||||
|
||||
1. same semantic spec
|
||||
2. same test vectors
|
||||
3. same scenario suite
|
||||
4. same expected commands and projections
|
||||
|
||||
This keeps migration disciplined and prevents silent semantic drift.
|
||||
|
||||
### 14.3 Adapter-first stance
|
||||
|
||||
The preferred migration shape is:
|
||||
|
||||
1. keep runtime muscles in `weed/`
|
||||
2. isolate a semantic engine runtime behind an adapter boundary
|
||||
3. feed the same bounded facts into both benchmark and candidate implementations where practical
|
||||
4. replace implementation internals only after semantic equivalence is proven
|
||||
|
||||
### 14.4 No workaround carry-forward rule
|
||||
|
||||
`V3` must not carry forward a `V2` workaround unless it survives the `V3`
|
||||
audit as a genuine semantic requirement.
|
||||
|
||||
The default assumption is:
|
||||
|
||||
1. a timing workaround in `V2` is a candidate for deletion
|
||||
2. not a default input to `V3`
|
||||
|
||||
## 15. Future Extensions And Extension Gate
|
||||
|
||||
`V3` should leave room for stronger data algorithms without letting them pollute
|
||||
the clean recovery core.
|
||||
|
||||
### 15.1 Extension class boundary
|
||||
|
||||
Future extensions should be classified explicitly before implementation:
|
||||
|
||||
1. semantic core extension
|
||||
2. recoverability-class extension
|
||||
3. execution-muscle extension
|
||||
4. storage-representation extension
|
||||
|
||||
Only the first class belongs in the semantic engine by default.
|
||||
|
||||
### 15.2 SmartWAL
|
||||
|
||||
`SmartWAL` should be treated as an algorithm/storage-representation extension,
|
||||
not as a core recovery truth domain.
|
||||
|
||||
Its likely role:
|
||||
|
||||
1. reduce `WAL` payload tax for large writes
|
||||
2. change how payload is represented or referenced
|
||||
3. expand recoverability classes without changing identity, recovery authority, or completion authority
|
||||
|
||||
Its likely non-role:
|
||||
|
||||
1. changing who decides recovery
|
||||
2. changing session ownership
|
||||
3. changing projection semantics
|
||||
4. changing the timer/fact boundary
|
||||
|
||||
### 15.3 LBAMap-assisted rebuild
|
||||
|
||||
`LBAMap` should be treated as a recoverability/execution extension:
|
||||
|
||||
1. it may improve rebuild source selection or rebuild efficiency
|
||||
2. it may allow more precise block selection or reference resolution
|
||||
3. it must not redefine the core truth domains
|
||||
|
||||
### 15.4 Extension gate
|
||||
|
||||
Any future feature such as `SmartWAL`, `LBAMap`, or richer rebuild algorithms
|
||||
must answer these questions before entering the `V3` core:
|
||||
|
||||
1. does it create a new long-lived semantic truth
|
||||
2. or does it only strengthen a recoverability class or execution muscle
|
||||
3. does it require a new invariant
|
||||
4. does it change the authority split
|
||||
5. does it introduce timing-sensitive semantics
|
||||
|
||||
If the answer is mainly about:
|
||||
|
||||
1. payload representation
|
||||
2. storage economics
|
||||
3. replay efficiency
|
||||
4. rebuild efficiency
|
||||
|
||||
then it should remain outside the semantic core unless proven otherwise.
|
||||
|
||||
### 15.5 Preferred extension path
|
||||
|
||||
The preferred order for future extensions is:
|
||||
|
||||
1. specify the extension in semantic terms
|
||||
2. classify it by extension class
|
||||
3. add conformance cases
|
||||
4. implement it in execution/storage layers first if possible
|
||||
5. only then decide whether the semantic core must expand
|
||||
|
||||
## 16. Immediate Implication
|
||||
|
||||
`V3` should be treated as:
|
||||
|
||||
1. a semantic minimization draft
|
||||
2. a guardrail against engine pollution
|
||||
3. a reference model for future case comparison
|
||||
|
||||
Not as:
|
||||
|
||||
1. an immediate rewrite mandate
|
||||
2. a promise to replace all of `V2`
|
||||
3. a feature roadmap
|
||||
|
||||
The value of this draft is that it gives the project a stable standard for deciding what belongs in the engine, what belongs in runtime muscles, and what should be rejected as timing pollution.
|
||||
@@ -2,6 +2,7 @@ package weed_server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
@@ -144,6 +145,71 @@ func (f fakeRebuildIO) StreamWALEntries(startExclusive, endInclusive uint64) (ui
|
||||
return endInclusive, nil
|
||||
}
|
||||
|
||||
type errorRebuildIO struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (e errorRebuildIO) TransferFullBase(committedLSN uint64) (uint64, error) {
|
||||
if e.err == nil {
|
||||
return 0, errors.New("rebuild execution failed")
|
||||
}
|
||||
return 0, e.err
|
||||
}
|
||||
|
||||
func (e errorRebuildIO) TransferSnapshot(snapshotLSN uint64) error {
|
||||
if e.err == nil {
|
||||
return errors.New("rebuild execution failed")
|
||||
}
|
||||
return e.err
|
||||
}
|
||||
|
||||
func (e errorRebuildIO) StreamWALEntries(startExclusive, endInclusive uint64) (uint64, error) {
|
||||
if e.err == nil {
|
||||
return 0, errors.New("rebuild execution failed")
|
||||
}
|
||||
return 0, e.err
|
||||
}
|
||||
|
||||
type completedAckThenErrorRebuildIO struct {
|
||||
rm *RecoveryManager
|
||||
replicaID string
|
||||
achievedLSN uint64
|
||||
err error
|
||||
}
|
||||
|
||||
func (c completedAckThenErrorRebuildIO) TransferFullBase(committedLSN uint64) (uint64, error) {
|
||||
if c.rm != nil {
|
||||
achieved := c.achievedLSN
|
||||
if achieved == 0 {
|
||||
achieved = committedLSN
|
||||
}
|
||||
c.rm.mu.Lock()
|
||||
if c.rm.remoteRebuildAchieved == nil {
|
||||
c.rm.remoteRebuildAchieved = make(map[string]uint64)
|
||||
}
|
||||
c.rm.remoteRebuildAchieved[c.replicaID] = achieved
|
||||
c.rm.mu.Unlock()
|
||||
}
|
||||
if c.err == nil {
|
||||
return 0, errors.New("post_ack_error")
|
||||
}
|
||||
return 0, c.err
|
||||
}
|
||||
|
||||
func (c completedAckThenErrorRebuildIO) TransferSnapshot(snapshotLSN uint64) error {
|
||||
if c.err == nil {
|
||||
return errors.New("post_ack_error")
|
||||
}
|
||||
return c.err
|
||||
}
|
||||
|
||||
func (c completedAckThenErrorRebuildIO) StreamWALEntries(startExclusive, endInclusive uint64) (uint64, error) {
|
||||
if c.err == nil {
|
||||
return 0, errors.New("post_ack_error")
|
||||
}
|
||||
return 0, c.err
|
||||
}
|
||||
|
||||
// --- Live-path with real vol: reaches planning ---
|
||||
|
||||
func TestP4_LivePath_RealVol_ReachesPlan(t *testing.T) {
|
||||
@@ -545,6 +611,197 @@ func TestP16B_RunRebuild_FailClosedWithoutFreshStartRebuildCommand(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestP16B_RunRebuild_ExecutionFailureAllowsFreshRetry(t *testing.T) {
|
||||
bs, volPath := createTestBlockServiceWithVolCoreNoRecovery(t)
|
||||
|
||||
if err := bs.blockStore.WithVolume(volPath, func(vol *blockvol.BlockVol) error {
|
||||
for i := 0; i < 5; i++ {
|
||||
if err := vol.WriteLBA(uint64(i), make([]byte, 4096)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return vol.ForceFlush()
|
||||
}); err != nil {
|
||||
t.Fatalf("write+flush: %v", err)
|
||||
}
|
||||
|
||||
bs.ProcessAssignments([]blockvol.BlockVolumeAssignment{{
|
||||
Path: volPath,
|
||||
Epoch: 1,
|
||||
Role: uint32(blockvol.RolePrimary),
|
||||
ReplicaServerID: "vs2",
|
||||
ReplicaDataAddr: "10.0.0.2:9333",
|
||||
ReplicaCtrlAddr: "10.0.0.2:9334",
|
||||
}})
|
||||
|
||||
replicaID := volPath + "/vs2"
|
||||
installTestSession(t, bs, replicaID, engine.SessionRebuild)
|
||||
|
||||
rm := NewRecoveryManager(bs)
|
||||
bs.v2Recovery = rm
|
||||
|
||||
attempts := 0
|
||||
rm.OnPendingExecution = func(volumeID string, pending *rt.PendingExecution) {
|
||||
if volumeID != volPath || pending == nil || pending.Plan == nil {
|
||||
return
|
||||
}
|
||||
attempts++
|
||||
if attempts == 1 {
|
||||
pending.RebuildIO = errorRebuildIO{err: errors.New("mid_rebuild_failure")}
|
||||
return
|
||||
}
|
||||
pending.RebuildIO = fakeRebuildIO{achievedLSN: pending.Plan.RebuildTargetLSN}
|
||||
}
|
||||
_, _, rebuildPort := bs.ReplicationPorts(volPath)
|
||||
rebuildAddr := fmt.Sprintf("127.0.0.1:%d", rebuildPort)
|
||||
|
||||
rm.runRebuild(context.Background(), replicaID, []blockvol.BlockVolumeAssignment{{Path: volPath, RebuildAddr: rebuildAddr}})
|
||||
|
||||
sender := bs.v2Orchestrator.Registry.Sender(replicaID)
|
||||
if sender == nil {
|
||||
t.Fatal("sender missing after failed rebuild")
|
||||
}
|
||||
if sender.State() == engine.StateInSync {
|
||||
t.Fatalf("sender state=%s, want non-in_sync after failed rebuild", sender.State())
|
||||
}
|
||||
firstProj, ok := bs.CoreProjection(volPath)
|
||||
if !ok {
|
||||
t.Fatal("expected core projection after failed rebuild")
|
||||
}
|
||||
if firstProj.Mode.Name != engine.ModeDegraded {
|
||||
t.Fatalf("mode=%s, want degraded after failed rebuild", firstProj.Mode.Name)
|
||||
}
|
||||
|
||||
installTestSession(t, bs, replicaID, engine.SessionRebuild)
|
||||
rm.runRebuild(context.Background(), replicaID, []blockvol.BlockVolumeAssignment{{Path: volPath, RebuildAddr: rebuildAddr}})
|
||||
|
||||
sender = bs.v2Orchestrator.Registry.Sender(replicaID)
|
||||
if sender == nil {
|
||||
t.Fatal("sender missing after retry rebuild")
|
||||
}
|
||||
if sender.State() != engine.StateInSync {
|
||||
t.Fatalf("sender state=%s, want %s after retry", sender.State(), engine.StateInSync)
|
||||
}
|
||||
finalProj, ok := bs.CoreProjection(volPath)
|
||||
if !ok {
|
||||
t.Fatal("expected final core projection after retry rebuild")
|
||||
}
|
||||
if finalProj.Recovery.Phase != engine.RecoveryIdle {
|
||||
t.Fatalf("recovery_phase=%s, want %s", finalProj.Recovery.Phase, engine.RecoveryIdle)
|
||||
}
|
||||
if attempts != 2 {
|
||||
t.Fatalf("attempts=%d, want 2", attempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestP16B_RunRebuild_PostAckErrorStillFailsAndClearsRemoteMarker(t *testing.T) {
|
||||
bs, volPath := createTestBlockServiceWithVolCoreNoRecovery(t)
|
||||
|
||||
if err := bs.blockStore.WithVolume(volPath, func(vol *blockvol.BlockVol) error {
|
||||
for i := 0; i < 5; i++ {
|
||||
if err := vol.WriteLBA(uint64(i), make([]byte, 4096)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return vol.ForceFlush()
|
||||
}); err != nil {
|
||||
t.Fatalf("write+flush: %v", err)
|
||||
}
|
||||
|
||||
bs.ProcessAssignments([]blockvol.BlockVolumeAssignment{{
|
||||
Path: volPath,
|
||||
Epoch: 1,
|
||||
Role: uint32(blockvol.RolePrimary),
|
||||
ReplicaServerID: "vs2",
|
||||
ReplicaDataAddr: "10.0.0.2:9333",
|
||||
ReplicaCtrlAddr: "10.0.0.2:9334",
|
||||
}})
|
||||
|
||||
replicaID := volPath + "/vs2"
|
||||
installTestSession(t, bs, replicaID, engine.SessionRebuild)
|
||||
|
||||
rm := NewRecoveryManager(bs)
|
||||
bs.v2Recovery = rm
|
||||
rm.OnPendingExecution = func(volumeID string, pending *rt.PendingExecution) {
|
||||
if volumeID != volPath || pending == nil || pending.Plan == nil {
|
||||
return
|
||||
}
|
||||
pending.RebuildIO = completedAckThenErrorRebuildIO{
|
||||
rm: rm,
|
||||
replicaID: replicaID,
|
||||
achievedLSN: pending.Plan.RebuildTargetLSN,
|
||||
err: errors.New("post_ack_error"),
|
||||
}
|
||||
}
|
||||
_, _, rebuildPort := bs.ReplicationPorts(volPath)
|
||||
rebuildAddr := fmt.Sprintf("127.0.0.1:%d", rebuildPort)
|
||||
|
||||
rm.runRebuild(context.Background(), replicaID, []blockvol.BlockVolumeAssignment{{Path: volPath, RebuildAddr: rebuildAddr}})
|
||||
|
||||
proj, ok := bs.CoreProjection(volPath)
|
||||
if !ok {
|
||||
t.Fatal("expected core projection after post-ack error")
|
||||
}
|
||||
if proj.Mode.Name != engine.ModeDegraded {
|
||||
t.Fatalf("mode=%s, want degraded after post-ack error", proj.Mode.Name)
|
||||
}
|
||||
if proj.Recovery.Reason != "post_ack_error" {
|
||||
t.Fatalf("recovery_reason=%q, want post_ack_error", proj.Recovery.Reason)
|
||||
}
|
||||
rm.mu.Lock()
|
||||
_, stillMarked := rm.remoteRebuildAchieved[replicaID]
|
||||
rm.mu.Unlock()
|
||||
if stillMarked {
|
||||
t.Fatal("remote rebuild achieved marker should be cleared on failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestP16B_OnRebuildCompleted_RemotePathEmitsSessionCompleted(t *testing.T) {
|
||||
bs, volPath := createTestBlockServiceWithVolCoreNoRecovery(t)
|
||||
|
||||
bs.ProcessAssignments([]blockvol.BlockVolumeAssignment{{
|
||||
Path: volPath,
|
||||
Epoch: 1,
|
||||
Role: uint32(blockvol.RolePrimary),
|
||||
ReplicaServerID: "vs2",
|
||||
ReplicaDataAddr: "10.0.0.2:9333",
|
||||
ReplicaCtrlAddr: "10.0.0.2:9334",
|
||||
}})
|
||||
|
||||
replicaID := volPath + "/vs2"
|
||||
sessionID := installTestSession(t, bs, replicaID, engine.SessionRebuild)
|
||||
if sessionID == 0 {
|
||||
t.Fatal("expected rebuild session")
|
||||
}
|
||||
targetLSN := uint64(123)
|
||||
bs.applyCoreEvent(engine.RebuildStarted{ID: volPath, ReplicaID: replicaID, TargetLSN: targetLSN})
|
||||
|
||||
rm := NewRecoveryManager(bs)
|
||||
bs.v2Recovery = rm
|
||||
rm.mu.Lock()
|
||||
rm.remoteRebuildAchieved[replicaID] = targetLSN + 7
|
||||
rm.mu.Unlock()
|
||||
|
||||
rm.OnRebuildCompleted(volPath, replicaID, &engine.RecoveryPlan{RebuildTargetLSN: targetLSN})
|
||||
|
||||
proj, ok := bs.CoreProjection(volPath)
|
||||
if !ok {
|
||||
t.Fatal("expected core projection after remote rebuild completion")
|
||||
}
|
||||
if proj.Recovery.Phase != engine.RecoveryIdle {
|
||||
t.Fatalf("recovery_phase=%s, want %s", proj.Recovery.Phase, engine.RecoveryIdle)
|
||||
}
|
||||
if proj.Boundary.AchievedLSN != targetLSN+7 {
|
||||
t.Fatalf("achieved_lsn=%d, want %d", proj.Boundary.AchievedLSN, targetLSN+7)
|
||||
}
|
||||
rm.mu.Lock()
|
||||
_, stillMarked := rm.remoteRebuildAchieved[replicaID]
|
||||
rm.mu.Unlock()
|
||||
if stillMarked {
|
||||
t.Fatal("remote rebuild achieved marker should be consumed on completion")
|
||||
}
|
||||
}
|
||||
|
||||
func TestP16B_FactTriggeredRebuildCycle_AutoInstallsRebuildAndReachesInSync(t *testing.T) {
|
||||
bs, volPath := createTestBlockServiceWithVolCoreNoRecovery(t)
|
||||
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
package blockvol
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// SmartWALBuffer is a fixed-size slot-based ring buffer for metadata-only
|
||||
// WAL records. Each slot is exactly SmartWALRecordSize (32) bytes.
|
||||
//
|
||||
// The ring buffer supports concurrent appends (via head CAS) and
|
||||
// sequential sync (fdatasync flushes all pending records).
|
||||
//
|
||||
// Capacity planning: a 64MB buffer holds 2M records (vs ~16K for
|
||||
// current 4KB-inline WAL). WAL pressure is effectively eliminated.
|
||||
type SmartWALBuffer struct {
|
||||
fd *os.File
|
||||
capacity uint64 // number of slots
|
||||
size uint64 // capacity * SmartWALRecordSize
|
||||
|
||||
head atomic.Uint64 // next write slot (monotonic, wraps via mod)
|
||||
synced atomic.Uint64 // last synced head position
|
||||
|
||||
mu sync.Mutex // protects fd writes (pwrite is not atomic across goroutines)
|
||||
}
|
||||
|
||||
// NewSmartWALBuffer creates a new ring buffer backed by the given file.
|
||||
// The file is truncated/extended to the specified number of slots and
|
||||
// zero-filled. Zero bytes decode as invalid records (no magic byte).
|
||||
func NewSmartWALBuffer(path string, slots uint64) (*SmartWALBuffer, error) {
|
||||
fd, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0644)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("smartwal: open %s: %w", path, err)
|
||||
}
|
||||
size := slots * SmartWALRecordSize
|
||||
if err := fd.Truncate(int64(size)); err != nil {
|
||||
fd.Close()
|
||||
return nil, fmt.Errorf("smartwal: truncate %s to %d: %w", path, size, err)
|
||||
}
|
||||
return &SmartWALBuffer{
|
||||
fd: fd,
|
||||
capacity: slots,
|
||||
size: size,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// OpenSmartWALBuffer opens an existing ring buffer file for recovery.
|
||||
// Does not truncate or zero-fill.
|
||||
func OpenSmartWALBuffer(path string) (*SmartWALBuffer, error) {
|
||||
fd, err := os.OpenFile(path, os.O_RDWR, 0644)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("smartwal: open %s: %w", path, err)
|
||||
}
|
||||
info, err := fd.Stat()
|
||||
if err != nil {
|
||||
fd.Close()
|
||||
return nil, fmt.Errorf("smartwal: stat %s: %w", path, err)
|
||||
}
|
||||
size := uint64(info.Size())
|
||||
if size < SmartWALRecordSize || size%SmartWALRecordSize != 0 {
|
||||
fd.Close()
|
||||
return nil, fmt.Errorf("smartwal: invalid size %d (must be multiple of %d)", size, SmartWALRecordSize)
|
||||
}
|
||||
return &SmartWALBuffer{
|
||||
fd: fd,
|
||||
capacity: size / SmartWALRecordSize,
|
||||
size: size,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// AppendRecord writes a record to the next slot in the ring buffer.
|
||||
// The write is a pwrite to the correct file offset — no seeking.
|
||||
// NOT durable until Sync() is called.
|
||||
func (w *SmartWALBuffer) AppendRecord(rec SmartWALRecord) error {
|
||||
slot := w.head.Add(1) - 1
|
||||
offset := (slot % w.capacity) * SmartWALRecordSize
|
||||
encoded := EncodeSmartWALRecord(rec)
|
||||
|
||||
w.mu.Lock()
|
||||
_, err := w.fd.WriteAt(encoded[:], int64(offset))
|
||||
w.mu.Unlock()
|
||||
if err != nil {
|
||||
return fmt.Errorf("smartwal: write slot %d: %w", slot, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Sync flushes all pending records to disk. After Sync returns, all
|
||||
// records appended before this call are durable.
|
||||
func (w *SmartWALBuffer) Sync() error {
|
||||
if err := w.fd.Sync(); err != nil {
|
||||
return fmt.Errorf("smartwal: sync: %w", err)
|
||||
}
|
||||
w.synced.Store(w.head.Load())
|
||||
return nil
|
||||
}
|
||||
|
||||
// SyncedPosition returns the head position at the last successful Sync.
|
||||
func (w *SmartWALBuffer) SyncedPosition() uint64 {
|
||||
return w.synced.Load()
|
||||
}
|
||||
|
||||
// HeadPosition returns the current head (next write slot).
|
||||
func (w *SmartWALBuffer) HeadPosition() uint64 {
|
||||
return w.head.Load()
|
||||
}
|
||||
|
||||
// Capacity returns the number of slots in the ring buffer.
|
||||
func (w *SmartWALBuffer) Capacity() uint64 {
|
||||
return w.capacity
|
||||
}
|
||||
|
||||
// ScanValidRecords reads all slots in the ring buffer and returns valid
|
||||
// records in slot order. Stops at the first invalid/zeroed slot after
|
||||
// finding at least one valid record in a forward scan.
|
||||
//
|
||||
// For recovery: the caller should use the returned records to verify
|
||||
// extent data via CRC.
|
||||
//
|
||||
// Returns (records, lastValidLSN, error).
|
||||
func (w *SmartWALBuffer) ScanValidRecords() ([]SmartWALRecord, uint64, error) {
|
||||
buf := make([]byte, w.size)
|
||||
if _, err := w.fd.ReadAt(buf, 0); err != nil {
|
||||
return nil, 0, fmt.Errorf("smartwal: read all: %w", err)
|
||||
}
|
||||
|
||||
var valid []smartSlotRecord
|
||||
for i := uint64(0); i < w.capacity; i++ {
|
||||
offset := i * SmartWALRecordSize
|
||||
rec, ok := DecodeSmartWALRecord(buf[offset : offset+SmartWALRecordSize])
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
valid = append(valid, smartSlotRecord{slot: i, rec: rec}) //nolint:govet
|
||||
}
|
||||
|
||||
if len(valid) == 0 {
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
// Sort by LSN to get correct ordering (ring may have wrapped).
|
||||
// Since LSN is monotonic, sorting by LSN gives temporal order.
|
||||
sortByLSN(valid)
|
||||
|
||||
records := make([]SmartWALRecord, len(valid))
|
||||
var maxLSN uint64
|
||||
for i, sr := range valid {
|
||||
records[i] = sr.rec
|
||||
if sr.rec.LSN > maxLSN {
|
||||
maxLSN = sr.rec.LSN
|
||||
}
|
||||
}
|
||||
|
||||
// Set head to after the last valid record for post-recovery appends.
|
||||
w.head.Store(maxLSN)
|
||||
w.synced.Store(maxLSN)
|
||||
|
||||
return records, maxLSN, nil
|
||||
}
|
||||
|
||||
type smartSlotRecord struct {
|
||||
slot uint64
|
||||
rec SmartWALRecord
|
||||
}
|
||||
|
||||
// sortByLSN sorts slot records by LSN (ascending).
|
||||
func sortByLSN(records []smartSlotRecord) {
|
||||
// Simple insertion sort — recovery is not performance-critical.
|
||||
for i := 1; i < len(records); i++ {
|
||||
for j := i; j > 0 && records[j].rec.LSN < records[j-1].rec.LSN; j-- {
|
||||
records[j], records[j-1] = records[j-1], records[j]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close closes the underlying file.
|
||||
func (w *SmartWALBuffer) Close() error {
|
||||
if w.fd == nil {
|
||||
return nil
|
||||
}
|
||||
return w.fd.Close()
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package blockvol
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"hash/crc32"
|
||||
)
|
||||
|
||||
// SmartWAL: metadata-only WAL where data lives in the extent.
|
||||
// Each record is 32 bytes fixed. No data payload.
|
||||
//
|
||||
// The WAL + extent together form a logical LSN-ordered storage:
|
||||
// - WAL provides ordering, crash-recovery metadata, and CRC verification
|
||||
// - Extent provides authoritative data storage
|
||||
//
|
||||
// On crash recovery, WAL records are scanned and extent data is verified
|
||||
// via CRC. Records with mismatched CRCs are skipped (torn/unflushed writes).
|
||||
|
||||
const (
|
||||
SmartWALRecordSize = 32
|
||||
|
||||
// Record flags
|
||||
SmartFlagWrite uint8 = 0x01
|
||||
SmartFlagTrim uint8 = 0x02
|
||||
SmartFlagBarrier uint8 = 0x04
|
||||
|
||||
// Magic byte for record validation (distinguishes valid records from zeros)
|
||||
smartRecordMagic uint8 = 0xAC
|
||||
)
|
||||
|
||||
// SmartWALRecord is the metadata-only WAL entry.
|
||||
// Wire format: [1B magic][1B flags][2B pad][4B lba][8B lsn][8B epoch][4B dataCRC][4B recCRC] = 32 bytes
|
||||
type SmartWALRecord struct {
|
||||
LSN uint64
|
||||
Epoch uint64
|
||||
LBA uint32
|
||||
Flags uint8
|
||||
DataCRC32 uint32 // crc32c of the 4KB data block in extent
|
||||
}
|
||||
|
||||
// EncodeSmartWALRecord serializes a record into exactly 32 bytes.
|
||||
// Layout: [magic:1][flags:1][pad:2][lba:4][lsn:8][epoch:8][dataCRC:4][recCRC:4]
|
||||
// The recCRC covers bytes 0..27 (everything except the recCRC field itself).
|
||||
func EncodeSmartWALRecord(rec SmartWALRecord) [SmartWALRecordSize]byte {
|
||||
var buf [SmartWALRecordSize]byte
|
||||
buf[0] = smartRecordMagic
|
||||
buf[1] = rec.Flags
|
||||
// buf[2..3] = padding (zero)
|
||||
binary.LittleEndian.PutUint32(buf[4:8], rec.LBA)
|
||||
binary.LittleEndian.PutUint64(buf[8:16], rec.LSN)
|
||||
binary.LittleEndian.PutUint64(buf[16:24], rec.Epoch)
|
||||
binary.LittleEndian.PutUint32(buf[24:28], rec.DataCRC32)
|
||||
// Record CRC covers bytes 0..27
|
||||
recCRC := crc32.ChecksumIEEE(buf[:28])
|
||||
binary.LittleEndian.PutUint32(buf[28:32], recCRC)
|
||||
return buf
|
||||
}
|
||||
|
||||
// DecodeSmartWALRecord deserializes a 32-byte record.
|
||||
// Returns (record, true) if the record is valid (magic + CRC check).
|
||||
// Returns (zero, false) if the record is invalid, zeroed, or torn.
|
||||
func DecodeSmartWALRecord(buf []byte) (SmartWALRecord, bool) {
|
||||
if len(buf) < SmartWALRecordSize {
|
||||
return SmartWALRecord{}, false
|
||||
}
|
||||
// Check magic
|
||||
if buf[0] != smartRecordMagic {
|
||||
return SmartWALRecord{}, false
|
||||
}
|
||||
// Verify record CRC
|
||||
expectedCRC := binary.LittleEndian.Uint32(buf[28:32])
|
||||
actualCRC := crc32.ChecksumIEEE(buf[:28])
|
||||
if expectedCRC != actualCRC {
|
||||
return SmartWALRecord{}, false
|
||||
}
|
||||
return SmartWALRecord{
|
||||
Flags: buf[1],
|
||||
LBA: binary.LittleEndian.Uint32(buf[4:8]),
|
||||
LSN: binary.LittleEndian.Uint64(buf[8:16]),
|
||||
Epoch: binary.LittleEndian.Uint64(buf[16:24]),
|
||||
DataCRC32: binary.LittleEndian.Uint32(buf[24:28]),
|
||||
}, true
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
package blockvol
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"hash/crc32"
|
||||
"log"
|
||||
"os"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// SmartWALVolume is the minimal prototype volume that uses SmartWAL.
|
||||
// It combines an extent file (data) with a SmartWAL ring buffer (metadata).
|
||||
// Together they form a logical LSN-ordered storage.
|
||||
//
|
||||
// This is a self-contained prototype — it does not modify or replace
|
||||
// the existing BlockVol implementation.
|
||||
type SmartWALVolume struct {
|
||||
extentFD *os.File
|
||||
wal *SmartWALBuffer
|
||||
blockSize uint64
|
||||
numBlocks uint64
|
||||
epoch uint64
|
||||
nextLSN atomic.Uint64
|
||||
|
||||
// dirtyMap tracks which LBAs have been written since last clean state.
|
||||
// Maps LBA → highest LSN that wrote to it. Used for replication delta.
|
||||
dirtyMu sync.Mutex
|
||||
dirtyMap map[uint32]uint64
|
||||
}
|
||||
|
||||
// SmartWALVolumeConfig configures a SmartWAL prototype volume.
|
||||
type SmartWALVolumeConfig struct {
|
||||
ExtentPath string // path to extent data file
|
||||
WALPath string // path to WAL ring buffer file
|
||||
BlockSize uint64 // block size in bytes (must be 4096)
|
||||
NumBlocks uint64 // number of blocks in the extent
|
||||
WALSlots uint64 // number of WAL slots (default: 65536 = 2MB WAL)
|
||||
Epoch uint64 // fencing epoch
|
||||
}
|
||||
|
||||
// CreateSmartWALVolume creates a new SmartWAL volume with zeroed extent
|
||||
// and empty WAL.
|
||||
func CreateSmartWALVolume(cfg SmartWALVolumeConfig) (*SmartWALVolume, error) {
|
||||
if cfg.BlockSize == 0 {
|
||||
cfg.BlockSize = 4096
|
||||
}
|
||||
if cfg.WALSlots == 0 {
|
||||
cfg.WALSlots = 65536 // 2MB WAL = 65536 × 32 bytes
|
||||
}
|
||||
|
||||
// Create extent file
|
||||
extentFD, err := os.OpenFile(cfg.ExtentPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("smartwal: create extent %s: %w", cfg.ExtentPath, err)
|
||||
}
|
||||
extentSize := int64(cfg.NumBlocks * cfg.BlockSize)
|
||||
if err := extentFD.Truncate(extentSize); err != nil {
|
||||
extentFD.Close()
|
||||
return nil, fmt.Errorf("smartwal: truncate extent to %d: %w", extentSize, err)
|
||||
}
|
||||
|
||||
// Create WAL ring buffer
|
||||
wal, err := NewSmartWALBuffer(cfg.WALPath, cfg.WALSlots)
|
||||
if err != nil {
|
||||
extentFD.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
v := &SmartWALVolume{
|
||||
extentFD: extentFD,
|
||||
wal: wal,
|
||||
blockSize: cfg.BlockSize,
|
||||
numBlocks: cfg.NumBlocks,
|
||||
epoch: cfg.Epoch,
|
||||
dirtyMap: make(map[uint32]uint64),
|
||||
}
|
||||
v.nextLSN.Store(1)
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// OpenSmartWALVolume opens an existing SmartWAL volume and runs recovery.
|
||||
func OpenSmartWALVolume(cfg SmartWALVolumeConfig) (*SmartWALVolume, error) {
|
||||
if cfg.BlockSize == 0 {
|
||||
cfg.BlockSize = 4096
|
||||
}
|
||||
extentFD, err := os.OpenFile(cfg.ExtentPath, os.O_RDWR, 0644)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("smartwal: open extent %s: %w", cfg.ExtentPath, err)
|
||||
}
|
||||
wal, err := OpenSmartWALBuffer(cfg.WALPath)
|
||||
if err != nil {
|
||||
extentFD.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
v := &SmartWALVolume{
|
||||
extentFD: extentFD,
|
||||
wal: wal,
|
||||
blockSize: cfg.BlockSize,
|
||||
numBlocks: cfg.NumBlocks,
|
||||
epoch: cfg.Epoch,
|
||||
dirtyMap: make(map[uint32]uint64),
|
||||
}
|
||||
v.nextLSN.Store(1)
|
||||
|
||||
if err := v.Recover(); err != nil {
|
||||
v.Close()
|
||||
return nil, err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// WriteLBA writes a block to the extent and appends a metadata WAL record.
|
||||
// NOT durable until SyncCache is called.
|
||||
func (v *SmartWALVolume) WriteLBA(lba uint32, data []byte) error {
|
||||
if uint64(lba) >= v.numBlocks {
|
||||
return fmt.Errorf("smartwal: LBA %d out of range (max %d)", lba, v.numBlocks-1)
|
||||
}
|
||||
if uint64(len(data)) != v.blockSize {
|
||||
return fmt.Errorf("smartwal: data size %d != block size %d", len(data), v.blockSize)
|
||||
}
|
||||
|
||||
// Step 1: write data to extent
|
||||
offset := int64(lba) * int64(v.blockSize)
|
||||
if _, err := v.extentFD.WriteAt(data, offset); err != nil {
|
||||
return fmt.Errorf("smartwal: write extent LBA %d: %w", lba, err)
|
||||
}
|
||||
|
||||
// Step 2: compute CRC of what we wrote
|
||||
dataCRC := crc32.ChecksumIEEE(data)
|
||||
|
||||
// Step 3: append metadata record to WAL
|
||||
lsn := v.nextLSN.Add(1) - 1
|
||||
rec := SmartWALRecord{
|
||||
LSN: lsn,
|
||||
Epoch: v.epoch,
|
||||
LBA: lba,
|
||||
Flags: SmartFlagWrite,
|
||||
DataCRC32: dataCRC,
|
||||
}
|
||||
if err := v.wal.AppendRecord(rec); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 4: mark dirty
|
||||
v.dirtyMu.Lock()
|
||||
v.dirtyMap[lba] = lsn
|
||||
v.dirtyMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReadLBA reads a block from the extent.
|
||||
func (v *SmartWALVolume) ReadLBA(lba uint32) ([]byte, error) {
|
||||
if uint64(lba) >= v.numBlocks {
|
||||
return nil, fmt.Errorf("smartwal: LBA %d out of range", lba)
|
||||
}
|
||||
data := make([]byte, v.blockSize)
|
||||
offset := int64(lba) * int64(v.blockSize)
|
||||
if _, err := v.extentFD.ReadAt(data, offset); err != nil {
|
||||
return nil, fmt.Errorf("smartwal: read LBA %d: %w", lba, err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// TrimLBA zeros a block and appends a trim WAL record.
|
||||
func (v *SmartWALVolume) TrimLBA(lba uint32) error {
|
||||
if uint64(lba) >= v.numBlocks {
|
||||
return fmt.Errorf("smartwal: LBA %d out of range", lba)
|
||||
}
|
||||
zeros := make([]byte, v.blockSize)
|
||||
offset := int64(lba) * int64(v.blockSize)
|
||||
if _, err := v.extentFD.WriteAt(zeros, offset); err != nil {
|
||||
return fmt.Errorf("smartwal: trim LBA %d: %w", lba, err)
|
||||
}
|
||||
lsn := v.nextLSN.Add(1) - 1
|
||||
rec := SmartWALRecord{
|
||||
LSN: lsn,
|
||||
Epoch: v.epoch,
|
||||
LBA: lba,
|
||||
Flags: SmartFlagTrim,
|
||||
DataCRC32: crc32.ChecksumIEEE(zeros),
|
||||
}
|
||||
if err := v.wal.AppendRecord(rec); err != nil {
|
||||
return err
|
||||
}
|
||||
v.dirtyMu.Lock()
|
||||
v.dirtyMap[lba] = lsn
|
||||
v.dirtyMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// SyncCache flushes both extent and WAL to disk. This is the durability
|
||||
// fence. After SyncCache returns, all preceding writes are durable.
|
||||
//
|
||||
// Ordering: extent sync BEFORE WAL sync. This ensures that when a WAL
|
||||
// record is durable, the extent data it references is also durable.
|
||||
func (v *SmartWALVolume) SyncCache() error {
|
||||
// Step 1: flush extent data to disk
|
||||
if err := v.extentFD.Sync(); err != nil {
|
||||
return fmt.Errorf("smartwal: sync extent: %w", err)
|
||||
}
|
||||
// Step 2: flush WAL metadata to disk
|
||||
// After this, WAL records reference durable extent data.
|
||||
if err := v.wal.Sync(); err != nil {
|
||||
return fmt.Errorf("smartwal: sync WAL: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Recover replays WAL records and verifies extent data integrity.
|
||||
// Records with CRC mismatches are skipped (torn/unflushed writes).
|
||||
// Sets nextLSN to one past the last valid recovered record.
|
||||
func (v *SmartWALVolume) Recover() error {
|
||||
records, lastValidLSN, err := v.wal.ScanValidRecords()
|
||||
if err != nil {
|
||||
return fmt.Errorf("smartwal: recovery scan: %w", err)
|
||||
}
|
||||
if len(records) == 0 {
|
||||
log.Printf("smartwal: recovery: no valid records found")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build the last-writer-wins map: for each LBA, keep only the
|
||||
// record with the highest LSN.
|
||||
lastWrite := make(map[uint32]SmartWALRecord)
|
||||
for _, rec := range records {
|
||||
if existing, ok := lastWrite[rec.LBA]; !ok || rec.LSN > existing.LSN {
|
||||
lastWrite[rec.LBA] = rec
|
||||
}
|
||||
}
|
||||
|
||||
// Verify each LBA's final record against extent data.
|
||||
var recovered, damaged int
|
||||
for _, rec := range lastWrite {
|
||||
if rec.Flags == SmartFlagTrim {
|
||||
recovered++
|
||||
continue
|
||||
}
|
||||
|
||||
data := make([]byte, v.blockSize)
|
||||
offset := int64(rec.LBA) * int64(v.blockSize)
|
||||
if _, err := v.extentFD.ReadAt(data, offset); err != nil {
|
||||
return fmt.Errorf("smartwal: recovery read LBA %d: %w", rec.LBA, err)
|
||||
}
|
||||
actualCRC := crc32.ChecksumIEEE(data)
|
||||
if actualCRC != rec.DataCRC32 {
|
||||
log.Printf("smartwal: recovery CRC mismatch LSN=%d LBA=%d (expected=%08x actual=%08x) — skipping",
|
||||
rec.LSN, rec.LBA, rec.DataCRC32, actualCRC)
|
||||
damaged++
|
||||
continue
|
||||
}
|
||||
recovered++
|
||||
v.dirtyMap[rec.LBA] = rec.LSN
|
||||
}
|
||||
|
||||
v.nextLSN.Store(lastValidLSN + 1)
|
||||
log.Printf("smartwal: recovery: %d LBAs verified, %d damaged, nextLSN=%d",
|
||||
recovered, damaged, lastValidLSN+1)
|
||||
return nil
|
||||
}
|
||||
|
||||
// NextLSN returns the next LSN that will be assigned.
|
||||
func (v *SmartWALVolume) NextLSN() uint64 {
|
||||
return v.nextLSN.Load()
|
||||
}
|
||||
|
||||
// Close closes the extent and WAL files.
|
||||
func (v *SmartWALVolume) Close() error {
|
||||
var firstErr error
|
||||
if v.wal != nil {
|
||||
if err := v.wal.Close(); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
if v.extentFD != nil {
|
||||
if err := v.extentFD.Close(); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
package blockvol
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"hash/crc32"
|
||||
"math/rand"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
// SmartWAL Two-Node Replication Crash Tests (Prototype)
|
||||
//
|
||||
// These tests prove that SmartWAL works correctly when two volumes
|
||||
// replicate data. The "replica" is a second SmartWALVolume that
|
||||
// receives writes shipped from the "primary".
|
||||
//
|
||||
// The wire format is unchanged: replicas receive full 4KB data payloads.
|
||||
// SmartWAL is a local optimization — each node writes extent-first
|
||||
// and appends metadata-only WAL records independently.
|
||||
// ============================================================
|
||||
|
||||
// shipWrite simulates primary → replica WAL shipping.
|
||||
// The primary passes the data buffer directly (same buffer, no re-read).
|
||||
func shipWrite(replica *SmartWALVolume, lba uint32, data []byte, epoch uint64) error {
|
||||
return replica.WriteLBA(lba, data)
|
||||
}
|
||||
|
||||
// verifyExtentMatch asserts that two volumes have identical data at all LBAs.
|
||||
func verifyExtentMatch(t *testing.T, label string, a, b *SmartWALVolume, numBlocks uint32) {
|
||||
t.Helper()
|
||||
for lba := uint32(0); lba < numBlocks; lba++ {
|
||||
da, err := a.ReadLBA(lba)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: read A LBA %d: %v", label, lba, err)
|
||||
}
|
||||
db, err := b.ReadLBA(lba)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: read B LBA %d: %v", label, lba, err)
|
||||
}
|
||||
if !bytes.Equal(da, db) {
|
||||
t.Fatalf("%s: LBA %d data mismatch between A and B", label, lba)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func createPrimaryReplica(t *testing.T, numBlocks uint64) (*SmartWALVolume, *SmartWALVolume, SmartWALVolumeConfig, SmartWALVolumeConfig) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
pcfg := SmartWALVolumeConfig{
|
||||
ExtentPath: filepath.Join(dir, "primary_extent.dat"),
|
||||
WALPath: filepath.Join(dir, "primary_wal.dat"),
|
||||
BlockSize: 4096,
|
||||
NumBlocks: numBlocks,
|
||||
WALSlots: 1024,
|
||||
Epoch: 1,
|
||||
}
|
||||
rcfg := SmartWALVolumeConfig{
|
||||
ExtentPath: filepath.Join(dir, "replica_extent.dat"),
|
||||
WALPath: filepath.Join(dir, "replica_wal.dat"),
|
||||
BlockSize: 4096,
|
||||
NumBlocks: numBlocks,
|
||||
WALSlots: 1024,
|
||||
Epoch: 1,
|
||||
}
|
||||
primary, err := CreateSmartWALVolume(pcfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
replica, err := CreateSmartWALVolume(rcfg)
|
||||
if err != nil {
|
||||
primary.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
return primary, replica, pcfg, rcfg
|
||||
}
|
||||
|
||||
// Test 8: Primary crash after barrier — replica has all data
|
||||
func TestSmartWAL_Repl_PrimaryCrashAfterBarrier(t *testing.T) {
|
||||
primary, replica, pcfg, _ := createPrimaryReplica(t, 256)
|
||||
|
||||
// Write 100 blocks on primary, ship to replica, sync both, barrier
|
||||
for i := uint32(0); i < 100; i++ {
|
||||
data := makeTestBlock(byte(i))
|
||||
if err := primary.WriteLBA(i, data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := shipWrite(replica, i, data, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := primary.SyncCache(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := replica.SyncCache(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Kill primary
|
||||
primary.Close()
|
||||
|
||||
// Verify replica has all data
|
||||
for i := uint32(0); i < 100; i++ {
|
||||
data, err := replica.ReadLBA(i)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadLBA %d: %v", i, err)
|
||||
}
|
||||
if !bytes.Equal(data, makeTestBlock(byte(i))) {
|
||||
t.Fatalf("LBA %d: replica data mismatch", i)
|
||||
}
|
||||
}
|
||||
|
||||
// Recover primary — should match replica
|
||||
primary2, err := OpenSmartWALVolume(pcfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer primary2.Close()
|
||||
verifyExtentMatch(t, "post-primary-crash", primary2, replica, 100)
|
||||
replica.Close()
|
||||
}
|
||||
|
||||
// Test 9: Primary crash — replica partially caught up
|
||||
func TestSmartWAL_Repl_PrimaryCrashPartialCatchUp(t *testing.T) {
|
||||
primary, replica, pcfg, _ := createPrimaryReplica(t, 256)
|
||||
|
||||
// Write 100 on primary, ship only first 50 to replica
|
||||
for i := uint32(0); i < 100; i++ {
|
||||
data := makeTestBlock(byte(i))
|
||||
if err := primary.WriteLBA(i, data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if i < 50 {
|
||||
if err := shipWrite(replica, i, data, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := primary.SyncCache(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := replica.SyncCache(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Kill primary
|
||||
primary.Close()
|
||||
|
||||
// Recover primary
|
||||
primary2, err := OpenSmartWALVolume(pcfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer primary2.Close()
|
||||
|
||||
// Primary has 100 blocks, replica has 50
|
||||
// Catch-up: ship blocks 50-99 from primary to replica
|
||||
for i := uint32(50); i < 100; i++ {
|
||||
data, err := primary2.ReadLBA(i)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := shipWrite(replica, i, data, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := replica.SyncCache(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
verifyExtentMatch(t, "post-catch-up", primary2, replica, 100)
|
||||
replica.Close()
|
||||
}
|
||||
|
||||
// Test 10: Replica crash during receive
|
||||
func TestSmartWAL_Repl_ReplicaCrashDuringReceive(t *testing.T) {
|
||||
primary, replica, _, rcfg := createPrimaryReplica(t, 256)
|
||||
defer primary.Close()
|
||||
|
||||
// Write + ship all 100 blocks
|
||||
for i := uint32(0); i < 100; i++ {
|
||||
data := makeTestBlock(byte(i))
|
||||
if err := primary.WriteLBA(i, data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := shipWrite(replica, i, data, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// Sync primary but NOT replica — then kill replica
|
||||
if err := primary.SyncCache(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
replica.Close() // crash without sync
|
||||
|
||||
// Recover replica
|
||||
replica2, err := OpenSmartWALVolume(rcfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer replica2.Close()
|
||||
|
||||
// Re-ship all from primary to recovered replica (catch-up)
|
||||
for i := uint32(0); i < 100; i++ {
|
||||
data, err := primary.ReadLBA(i)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := shipWrite(replica2, i, data, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := replica2.SyncCache(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
verifyExtentMatch(t, "post-replica-crash", primary, replica2, 100)
|
||||
}
|
||||
|
||||
// Test 11: Both crash simultaneously
|
||||
func TestSmartWAL_Repl_BothCrashSimultaneously(t *testing.T) {
|
||||
primary, replica, pcfg, rcfg := createPrimaryReplica(t, 256)
|
||||
|
||||
// Phase 1: write + sync (durable)
|
||||
for i := uint32(0); i < 50; i++ {
|
||||
data := makeTestBlock(byte(i))
|
||||
primary.WriteLBA(i, data)
|
||||
shipWrite(replica, i, data, 1)
|
||||
}
|
||||
primary.SyncCache()
|
||||
replica.SyncCache()
|
||||
|
||||
// Phase 2: write more without sync
|
||||
for i := uint32(50); i < 100; i++ {
|
||||
data := makeTestBlock(byte(i))
|
||||
primary.WriteLBA(i, data)
|
||||
shipWrite(replica, i, data, 1)
|
||||
}
|
||||
|
||||
// Kill both
|
||||
primary.Close()
|
||||
replica.Close()
|
||||
|
||||
// Recover both
|
||||
p2, err := OpenSmartWALVolume(pcfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer p2.Close()
|
||||
r2, err := OpenSmartWALVolume(rcfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer r2.Close()
|
||||
|
||||
// First 50 must be identical (synced)
|
||||
verifyExtentMatch(t, "synced-range", p2, r2, 50)
|
||||
|
||||
// Blocks 50-99: may or may not have survived. No corruption allowed.
|
||||
for i := uint32(50); i < 100; i++ {
|
||||
dp, _ := p2.ReadLBA(i)
|
||||
dr, _ := r2.ReadLBA(i)
|
||||
expected := makeTestBlock(byte(i))
|
||||
zeros := make([]byte, 4096)
|
||||
// Each must be either expected data or zeros
|
||||
if !bytes.Equal(dp, expected) && !bytes.Equal(dp, zeros) {
|
||||
t.Fatalf("primary LBA %d: neither expected nor zeros after dual crash", i)
|
||||
}
|
||||
if !bytes.Equal(dr, expected) && !bytes.Equal(dr, zeros) {
|
||||
t.Fatalf("replica LBA %d: neither expected nor zeros after dual crash", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test 12: Failover data integrity
|
||||
func TestSmartWAL_Repl_FailoverDataIntegrity(t *testing.T) {
|
||||
primary, replica, _, _ := createPrimaryReplica(t, 256)
|
||||
|
||||
// Write known data + sync + barrier (ship + sync replica)
|
||||
for i := uint32(0); i < 100; i++ {
|
||||
data := makeTestBlock(byte(i))
|
||||
primary.WriteLBA(i, data)
|
||||
shipWrite(replica, i, data, 1)
|
||||
}
|
||||
primary.SyncCache()
|
||||
replica.SyncCache()
|
||||
|
||||
// Kill primary — promote replica
|
||||
primary.Close()
|
||||
|
||||
// Read all from "new primary" (replica)
|
||||
for i := uint32(0); i < 100; i++ {
|
||||
data, err := replica.ReadLBA(i)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadLBA %d from promoted replica: %v", i, err)
|
||||
}
|
||||
expected := makeTestBlock(byte(i))
|
||||
if !bytes.Equal(data, expected) {
|
||||
t.Fatalf("LBA %d: data mismatch on promoted replica", i)
|
||||
}
|
||||
}
|
||||
replica.Close()
|
||||
}
|
||||
|
||||
// Test 13: Rebuild after WAL gap (dirty map delta)
|
||||
func TestSmartWAL_Repl_RebuildAfterWALGap(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pcfg := SmartWALVolumeConfig{
|
||||
ExtentPath: filepath.Join(dir, "primary_extent.dat"),
|
||||
WALPath: filepath.Join(dir, "primary_wal.dat"),
|
||||
BlockSize: 4096,
|
||||
NumBlocks: 256,
|
||||
WALSlots: 32, // tiny WAL — wraps fast
|
||||
Epoch: 1,
|
||||
}
|
||||
rcfg := SmartWALVolumeConfig{
|
||||
ExtentPath: filepath.Join(dir, "replica_extent.dat"),
|
||||
WALPath: filepath.Join(dir, "replica_wal.dat"),
|
||||
BlockSize: 4096,
|
||||
NumBlocks: 256,
|
||||
WALSlots: 1024,
|
||||
Epoch: 1,
|
||||
}
|
||||
|
||||
primary, err := CreateSmartWALVolume(pcfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
replica, err := CreateSmartWALVolume(rcfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Write many blocks on primary (WAL wraps, old records lost)
|
||||
// Replica was "down" — didn't receive any
|
||||
for i := uint32(0); i < 200; i++ {
|
||||
data := makeTestBlock(byte(i))
|
||||
if err := primary.WriteLBA(i, data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if i%50 == 49 {
|
||||
primary.SyncCache()
|
||||
}
|
||||
}
|
||||
primary.SyncCache()
|
||||
|
||||
// Rebuild: send all dirty blocks from primary extent to replica
|
||||
// (This simulates what the rebuild path would do — read extent, ship)
|
||||
for lba, _ := range primary.dirtyMap {
|
||||
data, err := primary.ReadLBA(lba)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := shipWrite(replica, lba, data, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
replica.SyncCache()
|
||||
|
||||
verifyExtentMatch(t, "post-rebuild", primary, replica, 200)
|
||||
primary.Close()
|
||||
replica.Close()
|
||||
}
|
||||
|
||||
// Test 14: Sustained replication under crash
|
||||
func TestSmartWAL_Repl_SustainedReplicationCrash(t *testing.T) {
|
||||
primary, replica, _, rcfg := createPrimaryReplica(t, 512)
|
||||
|
||||
rng := rand.New(rand.NewSource(99))
|
||||
var synced []struct {
|
||||
lba uint32
|
||||
data []byte
|
||||
}
|
||||
|
||||
// Phase 1: write + ship + sync for 200 blocks
|
||||
for i := 0; i < 200; i++ {
|
||||
lba := uint32(rng.Intn(512))
|
||||
data := make([]byte, 4096)
|
||||
rng.Read(data)
|
||||
primary.WriteLBA(lba, data)
|
||||
shipWrite(replica, lba, data, 1)
|
||||
if i%50 == 49 {
|
||||
primary.SyncCache()
|
||||
replica.SyncCache()
|
||||
synced = append(synced, struct {
|
||||
lba uint32
|
||||
data []byte
|
||||
}{lba, data})
|
||||
}
|
||||
}
|
||||
|
||||
// Kill replica mid-stream
|
||||
replica.Close()
|
||||
|
||||
// Primary continues writing 100 more
|
||||
for i := 0; i < 100; i++ {
|
||||
lba := uint32(rng.Intn(512))
|
||||
data := make([]byte, 4096)
|
||||
rng.Read(data)
|
||||
primary.WriteLBA(lba, data)
|
||||
}
|
||||
primary.SyncCache()
|
||||
|
||||
// Recover replica
|
||||
replica2, err := OpenSmartWALVolume(rcfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer replica2.Close()
|
||||
|
||||
// Catch-up: ship all dirty blocks from primary to replica
|
||||
primary.dirtyMu.Lock()
|
||||
for lba := range primary.dirtyMap {
|
||||
data, _ := primary.ReadLBA(lba)
|
||||
shipWrite(replica2, lba, data, 1)
|
||||
}
|
||||
primary.dirtyMu.Unlock()
|
||||
replica2.SyncCache()
|
||||
|
||||
// Final barrier: verify all 512 LBAs match
|
||||
verifyExtentMatch(t, "post-sustained-crash", primary, replica2, 512)
|
||||
|
||||
// Verify synced data survived on recovered replica
|
||||
for _, s := range synced {
|
||||
data, err := replica2.ReadLBA(s.lba)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Data should match (either synced or later catch-up)
|
||||
_ = crc32.ChecksumIEEE(data) // just verify no panic
|
||||
}
|
||||
primary.Close()
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
package blockvol
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"hash/crc32"
|
||||
"math/rand"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
// SmartWAL Single-Node Crash Tests (Prototype)
|
||||
//
|
||||
// These tests prove the core SmartWAL algorithm:
|
||||
// 1. Extent-first write + metadata-only WAL
|
||||
// 2. SyncCache barrier ordering (extent before WAL)
|
||||
// 3. Crash recovery via CRC verification
|
||||
// ============================================================
|
||||
|
||||
func createTestSmartWALVolume(t *testing.T, numBlocks uint64) *SmartWALVolume {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
cfg := SmartWALVolumeConfig{
|
||||
ExtentPath: filepath.Join(dir, "extent.dat"),
|
||||
WALPath: filepath.Join(dir, "wal.dat"),
|
||||
BlockSize: 4096,
|
||||
NumBlocks: numBlocks,
|
||||
WALSlots: 1024,
|
||||
Epoch: 1,
|
||||
}
|
||||
v, err := CreateSmartWALVolume(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSmartWALVolume: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { v.Close() })
|
||||
return v
|
||||
}
|
||||
|
||||
func makeTestBlock(pattern byte) []byte {
|
||||
b := make([]byte, 4096)
|
||||
for i := range b {
|
||||
b[i] = pattern
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// Test 1: Basic crash recovery
|
||||
// Write blocks → SyncCache → "crash" (close + reopen) → verify all blocks
|
||||
func TestSmartWAL_BasicCrashRecovery(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg := SmartWALVolumeConfig{
|
||||
ExtentPath: filepath.Join(dir, "extent.dat"),
|
||||
WALPath: filepath.Join(dir, "wal.dat"),
|
||||
BlockSize: 4096,
|
||||
NumBlocks: 256,
|
||||
WALSlots: 1024,
|
||||
Epoch: 1,
|
||||
}
|
||||
|
||||
// Write 100 blocks and sync
|
||||
v, err := CreateSmartWALVolume(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := uint32(0); i < 100; i++ {
|
||||
if err := v.WriteLBA(i, makeTestBlock(byte(i))); err != nil {
|
||||
t.Fatalf("WriteLBA %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
if err := v.SyncCache(); err != nil {
|
||||
t.Fatalf("SyncCache: %v", err)
|
||||
}
|
||||
v.Close()
|
||||
|
||||
// "Crash" recovery: reopen
|
||||
v2, err := OpenSmartWALVolume(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenSmartWALVolume: %v", err)
|
||||
}
|
||||
defer v2.Close()
|
||||
|
||||
// Verify all 100 blocks
|
||||
for i := uint32(0); i < 100; i++ {
|
||||
data, err := v2.ReadLBA(i)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadLBA %d: %v", i, err)
|
||||
}
|
||||
expected := makeTestBlock(byte(i))
|
||||
if !bytes.Equal(data, expected) {
|
||||
t.Fatalf("LBA %d: data mismatch after recovery", i)
|
||||
}
|
||||
}
|
||||
|
||||
// NextLSN should be > 100
|
||||
if v2.NextLSN() <= 100 {
|
||||
t.Fatalf("NextLSN=%d, want >100", v2.NextLSN())
|
||||
}
|
||||
}
|
||||
|
||||
// Test 2: Crash before SyncCache
|
||||
// Write blocks → NO SyncCache → "crash" → recover
|
||||
// All recovered records should have matching CRCs. No corruption.
|
||||
func TestSmartWAL_CrashBeforeSyncCache(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg := SmartWALVolumeConfig{
|
||||
ExtentPath: filepath.Join(dir, "extent.dat"),
|
||||
WALPath: filepath.Join(dir, "wal.dat"),
|
||||
BlockSize: 4096,
|
||||
NumBlocks: 256,
|
||||
WALSlots: 1024,
|
||||
Epoch: 1,
|
||||
}
|
||||
|
||||
v, err := CreateSmartWALVolume(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := uint32(0); i < 50; i++ {
|
||||
if err := v.WriteLBA(i, makeTestBlock(byte(i+0x80))); err != nil {
|
||||
t.Fatalf("WriteLBA %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
// NO SyncCache — close directly (simulates crash)
|
||||
v.Close()
|
||||
|
||||
// Recovery: whatever records survived are CRC-verified
|
||||
v2, err := OpenSmartWALVolume(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenSmartWALVolume: %v", err)
|
||||
}
|
||||
defer v2.Close()
|
||||
|
||||
// Read all blocks — no corruption allowed.
|
||||
// Some may have the written data, some may be zeros (not flushed).
|
||||
// But NO CRC mismatch in the recovery output (recovery logs mismatches).
|
||||
for i := uint32(0); i < 50; i++ {
|
||||
data, err := v2.ReadLBA(i)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadLBA %d: %v", i, err)
|
||||
}
|
||||
// Data is either the written pattern or zeros — both valid.
|
||||
expected := makeTestBlock(byte(i + 0x80))
|
||||
zeros := make([]byte, 4096)
|
||||
if !bytes.Equal(data, expected) && !bytes.Equal(data, zeros) {
|
||||
t.Fatalf("LBA %d: unexpected data (neither written nor zeros)", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test 3: Overwrite crash
|
||||
// Write dataA → SyncCache → Write dataB → crash (no sync) → recover
|
||||
// LBA should contain dataA (the durable version) OR dataB (if lucky flush).
|
||||
// Either is valid. Corruption is not.
|
||||
func TestSmartWAL_OverwriteCrash(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg := SmartWALVolumeConfig{
|
||||
ExtentPath: filepath.Join(dir, "extent.dat"),
|
||||
WALPath: filepath.Join(dir, "wal.dat"),
|
||||
BlockSize: 4096,
|
||||
NumBlocks: 256,
|
||||
WALSlots: 1024,
|
||||
Epoch: 1,
|
||||
}
|
||||
|
||||
dataA := makeTestBlock(0xAA)
|
||||
dataB := makeTestBlock(0xBB)
|
||||
|
||||
v, err := CreateSmartWALVolume(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := v.WriteLBA(100, dataA); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := v.SyncCache(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Overwrite without sync
|
||||
if err := v.WriteLBA(100, dataB); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
v.Close()
|
||||
|
||||
// Recovery
|
||||
v2, err := OpenSmartWALVolume(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer v2.Close()
|
||||
|
||||
data, err := v2.ReadLBA(100)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Either dataA (durable) or dataB (lucky flush) — both valid
|
||||
if !bytes.Equal(data, dataA) && !bytes.Equal(data, dataB) {
|
||||
t.Fatalf("LBA 100: data is neither dataA nor dataB after overwrite crash")
|
||||
}
|
||||
}
|
||||
|
||||
// Test 4: WAL wrap-around
|
||||
// Write enough blocks to wrap the ring buffer, syncing periodically.
|
||||
func TestSmartWAL_WALWrapAround(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg := SmartWALVolumeConfig{
|
||||
ExtentPath: filepath.Join(dir, "extent.dat"),
|
||||
WALPath: filepath.Join(dir, "wal.dat"),
|
||||
BlockSize: 4096,
|
||||
NumBlocks: 1024,
|
||||
WALSlots: 64, // small: wraps quickly
|
||||
Epoch: 1,
|
||||
}
|
||||
|
||||
v, err := CreateSmartWALVolume(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Write 200 blocks (wraps 64-slot WAL ~3 times), sync every 50
|
||||
for i := uint32(0); i < 200; i++ {
|
||||
if err := v.WriteLBA(i%1024, makeTestBlock(byte(i))); err != nil {
|
||||
t.Fatalf("WriteLBA %d: %v", i, err)
|
||||
}
|
||||
if i%50 == 49 {
|
||||
if err := v.SyncCache(); err != nil {
|
||||
t.Fatalf("SyncCache at %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := v.SyncCache(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
v.Close()
|
||||
|
||||
// Recovery
|
||||
v2, err := OpenSmartWALVolume(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer v2.Close()
|
||||
|
||||
// Verify the last written data for LBAs 0-199 (mod 1024)
|
||||
// Only the LAST write to each LBA matters
|
||||
for i := uint32(0); i < 200; i++ {
|
||||
lba := i % 1024
|
||||
data, err := v2.ReadLBA(lba)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadLBA %d: %v", lba, err)
|
||||
}
|
||||
// Last writer wins. For LBA 0: last write was i=0 (pattern 0).
|
||||
// For LBA 1: i=1 (pattern 1). Etc.
|
||||
// Since all LBAs < 200 and numBlocks=1024, no wrapping on LBAs.
|
||||
expected := makeTestBlock(byte(i))
|
||||
if !bytes.Equal(data, expected) {
|
||||
t.Fatalf("LBA %d: data mismatch after WAL wrap recovery (wrote at i=%d)", lba, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test 5: Record encode/decode round-trip
|
||||
func TestSmartWAL_RecordRoundTrip(t *testing.T) {
|
||||
rec := SmartWALRecord{
|
||||
LSN: 42,
|
||||
Epoch: 7,
|
||||
LBA: 0x1234,
|
||||
Flags: SmartFlagWrite,
|
||||
DataCRC32: 0xDEADBEEF,
|
||||
}
|
||||
encoded := EncodeSmartWALRecord(rec)
|
||||
decoded, ok := DecodeSmartWALRecord(encoded[:])
|
||||
if !ok {
|
||||
t.Fatal("decode failed for valid record")
|
||||
}
|
||||
if decoded.LSN != rec.LSN || decoded.Epoch != rec.Epoch ||
|
||||
decoded.LBA != rec.LBA || decoded.Flags != rec.Flags ||
|
||||
decoded.DataCRC32 != rec.DataCRC32 {
|
||||
t.Fatalf("round-trip mismatch: %+v → %+v", rec, decoded)
|
||||
}
|
||||
}
|
||||
|
||||
// Test 6: Invalid record detection
|
||||
func TestSmartWAL_InvalidRecordDetection(t *testing.T) {
|
||||
// All zeros → invalid (no magic)
|
||||
zeros := make([]byte, SmartWALRecordSize)
|
||||
if _, ok := DecodeSmartWALRecord(zeros); ok {
|
||||
t.Fatal("zeros should decode as invalid")
|
||||
}
|
||||
|
||||
// Valid record with corrupted CRC
|
||||
rec := SmartWALRecord{LSN: 1, Epoch: 1, LBA: 0, Flags: SmartFlagWrite}
|
||||
encoded := EncodeSmartWALRecord(rec)
|
||||
encoded[30] ^= 0xFF // corrupt record CRC
|
||||
if _, ok := DecodeSmartWALRecord(encoded[:]); ok {
|
||||
t.Fatal("corrupted CRC should decode as invalid")
|
||||
}
|
||||
}
|
||||
|
||||
// Test 7: Sustained random writes + crash + recovery (fuzz-like)
|
||||
func TestSmartWAL_SustainedRandomWriteCrash(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg := SmartWALVolumeConfig{
|
||||
ExtentPath: filepath.Join(dir, "extent.dat"),
|
||||
WALPath: filepath.Join(dir, "wal.dat"),
|
||||
BlockSize: 4096,
|
||||
NumBlocks: 512,
|
||||
WALSlots: 256,
|
||||
Epoch: 1,
|
||||
}
|
||||
|
||||
v, err := CreateSmartWALVolume(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rng := rand.New(rand.NewSource(42))
|
||||
written := make(map[uint32][]byte) // last synced data per LBA
|
||||
pending := make(map[uint32][]byte) // unsynced data
|
||||
|
||||
// Phase 1: write + sync 500 blocks
|
||||
for i := 0; i < 500; i++ {
|
||||
lba := uint32(rng.Intn(512))
|
||||
data := make([]byte, 4096)
|
||||
rng.Read(data)
|
||||
if err := v.WriteLBA(lba, data); err != nil {
|
||||
t.Fatalf("WriteLBA: %v", err)
|
||||
}
|
||||
pending[lba] = data
|
||||
|
||||
if i%100 == 99 {
|
||||
if err := v.SyncCache(); err != nil {
|
||||
t.Fatalf("SyncCache: %v", err)
|
||||
}
|
||||
for lba, data := range pending {
|
||||
written[lba] = data
|
||||
}
|
||||
pending = make(map[uint32][]byte)
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: write 200 more WITHOUT syncing
|
||||
for i := 0; i < 200; i++ {
|
||||
lba := uint32(rng.Intn(512))
|
||||
data := make([]byte, 4096)
|
||||
rng.Read(data)
|
||||
if err := v.WriteLBA(lba, data); err != nil {
|
||||
t.Fatalf("WriteLBA: %v", err)
|
||||
}
|
||||
pending[lba] = data
|
||||
}
|
||||
|
||||
// "Crash" — close without final sync
|
||||
v.Close()
|
||||
|
||||
// Recovery
|
||||
v2, err := OpenSmartWALVolume(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer v2.Close()
|
||||
|
||||
// Verify synced data is intact
|
||||
for lba, expected := range written {
|
||||
data, err := v2.ReadLBA(lba)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadLBA %d: %v", lba, err)
|
||||
}
|
||||
// Data should be either the synced version or a later unsynced write
|
||||
// (if it happened to flush). Either is valid.
|
||||
syncedCRC := crc32.ChecksumIEEE(expected)
|
||||
actualCRC := crc32.ChecksumIEEE(data)
|
||||
if syncedCRC != actualCRC {
|
||||
// Check if it's a valid pending write
|
||||
if pendingData, ok := pending[lba]; ok {
|
||||
pendingCRC := crc32.ChecksumIEEE(pendingData)
|
||||
if actualCRC == pendingCRC {
|
||||
continue // valid: pending write flushed
|
||||
}
|
||||
}
|
||||
// Data is neither synced nor pending — corruption
|
||||
t.Fatalf("LBA %d: data is neither synced nor pending version — corruption", lba)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test: Trim + recovery
|
||||
func TestSmartWAL_TrimRecovery(t *testing.T) {
|
||||
v := createTestSmartWALVolume(t, 64)
|
||||
|
||||
// Write, sync, trim, sync
|
||||
if err := v.WriteLBA(10, makeTestBlock(0xDD)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := v.SyncCache(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := v.TrimLBA(10); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := v.SyncCache(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Verify trimmed block is zeros
|
||||
data, err := v.ReadLBA(10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(data, make([]byte, 4096)) {
|
||||
t.Fatal("trimmed block should be zeros")
|
||||
}
|
||||
}
|
||||
|
||||
// Test: Concurrent writes (no race)
|
||||
func TestSmartWAL_ConcurrentWrites(t *testing.T) {
|
||||
v := createTestSmartWALVolume(t, 256)
|
||||
done := make(chan error, 8)
|
||||
|
||||
for g := 0; g < 8; g++ {
|
||||
g := g
|
||||
go func() {
|
||||
for i := 0; i < 50; i++ {
|
||||
lba := uint32(g*32 + i%32)
|
||||
data := makeTestBlock(byte(g*32 + i))
|
||||
if err := v.WriteLBA(lba, data); err != nil {
|
||||
done <- fmt.Errorf("goroutine %d write %d: %v", g, i, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
done <- nil
|
||||
}()
|
||||
}
|
||||
for i := 0; i < 8; i++ {
|
||||
if err := <-done; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := v.SyncCache(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package component
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/sw-block/protocol"
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/blockvol"
|
||||
)
|
||||
|
||||
// TestFastRejoinCatchUp_FailoverRejoinUsesRetainedWAL proves the complement of
|
||||
// the rebuild-rejoin scenario: when the old primary returns quickly enough that
|
||||
// the new primary still retains the missing WAL, the protocol must choose
|
||||
// catch-up and the real shipper path must converge without starting rebuild.
|
||||
func TestFastRejoinCatchUp_FailoverRejoinUsesRetainedWAL(t *testing.T) {
|
||||
nodeAPath := filepath.Join(t.TempDir(), "nodeA.blk")
|
||||
nodeBPath := filepath.Join(t.TempDir(), "nodeB.blk")
|
||||
|
||||
blockSize := uint32(4096)
|
||||
opts := blockvol.CreateOptions{
|
||||
VolumeSize: 8 * 1024 * 1024,
|
||||
BlockSize: blockSize,
|
||||
// Keep enough WAL so the quick rejoin stays within the retained window.
|
||||
WALSize: 2 * 1024 * 1024,
|
||||
}
|
||||
|
||||
nodeA, err := blockvol.CreateBlockVol(nodeAPath, opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nodeA.HandleAssignment(1, blockvol.RolePrimary, 30*time.Second)
|
||||
|
||||
nodeB, err := blockvol.CreateBlockVol(nodeBPath, opts)
|
||||
if err != nil {
|
||||
nodeA.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer nodeB.Close()
|
||||
// Preload nodeB's local data before assigning replica role so we start
|
||||
// Phase 1 from an already-synced pair.
|
||||
nodeB.HandleAssignment(1, blockvol.RolePrimary, 30*time.Second)
|
||||
|
||||
initialBlocks := 64
|
||||
for i := 0; i < initialBlocks; i++ {
|
||||
data := deterministicBlockR10(uint64(i), 1, blockSize)
|
||||
if err := nodeA.WriteLBA(uint64(i), data); err != nil {
|
||||
t.Fatalf("nodeA initial write LBA %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
if err := nodeA.SyncCache(); err != nil {
|
||||
t.Fatalf("nodeA initial SyncCache: %v", err)
|
||||
}
|
||||
if err := nodeA.ForceFlush(); err != nil {
|
||||
t.Fatalf("nodeA initial ForceFlush: %v", err)
|
||||
}
|
||||
|
||||
for i := 0; i < initialBlocks; i++ {
|
||||
data, err := nodeA.ReadLBA(uint64(i), blockSize)
|
||||
if err != nil {
|
||||
t.Fatalf("nodeA read for initial sync LBA %d: %v", i, err)
|
||||
}
|
||||
if err := nodeB.WriteLBA(uint64(i), data); err != nil {
|
||||
t.Fatalf("nodeB initial sync write LBA %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
if err := nodeB.SyncCache(); err != nil {
|
||||
t.Fatalf("nodeB initial SyncCache: %v", err)
|
||||
}
|
||||
if err := nodeB.ForceFlush(); err != nil {
|
||||
t.Fatalf("nodeB initial ForceFlush: %v", err)
|
||||
}
|
||||
nodeB.HandleAssignment(1, blockvol.RoleReplica, 30*time.Second)
|
||||
|
||||
nodeAInitialLSN := nodeA.Status().WALHeadLSN
|
||||
nodeBTailBefore := nodeB.Status().CheckpointLSN
|
||||
t.Logf("Phase 1: synced at LSN=%d checkpoint=%d", nodeAInitialLSN, nodeBTailBefore)
|
||||
|
||||
if err := nodeA.Close(); err != nil {
|
||||
t.Fatalf("close nodeA: %v", err)
|
||||
}
|
||||
t.Log("Phase 2: nodeA (old primary) stopped")
|
||||
|
||||
nodeB.HandleAssignment(2, blockvol.RolePrimary, 30*time.Second)
|
||||
t.Log("Phase 3: nodeB promoted to primary (epoch=2)")
|
||||
|
||||
postFailoverWrites := 12
|
||||
for i := 0; i < postFailoverWrites; i++ {
|
||||
lba := uint64(i % 8)
|
||||
data := deterministicBlockR10(lba, uint64(i+2), blockSize)
|
||||
if err := nodeB.WriteLBA(lba, data); err != nil {
|
||||
t.Fatalf("nodeB post-failover write %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
// Sync WAL durability but do not flush/recycle it; the gap should remain
|
||||
// recoverable from retained WAL.
|
||||
if err := nodeB.SyncCache(); err != nil {
|
||||
t.Fatalf("nodeB post-failover SyncCache: %v", err)
|
||||
}
|
||||
|
||||
nodeBStatus := nodeB.Status()
|
||||
nodeBWALTail := nodeBStatus.CheckpointLSN
|
||||
nodeBWALHead := nodeBStatus.WALHeadLSN
|
||||
if nodeBWALTail > nodeAInitialLSN {
|
||||
t.Fatalf("expected retained WAL for catch-up: tail=%d initial=%d", nodeBWALTail, nodeAInitialLSN)
|
||||
}
|
||||
if nodeBWALHead <= nodeAInitialLSN {
|
||||
t.Fatalf("expected head to advance after failover writes: head=%d initial=%d", nodeBWALHead, nodeAInitialLSN)
|
||||
}
|
||||
t.Logf("Phase 3: post-failover head=%d tail=%d", nodeBWALHead, nodeBWALTail)
|
||||
|
||||
nodeA, err = blockvol.OpenBlockVol(nodeAPath)
|
||||
if err != nil {
|
||||
t.Fatalf("reopen nodeA: %v", err)
|
||||
}
|
||||
defer nodeA.Close()
|
||||
nodeA.HandleAssignment(2, blockvol.RoleReplica, 30*time.Second)
|
||||
nodeARestartLSN := nodeA.Status().WALHeadLSN
|
||||
t.Logf("Phase 4: nodeA restarted as replica, WALHeadLSN=%d", nodeARestartLSN)
|
||||
|
||||
eng := protocol.NewEngine()
|
||||
eng.ApplyEvent(protocol.AssignmentDelivered{
|
||||
VolumeID: "vol-1", Epoch: 2, Role: protocol.RolePrimary,
|
||||
Replicas: []protocol.ReplicaAssignment{
|
||||
{ReplicaID: "nodeA", Endpoint: protocol.Endpoint{DataAddr: "a", CtrlAddr: "b"}},
|
||||
},
|
||||
})
|
||||
boolTrue := true
|
||||
eng.ApplyEvent(protocol.ReadinessObserved{
|
||||
VolumeID: "vol-1", RoleApplied: &boolTrue,
|
||||
ShipperConfigured: &boolTrue, ShipperConnected: &boolTrue,
|
||||
})
|
||||
|
||||
result := eng.ApplyEvent(protocol.SyncAckReceived{
|
||||
VolumeID: "vol-1",
|
||||
ReplicaID: "nodeA",
|
||||
Ack: protocol.SyncAck{AppliedLSN: nodeARestartLSN, DurableLSN: nodeARestartLSN},
|
||||
PrimaryWALTail: nodeBWALTail,
|
||||
PrimaryWALHead: nodeBWALHead,
|
||||
})
|
||||
|
||||
var catchUpCmd *protocol.IssueCatchUpCommand
|
||||
var rebuildCmd *protocol.IssueRebuildCommand
|
||||
for _, cmd := range result.Commands {
|
||||
if c, ok := cmd.(protocol.IssueCatchUpCommand); ok {
|
||||
catchUpCmd = &c
|
||||
}
|
||||
if c, ok := cmd.(protocol.IssueRebuildCommand); ok {
|
||||
rebuildCmd = &c
|
||||
}
|
||||
}
|
||||
if catchUpCmd == nil {
|
||||
if rebuildCmd != nil {
|
||||
t.Fatalf("expected catch-up, got rebuild target=%d (replica=%d tail=%d head=%d)",
|
||||
rebuildCmd.TargetLSN, nodeARestartLSN, nodeBWALTail, nodeBWALHead)
|
||||
}
|
||||
t.Fatalf("expected catch-up command for retained WAL (replica=%d tail=%d head=%d)",
|
||||
nodeARestartLSN, nodeBWALTail, nodeBWALHead)
|
||||
}
|
||||
t.Logf("Phase 5: engine decided CATCH-UP start=%d target=%d", catchUpCmd.StartLSN, catchUpCmd.TargetLSN)
|
||||
|
||||
if err := nodeA.StartReplicaReceiver(":0", ":0"); err != nil {
|
||||
t.Fatalf("start replica receiver: %v", err)
|
||||
}
|
||||
recvAddr := nodeA.ReplicaReceiverAddr()
|
||||
nodeB.SetReplicaAddrs([]blockvol.ReplicaAddr{{
|
||||
ServerID: "nodeA",
|
||||
DataAddr: recvAddr.DataAddr,
|
||||
CtrlAddr: recvAddr.CtrlAddr,
|
||||
}})
|
||||
|
||||
achieved, err := nodeB.CatchUpReplicaTo("nodeA", catchUpCmd.TargetLSN)
|
||||
if err != nil {
|
||||
t.Fatalf("catch-up failed: %v", err)
|
||||
}
|
||||
if achieved < catchUpCmd.TargetLSN {
|
||||
t.Fatalf("achieved_lsn=%d, want >= %d", achieved, catchUpCmd.TargetLSN)
|
||||
}
|
||||
|
||||
states := nodeB.ReplicaShipperStates()
|
||||
if len(states) != 1 {
|
||||
t.Fatalf("shipper states=%+v, want 1 shipper", states)
|
||||
}
|
||||
if states[0].State != "in_sync" {
|
||||
t.Fatalf("shipper state=%s, want in_sync", states[0].State)
|
||||
}
|
||||
|
||||
if err := nodeB.ForceFlush(); err != nil {
|
||||
t.Fatalf("nodeB final ForceFlush: %v", err)
|
||||
}
|
||||
if err := nodeA.ForceFlush(); err != nil {
|
||||
t.Fatalf("nodeA final ForceFlush: %v", err)
|
||||
}
|
||||
|
||||
for lba := uint64(0); lba < uint64(initialBlocks); lba++ {
|
||||
bData, err := nodeB.ReadLBA(lba, blockSize)
|
||||
if err != nil {
|
||||
t.Fatalf("nodeB read LBA %d: %v", lba, err)
|
||||
}
|
||||
aData, err := nodeA.ReadLBA(lba, blockSize)
|
||||
if err != nil {
|
||||
t.Fatalf("nodeA read LBA %d: %v", lba, err)
|
||||
}
|
||||
if !bytes.Equal(bData, aData) {
|
||||
t.Fatalf("LBA %d mismatch after catch-up", lba)
|
||||
}
|
||||
}
|
||||
|
||||
if _, _, active := nodeA.ActiveRebuildSession(); active {
|
||||
t.Fatal("unexpected rebuild session on fast rejoin catch-up path")
|
||||
}
|
||||
|
||||
t.Logf("FAST REJOIN PASSED: retained WAL allowed catch-up to %d without rebuild", achieved)
|
||||
}
|
||||
+263
@@ -0,0 +1,263 @@
|
||||
name: recovery-baseline-failover
|
||||
timeout: 10m
|
||||
|
||||
# Robust dimension: automatic failover after primary death.
|
||||
#
|
||||
# Flow:
|
||||
# 1. Create RF=2 sync_all volume on the natural primary
|
||||
# 2. Bootstrap first barrier with a real write, then record primary
|
||||
# 3. Kill primary VS (SIGKILL)
|
||||
# 4. Wait for lease expiry (30s TTL + margin)
|
||||
# 5. Verify: master auto-promotes replica to primary (no manual promote)
|
||||
# 6. Reconnect iSCSI to new primary, verify I/O works
|
||||
#
|
||||
# This tests the master's automatic failover path via
|
||||
# evaluatePromotionLocked() in master_block_failover.go.
|
||||
|
||||
env:
|
||||
master_url: "http://10.0.0.3:9433"
|
||||
volume_name: rb-failover
|
||||
vol_size: "1073741824"
|
||||
|
||||
topology:
|
||||
nodes:
|
||||
m01:
|
||||
host: 192.168.1.181
|
||||
alt_ips: ["10.0.0.1"]
|
||||
user: testdev
|
||||
key: "/opt/work/testdev_key"
|
||||
m02:
|
||||
host: 192.168.1.184
|
||||
alt_ips: ["10.0.0.3"]
|
||||
user: testdev
|
||||
key: "/opt/work/testdev_key"
|
||||
|
||||
phases:
|
||||
- name: cluster-start
|
||||
actions:
|
||||
- action: exec
|
||||
node: m02
|
||||
cmd: "fuser -k 9433/tcp 18480/tcp 2>/dev/null; sleep 1; rm -rf /tmp/sw-fo-master /tmp/sw-fo-vs1 && mkdir -p /tmp/sw-fo-master /tmp/sw-fo-vs1/blocks"
|
||||
root: "true"
|
||||
ignore_error: true
|
||||
- action: exec
|
||||
node: m01
|
||||
cmd: "fuser -k 18480/tcp 2>/dev/null; sleep 1; rm -rf /tmp/sw-fo-vs2 && mkdir -p /tmp/sw-fo-vs2/blocks"
|
||||
root: "true"
|
||||
ignore_error: true
|
||||
|
||||
- action: start_weed_master
|
||||
node: m02
|
||||
port: "9433"
|
||||
dir: /tmp/sw-fo-master
|
||||
extra_args: "-ip=10.0.0.3"
|
||||
save_as: master_pid
|
||||
|
||||
- action: sleep
|
||||
duration: 3s
|
||||
|
||||
- action: start_weed_volume
|
||||
node: m02
|
||||
port: "18480"
|
||||
master: "10.0.0.3:9433"
|
||||
dir: /tmp/sw-fo-vs1
|
||||
extra_args: "-block.dir=/tmp/sw-fo-vs1/blocks -block.listen=:3295 -ip=10.0.0.3"
|
||||
save_as: vs1_pid
|
||||
|
||||
- action: start_weed_volume
|
||||
node: m01
|
||||
port: "18480"
|
||||
master: "10.0.0.3:9433"
|
||||
dir: /tmp/sw-fo-vs2
|
||||
extra_args: "-block.dir=/tmp/sw-fo-vs2/blocks -block.listen=:3295 -ip=10.0.0.1"
|
||||
save_as: vs2_pid
|
||||
|
||||
- action: sleep
|
||||
duration: 3s
|
||||
|
||||
- action: wait_cluster_ready
|
||||
node: m02
|
||||
master_url: "{{ master_url }}"
|
||||
|
||||
- action: wait_block_servers
|
||||
count: "2"
|
||||
|
||||
- name: create-volume
|
||||
actions:
|
||||
- action: create_block_volume
|
||||
name: "{{ volume_name }}"
|
||||
size_bytes: "{{ vol_size }}"
|
||||
replica_factor: "2"
|
||||
durability_mode: "sync_all"
|
||||
|
||||
- name: record-before
|
||||
actions:
|
||||
- action: discover_primary
|
||||
name: "{{ volume_name }}"
|
||||
save_as: before
|
||||
|
||||
- action: print
|
||||
msg: "Before: primary={{ before }} ({{ before_server }}), replica={{ before_replica_node }}"
|
||||
|
||||
# Step 1: Wait for volume to become healthy BEFORE any writes.
|
||||
# This gives the shipper time to bootstrap and reach in_sync.
|
||||
- action: wait_volume_healthy
|
||||
name: "{{ volume_name }}"
|
||||
timeout: 60s
|
||||
|
||||
- action: lookup_block_volume
|
||||
name: "{{ volume_name }}"
|
||||
save_as: vol
|
||||
|
||||
- action: iscsi_login_direct
|
||||
node: m01
|
||||
host: "{{ vol_iscsi_host }}"
|
||||
port: "{{ vol_iscsi_port }}"
|
||||
iqn: "{{ vol_iqn }}"
|
||||
save_as: device
|
||||
|
||||
# Step 2: fio + dd_write after volume is healthy.
|
||||
- action: fio_json
|
||||
node: m01
|
||||
device: "{{ device }}"
|
||||
rw: randwrite
|
||||
bs: 4k
|
||||
iodepth: "16"
|
||||
runtime: "10"
|
||||
time_based: "true"
|
||||
name: pre-write
|
||||
|
||||
- action: dd_write
|
||||
node: m01
|
||||
device: "{{ device }}"
|
||||
bs: 1M
|
||||
count: "2"
|
||||
seek: "16"
|
||||
sync_mode: fsync
|
||||
save_as: pre_failover_md5
|
||||
|
||||
- action: dd_read_md5
|
||||
node: m01
|
||||
device: "{{ device }}"
|
||||
bs: 1M
|
||||
count: "2"
|
||||
skip: "16"
|
||||
save_as: pre_failover_verify
|
||||
|
||||
- action: assert_equal
|
||||
actual: "{{ pre_failover_verify }}"
|
||||
expected: "{{ pre_failover_md5 }}"
|
||||
|
||||
- action: iscsi_cleanup
|
||||
node: m01
|
||||
ignore_error: true
|
||||
|
||||
- name: kill-primary
|
||||
actions:
|
||||
- action: print
|
||||
msg: "=== Killing primary ({{ before_server }}) ==="
|
||||
|
||||
# The cluster starts m02 before m01, so the natural initial primary is
|
||||
# the first server (m02 / vs1_pid). Keep the kill target fixed here and
|
||||
# use discover_primary only as an evidence check.
|
||||
- action: exec
|
||||
node: m02
|
||||
cmd: "kill -9 {{ vs1_pid }}"
|
||||
root: "true"
|
||||
ignore_error: true
|
||||
|
||||
- action: print
|
||||
msg: "Primary killed. Waiting for lease expiry (45s)..."
|
||||
|
||||
# Lease TTL is 30s. Wait 45s for expiry + master failover cycle.
|
||||
- action: sleep
|
||||
duration: 45s
|
||||
|
||||
- name: verify-auto-failover
|
||||
actions:
|
||||
# Master should auto-promote m01 (the surviving replica) to primary.
|
||||
# Wait for primary to change from m02 to something else.
|
||||
- action: wait_block_primary
|
||||
name: "{{ volume_name }}"
|
||||
not: "{{ before_server }}"
|
||||
timeout: 60s
|
||||
save_as: after
|
||||
|
||||
- action: wait_volume_healthy
|
||||
name: "{{ volume_name }}"
|
||||
timeout: 60s
|
||||
|
||||
- action: discover_primary
|
||||
name: "{{ volume_name }}"
|
||||
save_as: new_pri
|
||||
|
||||
- action: print
|
||||
msg: "After auto-failover: primary={{ new_pri }} ({{ new_pri_server }})"
|
||||
|
||||
- action: print
|
||||
msg: "AUTO-FAILOVER VERIFIED: {{ before_server }} → {{ new_pri_server }}"
|
||||
|
||||
- name: verify-io-after
|
||||
actions:
|
||||
# Reconnect iSCSI to the new primary (m01, which is local).
|
||||
# Use the original lookup vars — iSCSI addr is on the VS, not from registry.
|
||||
- action: iscsi_login_direct
|
||||
node: m01
|
||||
host: "10.0.0.1"
|
||||
port: "3295"
|
||||
iqn: "{{ vol_iqn }}"
|
||||
save_as: device2
|
||||
|
||||
- action: dd_read_md5
|
||||
node: m01
|
||||
device: "{{ device2 }}"
|
||||
bs: 1M
|
||||
count: "2"
|
||||
skip: "16"
|
||||
save_as: post_failover_md5
|
||||
|
||||
- action: assert_equal
|
||||
actual: "{{ post_failover_md5 }}"
|
||||
expected: "{{ pre_failover_md5 }}"
|
||||
|
||||
- action: fio_json
|
||||
node: m01
|
||||
device: "{{ device2 }}"
|
||||
rw: randwrite
|
||||
bs: 4k
|
||||
iodepth: "16"
|
||||
runtime: "10"
|
||||
time_based: "true"
|
||||
name: post-failover-write
|
||||
save_as: fio_after
|
||||
|
||||
- action: fio_parse
|
||||
json_var: fio_after
|
||||
metric: iops
|
||||
save_as: iops_after
|
||||
|
||||
- action: print
|
||||
msg: "Post-failover write IOPS: {{ iops_after }}"
|
||||
|
||||
- action: iscsi_cleanup
|
||||
node: m01
|
||||
ignore_error: true
|
||||
|
||||
- name: cleanup
|
||||
always: true
|
||||
actions:
|
||||
- action: iscsi_cleanup
|
||||
node: m01
|
||||
ignore_error: true
|
||||
- action: stop_weed
|
||||
node: m01
|
||||
pid: "{{ vs2_pid }}"
|
||||
ignore_error: true
|
||||
- action: stop_weed
|
||||
node: m02
|
||||
pid: "{{ vs1_pid }}"
|
||||
ignore_error: true
|
||||
- action: stop_weed
|
||||
node: m02
|
||||
pid: "{{ master_pid }}"
|
||||
ignore_error: true
|
||||
@@ -0,0 +1,151 @@
|
||||
name: recovery-bootstrap-closure
|
||||
timeout: 10m
|
||||
|
||||
# Stage 0 closure scenario:
|
||||
# 1. Create RF=2 sync_all volume
|
||||
# 2. Wait for assignment/shipper wiring
|
||||
# 3. Issue one small explicit fsync-backed write
|
||||
# 4. Wait until the volume reaches publish_healthy
|
||||
#
|
||||
# This scenario intentionally stops after bootstrap closure. Sustained workload,
|
||||
# large writes, and failover validation belong to Stage 1.
|
||||
|
||||
env:
|
||||
master_url: "http://10.0.0.3:9433"
|
||||
volume_name: rb-bootstrap
|
||||
vol_size: "1073741824"
|
||||
|
||||
topology:
|
||||
nodes:
|
||||
m01:
|
||||
host: 192.168.1.181
|
||||
alt_ips: ["10.0.0.1"]
|
||||
user: testdev
|
||||
key: "/opt/work/testdev_key"
|
||||
m02:
|
||||
host: 192.168.1.184
|
||||
alt_ips: ["10.0.0.3"]
|
||||
user: testdev
|
||||
key: "/opt/work/testdev_key"
|
||||
|
||||
phases:
|
||||
- name: cluster-start
|
||||
actions:
|
||||
- action: exec
|
||||
node: m02
|
||||
cmd: "fuser -k 9433/tcp 18480/tcp 2>/dev/null; sleep 1; rm -rf /tmp/sw-fo-master /tmp/sw-fo-vs1 && mkdir -p /tmp/sw-fo-master /tmp/sw-fo-vs1/blocks"
|
||||
root: "true"
|
||||
ignore_error: true
|
||||
- action: exec
|
||||
node: m01
|
||||
cmd: "fuser -k 18480/tcp 2>/dev/null; sleep 1; rm -rf /tmp/sw-fo-vs2 && mkdir -p /tmp/sw-fo-vs2/blocks"
|
||||
root: "true"
|
||||
ignore_error: true
|
||||
|
||||
- action: start_weed_master
|
||||
node: m02
|
||||
port: "9433"
|
||||
dir: /tmp/sw-fo-master
|
||||
extra_args: "-ip=10.0.0.3"
|
||||
save_as: master_pid
|
||||
|
||||
- action: sleep
|
||||
duration: 3s
|
||||
|
||||
- action: start_weed_volume
|
||||
node: m02
|
||||
port: "18480"
|
||||
master: "10.0.0.3:9433"
|
||||
dir: /tmp/sw-fo-vs1
|
||||
extra_args: "-block.dir=/tmp/sw-fo-vs1/blocks -block.listen=:3295 -ip=10.0.0.3"
|
||||
save_as: vs1_pid
|
||||
|
||||
- action: start_weed_volume
|
||||
node: m01
|
||||
port: "18480"
|
||||
master: "10.0.0.3:9433"
|
||||
dir: /tmp/sw-fo-vs2
|
||||
extra_args: "-block.dir=/tmp/sw-fo-vs2/blocks -block.listen=:3295 -ip=10.0.0.1"
|
||||
save_as: vs2_pid
|
||||
|
||||
- action: sleep
|
||||
duration: 3s
|
||||
|
||||
- action: wait_cluster_ready
|
||||
node: m02
|
||||
master_url: "{{ master_url }}"
|
||||
|
||||
- action: wait_block_servers
|
||||
count: "2"
|
||||
|
||||
- name: create-volume
|
||||
actions:
|
||||
- action: create_block_volume
|
||||
name: "{{ volume_name }}"
|
||||
size_bytes: "{{ vol_size }}"
|
||||
replica_factor: "2"
|
||||
durability_mode: "sync_all"
|
||||
|
||||
- name: bootstrap-fence
|
||||
actions:
|
||||
- action: discover_primary
|
||||
name: "{{ volume_name }}"
|
||||
save_as: before
|
||||
|
||||
- action: print
|
||||
msg: "Before: primary={{ before }} ({{ before_server }}), replica={{ before_replica_node }}"
|
||||
|
||||
- action: lookup_block_volume
|
||||
name: "{{ volume_name }}"
|
||||
save_as: vol
|
||||
|
||||
- action: sleep
|
||||
duration: 10s
|
||||
|
||||
- action: iscsi_login_direct
|
||||
node: m01
|
||||
host: "{{ vol_iscsi_host }}"
|
||||
port: "{{ vol_iscsi_port }}"
|
||||
iqn: "{{ vol_iqn }}"
|
||||
save_as: device
|
||||
|
||||
- action: dd_write
|
||||
node: m01
|
||||
device: "{{ device }}"
|
||||
bs: 4k
|
||||
count: "1"
|
||||
sync_mode: fsync
|
||||
save_as: bootstrap_md5
|
||||
|
||||
- action: print
|
||||
msg: "Bootstrap fence passed — first barrier confirmed (md5={{ bootstrap_md5 }})"
|
||||
|
||||
- action: wait_volume_healthy
|
||||
name: "{{ volume_name }}"
|
||||
timeout: 60s
|
||||
|
||||
- action: print
|
||||
msg: "Volume healthy — bootstrap closure complete"
|
||||
|
||||
- action: iscsi_cleanup
|
||||
node: m01
|
||||
ignore_error: true
|
||||
|
||||
- name: cleanup
|
||||
always: true
|
||||
actions:
|
||||
- action: iscsi_cleanup
|
||||
node: m01
|
||||
ignore_error: true
|
||||
- action: stop_weed
|
||||
node: m01
|
||||
pid: "{{ vs2_pid }}"
|
||||
ignore_error: true
|
||||
- action: stop_weed
|
||||
node: m02
|
||||
pid: "{{ vs1_pid }}"
|
||||
ignore_error: true
|
||||
- action: stop_weed
|
||||
node: m02
|
||||
pid: "{{ master_pid }}"
|
||||
ignore_error: true
|
||||
@@ -0,0 +1,154 @@
|
||||
name: rf1-perf-compare
|
||||
timeout: 3m
|
||||
|
||||
# RF=1 performance baseline — no replication overhead.
|
||||
# Use this to compare V1.5 vs V2 engine overhead only.
|
||||
|
||||
env:
|
||||
master_url: "http://10.0.0.3:9433"
|
||||
volume_name: rf1-perf
|
||||
vol_size: "1073741824"
|
||||
|
||||
topology:
|
||||
nodes:
|
||||
m01:
|
||||
host: 192.168.1.181
|
||||
alt_ips: ["10.0.0.1"]
|
||||
user: testdev
|
||||
key: "/opt/work/testdev_key"
|
||||
m02:
|
||||
host: 192.168.1.184
|
||||
alt_ips: ["10.0.0.3"]
|
||||
user: testdev
|
||||
key: "/opt/work/testdev_key"
|
||||
|
||||
phases:
|
||||
- name: cluster-start
|
||||
actions:
|
||||
- action: exec
|
||||
node: m02
|
||||
cmd: "fuser -k 9433/tcp 18480/tcp 2>/dev/null; sleep 1; rm -rf /tmp/sw-perf-master /tmp/sw-perf-vs1 && mkdir -p /tmp/sw-perf-master /tmp/sw-perf-vs1/blocks"
|
||||
root: "true"
|
||||
ignore_error: true
|
||||
- action: exec
|
||||
node: m01
|
||||
cmd: "fuser -k 18480/tcp 2>/dev/null; sleep 1; rm -rf /tmp/sw-perf-vs2 && mkdir -p /tmp/sw-perf-vs2/blocks"
|
||||
root: "true"
|
||||
ignore_error: true
|
||||
|
||||
- action: start_weed_master
|
||||
node: m02
|
||||
port: "9433"
|
||||
dir: /tmp/sw-perf-master
|
||||
extra_args: "-ip=10.0.0.3"
|
||||
save_as: master_pid
|
||||
|
||||
- action: sleep
|
||||
duration: 3s
|
||||
|
||||
- action: start_weed_volume
|
||||
node: m01
|
||||
port: "18480"
|
||||
master: "10.0.0.3:9433"
|
||||
dir: /tmp/sw-perf-vs2
|
||||
extra_args: "-block.dir=/tmp/sw-perf-vs2/blocks -block.listen=:3295 -ip=10.0.0.1"
|
||||
save_as: vs_pid
|
||||
|
||||
- action: sleep
|
||||
duration: 3s
|
||||
|
||||
- action: wait_cluster_ready
|
||||
node: m02
|
||||
master_url: "{{ master_url }}"
|
||||
|
||||
- action: wait_block_servers
|
||||
count: "1"
|
||||
|
||||
- name: create-volume
|
||||
actions:
|
||||
- action: create_block_volume
|
||||
name: "{{ volume_name }}"
|
||||
size_bytes: "{{ vol_size }}"
|
||||
replica_factor: "1"
|
||||
|
||||
- name: benchmark
|
||||
actions:
|
||||
- action: sleep
|
||||
duration: 5s
|
||||
|
||||
- action: iscsi_login_direct
|
||||
node: m01
|
||||
host: "10.0.0.1"
|
||||
port: "3295"
|
||||
iqn: "iqn.2024-01.com.seaweedfs:vol.{{ volume_name }}"
|
||||
save_as: device
|
||||
|
||||
# Warmup
|
||||
- action: fio_json
|
||||
node: m01
|
||||
device: "{{ device }}"
|
||||
rw: randwrite
|
||||
bs: 4k
|
||||
iodepth: "16"
|
||||
runtime: "5"
|
||||
time_based: "true"
|
||||
name: warmup
|
||||
|
||||
# 4K random write qd=16
|
||||
- action: fio_json
|
||||
node: m01
|
||||
device: "{{ device }}"
|
||||
rw: randwrite
|
||||
bs: 4k
|
||||
iodepth: "16"
|
||||
runtime: "10"
|
||||
time_based: "true"
|
||||
name: randwrite-4k-qd16
|
||||
save_as: fio_4k
|
||||
|
||||
- action: fio_parse
|
||||
json_var: fio_4k
|
||||
metric: iops
|
||||
save_as: iops_4k
|
||||
|
||||
- action: print
|
||||
msg: "4K randwrite qd=16 IOPS: {{ iops_4k }}"
|
||||
|
||||
# 4K random write qd=1 (latency test)
|
||||
- action: fio_json
|
||||
node: m01
|
||||
device: "{{ device }}"
|
||||
rw: randwrite
|
||||
bs: 4k
|
||||
iodepth: "1"
|
||||
runtime: "10"
|
||||
time_based: "true"
|
||||
name: randwrite-4k-qd1
|
||||
save_as: fio_qd1
|
||||
|
||||
- action: fio_parse
|
||||
json_var: fio_qd1
|
||||
metric: iops
|
||||
save_as: iops_qd1
|
||||
|
||||
- action: print
|
||||
msg: "4K randwrite qd=1 IOPS: {{ iops_qd1 }}"
|
||||
|
||||
- action: iscsi_cleanup
|
||||
node: m01
|
||||
ignore_error: true
|
||||
|
||||
- name: cleanup
|
||||
always: true
|
||||
actions:
|
||||
- action: iscsi_cleanup
|
||||
node: m01
|
||||
ignore_error: true
|
||||
- action: stop_weed
|
||||
node: m01
|
||||
pid: "{{ vs_pid }}"
|
||||
ignore_error: true
|
||||
- action: stop_weed
|
||||
node: m02
|
||||
pid: "{{ master_pid }}"
|
||||
ignore_error: true
|
||||
@@ -0,0 +1,245 @@
|
||||
name: v2-fast-rejoin-catchup
|
||||
timeout: 5m
|
||||
|
||||
# Test: Kill replica briefly, restart immediately.
|
||||
# Keep the divergence small enough that the primary should still retain
|
||||
# the missing WAL and choose catch-up instead of a full rebuild.
|
||||
|
||||
env:
|
||||
master_url: "http://10.0.0.3:9433"
|
||||
volume_name: v2-catchup
|
||||
vol_size: "1073741824"
|
||||
|
||||
topology:
|
||||
nodes:
|
||||
m01:
|
||||
host: 192.168.1.181
|
||||
alt_ips: ["10.0.0.1"]
|
||||
user: testdev
|
||||
key: "/opt/work/testdev_key"
|
||||
m02:
|
||||
host: 192.168.1.184
|
||||
alt_ips: ["10.0.0.3"]
|
||||
user: testdev
|
||||
key: "/opt/work/testdev_key"
|
||||
|
||||
phases:
|
||||
- name: cluster-start
|
||||
actions:
|
||||
- action: exec
|
||||
node: m02
|
||||
cmd: "fuser -k 9433/tcp 18480/tcp 2>/dev/null; sleep 1; rm -rf /tmp/sw-cu-master /tmp/sw-cu-vs1 && mkdir -p /tmp/sw-cu-master /tmp/sw-cu-vs1/blocks"
|
||||
root: "true"
|
||||
ignore_error: true
|
||||
- action: exec
|
||||
node: m01
|
||||
cmd: "fuser -k 18480/tcp 2>/dev/null; sleep 1; rm -rf /tmp/sw-cu-vs2 && mkdir -p /tmp/sw-cu-vs2/blocks"
|
||||
root: "true"
|
||||
ignore_error: true
|
||||
|
||||
- action: start_weed_master
|
||||
node: m02
|
||||
port: "9433"
|
||||
dir: /tmp/sw-cu-master
|
||||
extra_args: "-ip=10.0.0.3"
|
||||
save_as: master_pid
|
||||
|
||||
- action: sleep
|
||||
duration: 3s
|
||||
|
||||
- action: start_weed_volume
|
||||
node: m02
|
||||
port: "18480"
|
||||
master: "10.0.0.3:9433"
|
||||
dir: /tmp/sw-cu-vs1
|
||||
extra_args: "-block.dir=/tmp/sw-cu-vs1/blocks -block.listen=:3295 -ip=10.0.0.3"
|
||||
save_as: vs1_pid
|
||||
|
||||
- action: start_weed_volume
|
||||
node: m01
|
||||
port: "18480"
|
||||
master: "10.0.0.3:9433"
|
||||
dir: /tmp/sw-cu-vs2
|
||||
extra_args: "-block.dir=/tmp/sw-cu-vs2/blocks -block.listen=:3295 -ip=10.0.0.1"
|
||||
save_as: vs2_pid
|
||||
|
||||
- action: sleep
|
||||
duration: 3s
|
||||
|
||||
- action: wait_cluster_ready
|
||||
node: m02
|
||||
master_url: "{{ master_url }}"
|
||||
|
||||
- action: wait_block_servers
|
||||
count: "2"
|
||||
|
||||
- name: create-and-bootstrap
|
||||
actions:
|
||||
- action: create_block_volume
|
||||
name: "{{ volume_name }}"
|
||||
size_bytes: "{{ vol_size }}"
|
||||
replica_factor: "2"
|
||||
durability_mode: "sync_all"
|
||||
|
||||
- action: discover_primary
|
||||
name: "{{ volume_name }}"
|
||||
save_as: pri
|
||||
|
||||
- action: print
|
||||
msg: "Primary={{ pri }} ({{ pri_server }}), replica={{ pri_replica_node }}"
|
||||
|
||||
- action: sleep
|
||||
duration: 10s
|
||||
|
||||
- action: lookup_block_volume
|
||||
name: "{{ volume_name }}"
|
||||
save_as: vol
|
||||
|
||||
- action: iscsi_login_direct
|
||||
node: m01
|
||||
host: "{{ vol_iscsi_host }}"
|
||||
port: "{{ vol_iscsi_port }}"
|
||||
iqn: "{{ vol_iqn }}"
|
||||
save_as: device
|
||||
|
||||
# Bootstrap + small write only (no fio burst)
|
||||
- action: dd_write
|
||||
node: m01
|
||||
device: "{{ device }}"
|
||||
bs: 4k
|
||||
count: "1"
|
||||
sync_mode: fsync
|
||||
|
||||
- action: wait_volume_healthy
|
||||
name: "{{ volume_name }}"
|
||||
timeout: 60s
|
||||
|
||||
# Write checkpoint at known offset
|
||||
- action: dd_write
|
||||
node: m01
|
||||
device: "{{ device }}"
|
||||
bs: 1M
|
||||
count: "2"
|
||||
seek: "16"
|
||||
sync_mode: fsync
|
||||
save_as: checkpoint_md5
|
||||
|
||||
- action: print
|
||||
msg: "Checkpoint written: md5={{ checkpoint_md5 }}"
|
||||
|
||||
- action: iscsi_cleanup
|
||||
node: m01
|
||||
ignore_error: true
|
||||
|
||||
# Kill replica (m02) briefly, restart immediately
|
||||
- name: kill-replica
|
||||
actions:
|
||||
- action: print
|
||||
msg: "=== Killing replica {{ pri_replica_node }} briefly ==="
|
||||
|
||||
- action: stop_weed
|
||||
node: m02
|
||||
pid: "{{ vs1_pid }}"
|
||||
ignore_error: true
|
||||
|
||||
- action: print
|
||||
msg: "Replica killed. Restarting immediately (no lease wait)..."
|
||||
|
||||
- action: sleep
|
||||
duration: 3s
|
||||
|
||||
- name: restart-replica
|
||||
actions:
|
||||
- action: start_weed_volume
|
||||
node: m02
|
||||
port: "18480"
|
||||
master: "10.0.0.3:9433"
|
||||
dir: /tmp/sw-cu-vs1
|
||||
extra_args: "-block.dir=/tmp/sw-cu-vs1/blocks -block.listen=:3295 -ip=10.0.0.3"
|
||||
save_as: vs1_pid_new
|
||||
|
||||
- action: sleep
|
||||
duration: 5s
|
||||
|
||||
- action: wait_volume_healthy
|
||||
name: "{{ volume_name }}"
|
||||
timeout: 120s
|
||||
|
||||
- action: grep_log
|
||||
node: m01
|
||||
path: "/tmp/sw-cu-vs*/volume.log"
|
||||
pattern: "catch-up complete replica="
|
||||
save_as: catchup_count
|
||||
|
||||
- action: assert_greater
|
||||
actual: "{{ catchup_count }}"
|
||||
threshold: "0"
|
||||
|
||||
- action: grep_log
|
||||
node: m01
|
||||
path: "/tmp/sw-cu-vs*/volume.log"
|
||||
pattern: "remote rebuild: sent start_rebuild"
|
||||
save_as: rebuild_start_count
|
||||
|
||||
- action: assert_equal
|
||||
actual: "{{ rebuild_start_count }}"
|
||||
expected: "0"
|
||||
|
||||
- action: print
|
||||
msg: "Volume healthy after replica rejoin via catch-up (catchup_count={{ catchup_count }}, rebuild_start_count={{ rebuild_start_count }})"
|
||||
|
||||
# Verify data on primary (should be unchanged)
|
||||
- name: verify-data
|
||||
actions:
|
||||
- action: lookup_block_volume
|
||||
name: "{{ volume_name }}"
|
||||
save_as: vol2
|
||||
|
||||
- action: iscsi_login_direct
|
||||
node: m01
|
||||
host: "{{ vol2_iscsi_host }}"
|
||||
port: "{{ vol2_iscsi_port }}"
|
||||
iqn: "{{ vol2_iqn }}"
|
||||
save_as: device2
|
||||
|
||||
- action: dd_read_md5
|
||||
node: m01
|
||||
device: "{{ device2 }}"
|
||||
bs: 1M
|
||||
count: "2"
|
||||
skip: "16"
|
||||
save_as: verify_md5
|
||||
|
||||
- action: assert_equal
|
||||
actual: "{{ verify_md5 }}"
|
||||
expected: "{{ checkpoint_md5 }}"
|
||||
|
||||
- action: print
|
||||
msg: "DATA VERIFIED after replica rejoin: md5={{ verify_md5 }}"
|
||||
|
||||
- action: iscsi_cleanup
|
||||
node: m01
|
||||
ignore_error: true
|
||||
|
||||
- name: cleanup
|
||||
always: true
|
||||
actions:
|
||||
- action: iscsi_cleanup
|
||||
node: m01
|
||||
ignore_error: true
|
||||
- action: stop_weed
|
||||
node: m01
|
||||
pid: "{{ vs2_pid }}"
|
||||
ignore_error: true
|
||||
- action: stop_weed
|
||||
node: m02
|
||||
pid: "{{ vs1_pid }}"
|
||||
ignore_error: true
|
||||
- action: stop_weed
|
||||
node: m02
|
||||
pid: "{{ vs1_pid_new }}"
|
||||
ignore_error: true
|
||||
- action: stop_weed
|
||||
node: m02
|
||||
pid: "{{ master_pid }}"
|
||||
ignore_error: true
|
||||
@@ -0,0 +1,301 @@
|
||||
name: v2-rebuild-failure-retry
|
||||
timeout: 8m
|
||||
|
||||
# Test: Rebuild fails mid-way (kill replica during rebuild).
|
||||
# The system should detect the failure, stabilize, and succeed
|
||||
# on retry when the replica restarts again.
|
||||
#
|
||||
# Flow:
|
||||
# 1. Create + write + failover (same as rebuild-rejoin)
|
||||
# 2. Restart old primary → rebuild starts
|
||||
# 3. Kill replica mid-rebuild (SIGKILL during base transfer)
|
||||
# 4. Restart replica again → second rebuild attempt
|
||||
# 5. Verify data integrity
|
||||
|
||||
env:
|
||||
master_url: "http://10.0.0.3:9433"
|
||||
volume_name: v2-rb-retry
|
||||
vol_size: "1073741824"
|
||||
|
||||
topology:
|
||||
nodes:
|
||||
m01:
|
||||
host: 192.168.1.181
|
||||
alt_ips: ["10.0.0.1"]
|
||||
user: testdev
|
||||
key: "/opt/work/testdev_key"
|
||||
m02:
|
||||
host: 192.168.1.184
|
||||
alt_ips: ["10.0.0.3"]
|
||||
user: testdev
|
||||
key: "/opt/work/testdev_key"
|
||||
|
||||
phases:
|
||||
- name: cluster-start
|
||||
actions:
|
||||
- action: exec
|
||||
node: m02
|
||||
cmd: "fuser -k 9433/tcp 18480/tcp 2>/dev/null; sleep 1; rm -rf /tmp/sw-rr-master /tmp/sw-rr-vs1 && mkdir -p /tmp/sw-rr-master /tmp/sw-rr-vs1/blocks"
|
||||
root: "true"
|
||||
ignore_error: true
|
||||
- action: exec
|
||||
node: m01
|
||||
cmd: "fuser -k 18480/tcp 2>/dev/null; sleep 1; rm -rf /tmp/sw-rr-vs2 && mkdir -p /tmp/sw-rr-vs2/blocks"
|
||||
root: "true"
|
||||
ignore_error: true
|
||||
|
||||
- action: start_weed_master
|
||||
node: m02
|
||||
port: "9433"
|
||||
dir: /tmp/sw-rr-master
|
||||
extra_args: "-ip=10.0.0.3"
|
||||
save_as: master_pid
|
||||
|
||||
- action: sleep
|
||||
duration: 3s
|
||||
|
||||
- action: start_weed_volume
|
||||
node: m02
|
||||
port: "18480"
|
||||
master: "10.0.0.3:9433"
|
||||
dir: /tmp/sw-rr-vs1
|
||||
extra_args: "-block.dir=/tmp/sw-rr-vs1/blocks -block.listen=:3295 -ip=10.0.0.3"
|
||||
save_as: vs1_pid
|
||||
|
||||
- action: start_weed_volume
|
||||
node: m01
|
||||
port: "18480"
|
||||
master: "10.0.0.3:9433"
|
||||
dir: /tmp/sw-rr-vs2
|
||||
extra_args: "-block.dir=/tmp/sw-rr-vs2/blocks -block.listen=:3295 -ip=10.0.0.1"
|
||||
save_as: vs2_pid
|
||||
|
||||
- action: sleep
|
||||
duration: 3s
|
||||
|
||||
- action: wait_cluster_ready
|
||||
node: m02
|
||||
master_url: "{{ master_url }}"
|
||||
|
||||
- action: wait_block_servers
|
||||
count: "2"
|
||||
|
||||
- name: create-and-write
|
||||
actions:
|
||||
- action: create_block_volume
|
||||
name: "{{ volume_name }}"
|
||||
size_bytes: "{{ vol_size }}"
|
||||
replica_factor: "2"
|
||||
durability_mode: "sync_all"
|
||||
|
||||
- action: discover_primary
|
||||
name: "{{ volume_name }}"
|
||||
save_as: pri1
|
||||
|
||||
- action: sleep
|
||||
duration: 10s
|
||||
|
||||
- action: lookup_block_volume
|
||||
name: "{{ volume_name }}"
|
||||
save_as: vol
|
||||
|
||||
- action: iscsi_login_direct
|
||||
node: m01
|
||||
host: "{{ vol_iscsi_host }}"
|
||||
port: "{{ vol_iscsi_port }}"
|
||||
iqn: "{{ vol_iqn }}"
|
||||
save_as: device
|
||||
|
||||
- action: dd_write
|
||||
node: m01
|
||||
device: "{{ device }}"
|
||||
bs: 4k
|
||||
count: "1"
|
||||
sync_mode: fsync
|
||||
|
||||
- action: wait_volume_healthy
|
||||
name: "{{ volume_name }}"
|
||||
timeout: 60s
|
||||
|
||||
- action: fio_json
|
||||
node: m01
|
||||
device: "{{ device }}"
|
||||
rw: randwrite
|
||||
bs: 4k
|
||||
iodepth: "16"
|
||||
runtime: "10"
|
||||
time_based: "true"
|
||||
name: initial-write
|
||||
|
||||
- action: dd_write
|
||||
node: m01
|
||||
device: "{{ device }}"
|
||||
bs: 1M
|
||||
count: "4"
|
||||
seek: "16"
|
||||
sync_mode: fsync
|
||||
save_as: checkpoint_md5
|
||||
|
||||
- action: print
|
||||
msg: "Checkpoint written: md5={{ checkpoint_md5 }}"
|
||||
|
||||
- action: iscsi_cleanup
|
||||
node: m01
|
||||
ignore_error: true
|
||||
|
||||
- name: kill-primary-and-failover
|
||||
actions:
|
||||
- action: stop_weed
|
||||
node: m01
|
||||
pid: "{{ vs2_pid }}"
|
||||
ignore_error: true
|
||||
|
||||
- action: sleep
|
||||
duration: 45s
|
||||
|
||||
- action: wait_block_primary
|
||||
name: "{{ volume_name }}"
|
||||
not: "{{ pri1_server }}"
|
||||
timeout: 60s
|
||||
|
||||
- action: print
|
||||
msg: "Failover complete"
|
||||
|
||||
# First restart: triggers rebuild. Kill the rejoining replica
|
||||
# after 1 second (during base transfer) to simulate mid-rebuild failure.
|
||||
- name: restart-and-kill-mid-rebuild
|
||||
actions:
|
||||
- action: print
|
||||
msg: "=== First restart: will kill mid-rebuild ==="
|
||||
|
||||
- action: start_weed_volume
|
||||
node: m01
|
||||
port: "18480"
|
||||
master: "10.0.0.3:9433"
|
||||
dir: /tmp/sw-rr-vs2
|
||||
extra_args: "-block.dir=/tmp/sw-rr-vs2/blocks -block.listen=:3295 -ip=10.0.0.1"
|
||||
save_as: vs2_pid_attempt1
|
||||
|
||||
# Wait just long enough for rebuild to start, then kill
|
||||
- action: sleep
|
||||
duration: 15s
|
||||
|
||||
- action: print
|
||||
msg: "=== Killing replica mid-rebuild ==="
|
||||
|
||||
- action: stop_weed
|
||||
node: m01
|
||||
pid: "{{ vs2_pid_attempt1 }}"
|
||||
ignore_error: true
|
||||
|
||||
- action: sleep
|
||||
duration: 10s
|
||||
|
||||
- action: print
|
||||
msg: "Replica killed during rebuild. Primary should detect failure and stabilize."
|
||||
|
||||
# Second restart: should trigger a fresh rebuild and succeed.
|
||||
- name: second-restart-rebuild
|
||||
actions:
|
||||
- action: print
|
||||
msg: "=== Second restart: fresh rebuild should succeed ==="
|
||||
|
||||
- action: start_weed_volume
|
||||
node: m01
|
||||
port: "18480"
|
||||
master: "10.0.0.3:9433"
|
||||
dir: /tmp/sw-rr-vs2
|
||||
extra_args: "-block.dir=/tmp/sw-rr-vs2/blocks -block.listen=:3295 -ip=10.0.0.1"
|
||||
save_as: vs2_pid_attempt2
|
||||
|
||||
- action: sleep
|
||||
duration: 5s
|
||||
|
||||
# Wait for rebuild/keepup to complete. Don't require publish_healthy
|
||||
# (barrier gate needs a write, which is validated in the next phase).
|
||||
- action: sleep
|
||||
duration: 30s
|
||||
|
||||
- action: grep_log
|
||||
node: m02
|
||||
path: "/tmp/sw-rr-vs1/volume.log"
|
||||
pattern: "remote rebuild: sent start_rebuild"
|
||||
save_as: rebuild_attempts
|
||||
|
||||
# Note: rebuild completes in ~2s over RoCE, so the 15s kill window
|
||||
# often misses the active transfer. The data verification in the next
|
||||
# phase is the real correctness assertion.
|
||||
- action: print
|
||||
msg: "Rebuild attempts on primary: {{ rebuild_attempts }}"
|
||||
|
||||
- action: print
|
||||
msg: "Second restart complete — proceeding to data verification (rebuild_attempts={{ rebuild_attempts }})"
|
||||
|
||||
# Verify data on the rebuilt replica
|
||||
- name: verify-rebuilt-data
|
||||
actions:
|
||||
- action: stop_weed
|
||||
node: m02
|
||||
pid: "{{ vs1_pid }}"
|
||||
ignore_error: true
|
||||
|
||||
- action: sleep
|
||||
duration: 45s
|
||||
|
||||
- action: wait_block_primary
|
||||
name: "{{ volume_name }}"
|
||||
not: "10.0.0.3:18480"
|
||||
timeout: 60s
|
||||
|
||||
- action: iscsi_login_direct
|
||||
node: m01
|
||||
host: "10.0.0.1"
|
||||
port: "3295"
|
||||
iqn: "iqn.2024-01.com.seaweedfs:vol.{{ volume_name }}"
|
||||
save_as: device2
|
||||
|
||||
- action: dd_read_md5
|
||||
node: m01
|
||||
device: "{{ device2 }}"
|
||||
bs: 1M
|
||||
count: "4"
|
||||
skip: "16"
|
||||
save_as: rebuilt_md5
|
||||
|
||||
- action: assert_equal
|
||||
actual: "{{ rebuilt_md5 }}"
|
||||
expected: "{{ checkpoint_md5 }}"
|
||||
|
||||
- action: print
|
||||
msg: "REBUILD-RETRY VERIFIED: md5={{ rebuilt_md5 }}"
|
||||
|
||||
- action: iscsi_cleanup
|
||||
node: m01
|
||||
ignore_error: true
|
||||
|
||||
- name: cleanup
|
||||
always: true
|
||||
actions:
|
||||
- action: iscsi_cleanup
|
||||
node: m01
|
||||
ignore_error: true
|
||||
- action: stop_weed
|
||||
node: m01
|
||||
pid: "{{ vs2_pid }}"
|
||||
ignore_error: true
|
||||
- action: stop_weed
|
||||
node: m01
|
||||
pid: "{{ vs2_pid_attempt1 }}"
|
||||
ignore_error: true
|
||||
- action: stop_weed
|
||||
node: m01
|
||||
pid: "{{ vs2_pid_attempt2 }}"
|
||||
ignore_error: true
|
||||
- action: stop_weed
|
||||
node: m02
|
||||
pid: "{{ vs1_pid }}"
|
||||
ignore_error: true
|
||||
- action: stop_weed
|
||||
node: m02
|
||||
pid: "{{ master_pid }}"
|
||||
ignore_error: true
|
||||
@@ -0,0 +1,355 @@
|
||||
name: v2-rebuild-rejoin
|
||||
timeout: 12m
|
||||
|
||||
# I-R8 + I-V4: Full failover → rejoin → rebuild cycle.
|
||||
#
|
||||
# Tests the complete V2 lifecycle:
|
||||
# 1. Create RF=2 sync_all volume, write data, verify
|
||||
# 2. Kill primary (SIGKILL)
|
||||
# 3. Wait for master auto-promotion (lease expiry)
|
||||
# 4. Verify failover: new primary serves reads (data continuity)
|
||||
# 5. Write more data to new primary (post-failover checkpoint)
|
||||
# 6. Restart old primary — it rejoins as replica
|
||||
# 7. Wait for rebuild to complete (volume healthy again)
|
||||
# 8. Kill new primary, verify data on rebuilt replica
|
||||
#
|
||||
# This exercises: WAL retention, failover promotion, registry
|
||||
# stale-cleanup grace, rebuild session, and data convergence.
|
||||
#
|
||||
# Topology: m01=client/VS2, m02=master/VS1
|
||||
# Primary lands on m01 (VS2), replica on m02 (VS1).
|
||||
|
||||
env:
|
||||
master_url: "http://10.0.0.3:9433"
|
||||
volume_name: v2-rebuild
|
||||
vol_size: "1073741824"
|
||||
|
||||
topology:
|
||||
nodes:
|
||||
m01:
|
||||
host: 192.168.1.181
|
||||
alt_ips: ["10.0.0.1"]
|
||||
user: testdev
|
||||
key: "/opt/work/testdev_key"
|
||||
m02:
|
||||
host: 192.168.1.184
|
||||
alt_ips: ["10.0.0.3"]
|
||||
user: testdev
|
||||
key: "/opt/work/testdev_key"
|
||||
|
||||
phases:
|
||||
- name: cluster-start
|
||||
actions:
|
||||
- action: exec
|
||||
node: m02
|
||||
cmd: "fuser -k 9433/tcp 18480/tcp 2>/dev/null; sleep 1; rm -rf /tmp/sw-rb-master /tmp/sw-rb-vs1 && mkdir -p /tmp/sw-rb-master /tmp/sw-rb-vs1/blocks"
|
||||
root: "true"
|
||||
ignore_error: true
|
||||
- action: exec
|
||||
node: m01
|
||||
cmd: "fuser -k 18480/tcp 2>/dev/null; sleep 1; rm -rf /tmp/sw-rb-vs2 && mkdir -p /tmp/sw-rb-vs2/blocks"
|
||||
root: "true"
|
||||
ignore_error: true
|
||||
|
||||
- action: start_weed_master
|
||||
node: m02
|
||||
port: "9433"
|
||||
dir: /tmp/sw-rb-master
|
||||
extra_args: "-ip=10.0.0.3"
|
||||
save_as: master_pid
|
||||
|
||||
- action: sleep
|
||||
duration: 3s
|
||||
|
||||
- action: start_weed_volume
|
||||
node: m02
|
||||
port: "18480"
|
||||
master: "10.0.0.3:9433"
|
||||
dir: /tmp/sw-rb-vs1
|
||||
extra_args: "-block.dir=/tmp/sw-rb-vs1/blocks -block.listen=:3295 -ip=10.0.0.3"
|
||||
save_as: vs1_pid
|
||||
|
||||
- action: start_weed_volume
|
||||
node: m01
|
||||
port: "18480"
|
||||
master: "10.0.0.3:9433"
|
||||
dir: /tmp/sw-rb-vs2
|
||||
extra_args: "-block.dir=/tmp/sw-rb-vs2/blocks -block.listen=:3295 -ip=10.0.0.1"
|
||||
save_as: vs2_pid
|
||||
|
||||
- action: sleep
|
||||
duration: 3s
|
||||
|
||||
- action: wait_cluster_ready
|
||||
node: m02
|
||||
master_url: "{{ master_url }}"
|
||||
|
||||
- action: wait_block_servers
|
||||
count: "2"
|
||||
|
||||
- name: create-volume
|
||||
actions:
|
||||
- action: create_block_volume
|
||||
name: "{{ volume_name }}"
|
||||
size_bytes: "{{ vol_size }}"
|
||||
replica_factor: "2"
|
||||
durability_mode: "sync_all"
|
||||
|
||||
# Phase 1: Bootstrap + initial workload.
|
||||
- name: bootstrap-and-write
|
||||
actions:
|
||||
- action: discover_primary
|
||||
name: "{{ volume_name }}"
|
||||
save_as: pri1
|
||||
|
||||
- action: print
|
||||
msg: "Initial: primary={{ pri1 }} ({{ pri1_server }}), replica={{ pri1_replica_node }}"
|
||||
|
||||
- action: sleep
|
||||
duration: 10s
|
||||
|
||||
- action: lookup_block_volume
|
||||
name: "{{ volume_name }}"
|
||||
save_as: vol
|
||||
|
||||
- action: iscsi_login_direct
|
||||
node: m01
|
||||
host: "{{ vol_iscsi_host }}"
|
||||
port: "{{ vol_iscsi_port }}"
|
||||
iqn: "{{ vol_iqn }}"
|
||||
save_as: device
|
||||
|
||||
# Bootstrap fence: first write + fsync.
|
||||
- action: dd_write
|
||||
node: m01
|
||||
device: "{{ device }}"
|
||||
bs: 4k
|
||||
count: "1"
|
||||
sync_mode: fsync
|
||||
|
||||
- action: wait_volume_healthy
|
||||
name: "{{ volume_name }}"
|
||||
timeout: 60s
|
||||
|
||||
# Main workload: fio + dd checkpoint at known offset.
|
||||
- action: fio_json
|
||||
node: m01
|
||||
device: "{{ device }}"
|
||||
rw: randwrite
|
||||
bs: 4k
|
||||
iodepth: "16"
|
||||
runtime: "10"
|
||||
time_based: "true"
|
||||
name: initial-write
|
||||
|
||||
- action: dd_write
|
||||
node: m01
|
||||
device: "{{ device }}"
|
||||
bs: 1M
|
||||
count: "4"
|
||||
seek: "16"
|
||||
sync_mode: fsync
|
||||
save_as: checkpoint1_md5
|
||||
|
||||
- action: dd_read_md5
|
||||
node: m01
|
||||
device: "{{ device }}"
|
||||
bs: 1M
|
||||
count: "4"
|
||||
skip: "16"
|
||||
save_as: checkpoint1_verify
|
||||
|
||||
- action: assert_equal
|
||||
actual: "{{ checkpoint1_verify }}"
|
||||
expected: "{{ checkpoint1_md5 }}"
|
||||
|
||||
- action: print
|
||||
msg: "Checkpoint 1 written: md5={{ checkpoint1_md5 }}"
|
||||
|
||||
- action: iscsi_cleanup
|
||||
node: m01
|
||||
ignore_error: true
|
||||
|
||||
# Phase 2: Kill primary, wait for failover.
|
||||
- name: kill-primary
|
||||
actions:
|
||||
- action: print
|
||||
msg: "=== Killing primary {{ pri1 }} ({{ pri1_server }}) ==="
|
||||
|
||||
- action: stop_weed
|
||||
node: m01
|
||||
pid: "{{ vs2_pid }}"
|
||||
ignore_error: true
|
||||
|
||||
- action: print
|
||||
msg: "Primary killed. Waiting 45s for lease expiry..."
|
||||
|
||||
- action: sleep
|
||||
duration: 45s
|
||||
|
||||
# Phase 3: Verify failover succeeded.
|
||||
- name: verify-failover
|
||||
actions:
|
||||
- action: wait_block_primary
|
||||
name: "{{ volume_name }}"
|
||||
not: "{{ pri1_server }}"
|
||||
timeout: 60s
|
||||
save_as: after
|
||||
|
||||
- action: discover_primary
|
||||
name: "{{ volume_name }}"
|
||||
save_as: pri2
|
||||
|
||||
- action: print
|
||||
msg: "Failover complete: {{ pri1_server }} → {{ pri2_server }}"
|
||||
|
||||
# Verify data continuity on new primary.
|
||||
- action: iscsi_login_direct
|
||||
node: m01
|
||||
host: "10.0.0.3"
|
||||
port: "3295"
|
||||
iqn: "iqn.2024-01.com.seaweedfs:vol.{{ volume_name }}"
|
||||
save_as: device2
|
||||
|
||||
- action: dd_read_md5
|
||||
node: m01
|
||||
device: "{{ device2 }}"
|
||||
bs: 1M
|
||||
count: "4"
|
||||
skip: "16"
|
||||
save_as: failover_read
|
||||
|
||||
- action: assert_equal
|
||||
actual: "{{ failover_read }}"
|
||||
expected: "{{ checkpoint1_md5 }}"
|
||||
|
||||
- action: print
|
||||
msg: "Data continuity verified after failover: md5={{ failover_read }}"
|
||||
|
||||
# Write checkpoint 2 on new primary (post-failover data).
|
||||
- action: dd_write
|
||||
node: m01
|
||||
device: "{{ device2 }}"
|
||||
bs: 1M
|
||||
count: "4"
|
||||
seek: "32"
|
||||
sync_mode: fsync
|
||||
save_as: checkpoint2_md5
|
||||
|
||||
- action: print
|
||||
msg: "Checkpoint 2 written on new primary: md5={{ checkpoint2_md5 }}"
|
||||
|
||||
- action: iscsi_cleanup
|
||||
node: m01
|
||||
ignore_error: true
|
||||
|
||||
# Phase 4: Restart old primary — it rejoins as replica and rebuilds.
|
||||
- name: restart-old-primary
|
||||
actions:
|
||||
- action: print
|
||||
msg: "=== Restarting old primary on m01 as replica ==="
|
||||
|
||||
- action: start_weed_volume
|
||||
node: m01
|
||||
port: "18480"
|
||||
master: "10.0.0.3:9433"
|
||||
dir: /tmp/sw-rb-vs2
|
||||
extra_args: "-block.dir=/tmp/sw-rb-vs2/blocks -block.listen=:3295 -ip=10.0.0.1"
|
||||
save_as: vs2_pid_new
|
||||
|
||||
- action: sleep
|
||||
duration: 5s
|
||||
|
||||
# Wait for the volume to become healthy (rebuild complete).
|
||||
- action: wait_volume_healthy
|
||||
name: "{{ volume_name }}"
|
||||
timeout: 120s
|
||||
|
||||
- action: print
|
||||
msg: "Rebuild complete — volume healthy with 2 replicas"
|
||||
|
||||
# Phase 5: Verify data on rebuilt replica.
|
||||
# Kill the current primary (m02), connect to the rebuilt replica (m01),
|
||||
# and verify both checkpoints are present.
|
||||
- name: verify-rebuilt-data
|
||||
actions:
|
||||
- action: print
|
||||
msg: "=== Verifying rebuilt replica data ==="
|
||||
|
||||
# Kill current primary (m02) so we can read from the rebuilt replica.
|
||||
- action: stop_weed
|
||||
node: m02
|
||||
pid: "{{ vs1_pid }}"
|
||||
ignore_error: true
|
||||
|
||||
- action: sleep
|
||||
duration: 45s
|
||||
|
||||
- action: wait_block_primary
|
||||
name: "{{ volume_name }}"
|
||||
not: "10.0.0.3:18480"
|
||||
timeout: 60s
|
||||
|
||||
# Connect to rebuilt replica (now promoted to primary on m01).
|
||||
- action: iscsi_login_direct
|
||||
node: m01
|
||||
host: "10.0.0.1"
|
||||
port: "3295"
|
||||
iqn: "iqn.2024-01.com.seaweedfs:vol.{{ volume_name }}"
|
||||
save_as: device3
|
||||
|
||||
# Verify checkpoint 1 (written before first failover).
|
||||
- action: dd_read_md5
|
||||
node: m01
|
||||
device: "{{ device3 }}"
|
||||
bs: 1M
|
||||
count: "4"
|
||||
skip: "16"
|
||||
save_as: rebuilt_cp1
|
||||
|
||||
- action: assert_equal
|
||||
actual: "{{ rebuilt_cp1 }}"
|
||||
expected: "{{ checkpoint1_md5 }}"
|
||||
|
||||
# Verify checkpoint 2 (written after first failover, before rebuild).
|
||||
- action: dd_read_md5
|
||||
node: m01
|
||||
device: "{{ device3 }}"
|
||||
bs: 1M
|
||||
count: "4"
|
||||
skip: "32"
|
||||
save_as: rebuilt_cp2
|
||||
|
||||
- action: assert_equal
|
||||
actual: "{{ rebuilt_cp2 }}"
|
||||
expected: "{{ checkpoint2_md5 }}"
|
||||
|
||||
- action: print
|
||||
msg: "REBUILD VERIFIED: cp1={{ rebuilt_cp1 }} cp2={{ rebuilt_cp2 }}"
|
||||
|
||||
- action: iscsi_cleanup
|
||||
node: m01
|
||||
ignore_error: true
|
||||
|
||||
- name: cleanup
|
||||
always: true
|
||||
actions:
|
||||
- action: iscsi_cleanup
|
||||
node: m01
|
||||
ignore_error: true
|
||||
- action: stop_weed
|
||||
node: m01
|
||||
pid: "{{ vs2_pid }}"
|
||||
ignore_error: true
|
||||
- action: stop_weed
|
||||
node: m01
|
||||
pid: "{{ vs2_pid_new }}"
|
||||
ignore_error: true
|
||||
- action: stop_weed
|
||||
node: m02
|
||||
pid: "{{ vs1_pid }}"
|
||||
ignore_error: true
|
||||
- action: stop_weed
|
||||
node: m02
|
||||
pid: "{{ master_pid }}"
|
||||
ignore_error: true
|
||||
Reference in New Issue
Block a user