feat: Phase 09 — V2 execution primitives and production closure

Engine execution layer for V2 replication protocol:
- RebuildInstaller: full state handoff (dirty map, WAL, superblock, flusher)
- TruncateToLSN: exact safety predicate (checkpointLSN == truncateLSN),
  ErrTruncationUnsafe escalation to NeedsRebuild
- SyncReceiverProgress: unconditional Store for post-rebuild alignment
- V2StatusSnapshot: CommittedLSN = nextLSN-1 for sync_all

V2 bridge real I/O executors:
- TransferFullBase: TCP streaming + RebuildInstaller + second catch-up
- TransferSnapshot: SHA-256 verified streaming to disk
- TruncateWAL: ErrTruncationUnsafe detection + escalation
- StreamWALEntries: rebuild-mode TCP apply

Engine executor interfaces:
- CatchUpIO.TruncateWAL, RebuildIO.TransferFullBase returns achievedLSN
- CatchUpExecutor truncation-only skip, NeedsRebuild escalation
- RebuildExecutor uses achievedLSN for progress tracking

Design docs reorganized: superseded planning docs removed, protocol
truths and closure map added.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
pingqiu
2026-04-02 16:25:23 -07:00
co-authored by Claude Opus 4.6
parent 643a5a1074
commit c7eb87c587
41 changed files with 6226 additions and 2218 deletions
+1 -1
View File
@@ -30,7 +30,7 @@ We should start with the ownership problem that most clearly separates V2 from V
## Source Of Truth
Design:
- `sw-block/design/v2-first-slice-session-ownership.md`
- `sw-block/docs/archive/design/v2-first-slice-session-ownership.md`
- `sw-block/design/v2-acceptance-criteria.md`
- `sw-block/design/v2-open-questions.md`
+1 -1
View File
@@ -36,7 +36,7 @@ That is the highest-value validation gap to close before trusting V2 too much.
## Source Of Truth
Design:
- `sw-block/design/v2-first-slice-session-ownership.md`
- `sw-block/docs/archive/design/v2-first-slice-session-ownership.md`
- `sw-block/design/v2-acceptance-criteria.md`
- `sw-block/design/v2-open-questions.md`
- `sw-block/design/protocol-development-process.md`
+2 -2
View File
@@ -34,8 +34,8 @@ Start the real V2 engine line under `sw-block/` with:
`Phase 05` is built on:
- `sw-block/design/v2-engine-readiness-review.md`
- `sw-block/design/v2-engine-slicing-plan.md`
- `sw-block/docs/archive/design/v2-engine-readiness-review.md`
- `sw-block/docs/archive/design/v2-engine-slicing-plan.md`
- `sw-block/.private/phase/phase-04.md`
- `sw-block/.private/phase/phase-4.5.md`
+1 -1
View File
@@ -57,7 +57,7 @@ Status:
- delivered
- planning artifact:
- `sw-block/design/phase-07-service-slice-plan.md`
- `sw-block/docs/archive/design/phase-07-service-slice-plan.md`
- implementation slice proposal:
- engine core: `sw-block/engine/replication/`
- bridge adapters: `sw-block/bridge/blockvol/`
+1 -1
View File
@@ -78,7 +78,7 @@ Status:
Reference:
- `sw-block/design/phase-08-engine-skeleton-map.md` is the implementation-side skeleton map for this phase
- `sw-block/docs/archive/design/phase-08-engine-skeleton-map.md` is the implementation-side skeleton map for this phase
- it is subordinate to `sw-block/design/v2-protocol-truths.md` and this `phase-08.md`; use it for module layout, execution order, interim fields, hard gates, and reuse guidance
### P1: Real Control Delivery
@@ -24,3 +24,154 @@ Default scope remains:
3. existing master / volume-server heartbeat path
Future paths or durability modes should not be absorbed casually into this phase.
## Decision 4: Full-base rebuild completion is defined by an achieved boundary, not exact target equality
For the chosen `RF=2 sync_all` backend path, `full_base` rebuild does not require:
1. extent image exactly equal to the engine's frozen `targetLSN`
It does require:
1. the engine plans a frozen minimum target `targetLSN`
2. the backend produces an actual rebuilt boundary `achievedLSN`
3. correctness requires `achievedLSN >= targetLSN`
4. after install, local runtime state and engine-visible completion must align to the same `achievedLSN`
5. the system must not keep engine truth at `targetLSN` while local runtime truth has advanced to `achievedLSN`
Reason:
1. the current full-base path copies a mutable extent image from the live backend
2. this backend does not provide an immutable extent export at an exact requested LSN
3. forcing exact-target extent equality would require a different protocol, not just a tighter implementation
4. rollback to an older target after a newer stable base is installed is much harder than accepting the newer stable boundary
Algorithm guarantees required by this decision:
1. minimum-target guarantee:
- rebuild completion must never leave the replica behind the engine's frozen minimum target
2. single-truth guarantee:
- `checkpoint`
- `nextLSN`
- receiver progress
- flusher checkpoint
- engine-visible rebuild progress/completion
must all converge to the same `achievedLSN`
3. no split-truth guarantee:
- do not allow local runtime state to reflect a newer boundary while engine/accounting still records the older one
4. backend-realism guarantee:
- it is acceptable for the achieved boundary to be newer than the frozen minimum target
- it is not acceptable for the achieved boundary to remain implicit
## Decision 5: P1 full-base execution closure accepted
P1 delivers real full-base execution closure under the Decision 4 contract.
Accepted properties:
1. `TransferFullBase(committedLSN) → (achievedLSN, error)` — achieved boundary surfaced explicitly
2. rebuild server pre-flushes before extent copy — no unflushed-entry hole
3. full state handoff on install — dirty map, WAL, superblock, flusher, receiver progress all aligned
4. second catch-up bounded to target — no unbounded replay
5. engine uses `achievedLSN` for progress recording — no split truth
6. rebuild server fail-closes on pre-copy flush failure
7. stale-higher local/runtime state is reset to the rebuilt achieved boundary, not preserved by monotonic advance
Evidence closure:
1. live-receiver convergence is now covered directly in `P1`
2. `P1` accepted state is final for full-base closure on the chosen path
## Decision 6: P2 snapshot execution closure accepted
`P2` delivers real `snapshot_tail` execution closure on the chosen path.
Accepted properties:
1. `TransferSnapshot(snapshotLSN)` now performs real TCP snapshot transfer
2. snapshot base boundary is exact, not conservative:
- requested `snapshotLSN` must match the transferred base
- newer checkpoints are rejected instead of silently accepted
3. snapshot transfer carries explicit boundary metadata through `SnapshotArtifactManifest.BaseLSN`
4. snapshot install converges local runtime to the exact snapshot boundary before tail replay begins
5. the `snapshot_tail` path now closes through one executor:
- `TransferSnapshot(snapshotLSN)`
- `StreamWALEntries(snapshotLSN, targetLSN)`
6. tail replay remains bounded to `targetLSN`
7. temporary snapshot ownership is cleaned up on both success and failure paths
Evidence closure:
1. component proof now covers real snapshot transfer and exact-boundary install
2. one-chain proof now covers `engine -> RebuildExecutor -> v2bridge -> blockvol -> tail replay -> InSync`
3. boundary-drift rejection is covered directly in `P2`
## Decision 7: P3 truncation execution closure accepted under the narrowed Option A contract
`P3` does not mean "all replica-ahead cases can be corrected by local truncate."
Accepted contract:
1. local truncation is allowed only when the local base boundary exactly matches the kept boundary:
- `checkpointLSN == truncateLSN`
2. if `checkpointLSN > truncateLSN`:
- ahead entries already contaminated extent
- truncation is unsafe
- the path must escalate to rebuild
3. if `checkpointLSN < truncateLSN`:
- part of the kept range may still exist only in WAL
- truncation would discard committed kept data
- the path must escalate to rebuild
4. no path may record truncation completion while extent/base truth is known to be unsafe for local truncate
5. execution-time escalation to `NeedsRebuild` is acceptable for `P3`
Accepted properties:
1. `TruncateWAL(truncateLSN)` now performs real local correction for the truncation-safe case
2. `TruncateToLSN()` pauses the flusher and drains I/O before mutating local runtime truth
3. `blockvol.ErrTruncationUnsafe` is bridged to `engine.ErrTruncationUnsafe`
4. `CatchUpExecutor` escalates unsafe truncation cases to `StateNeedsRebuild`
5. the mixed case `checkpointLSN < truncateLSN < headLSN` is now covered directly in tests
Evidence closure:
1. component proof covers exact local truncation only for the safe case
2. one-chain proof covers both:
- safe truncation to `InSync`
- unsafe truncation escalation to `NeedsRebuild`
3. `P3` accepted state is final for truncation execution closure on the chosen path
## Decision 8: P4 stronger live runtime ownership accepted
`P4` closes the bounded runtime-ownership gap for the chosen `RF=2 sync_all` live volume-server path.
Accepted properties:
1. `ProcessAssignments()` now drives live recovery ownership through:
- assignment conversion
- orchestrator session creation/supersede
- `RecoveryManager` start/cancel/replace/cleanup
2. runtime inputs are sourced from the live path rather than test-only injection:
- live volume path
- live storage adapter / pinner / reader
- rebuild address scoped by volume path
3. replacement is serialized:
- stale owner is cancelled and drained before replacement starts
- no concurrent live owners remain for the same `replicaID`
4. shutdown drains live recovery owners before the block service closes volumes
5. engine policy remains in engine; `P4` does not move policy into the volume-server runtime
Evidence closure:
1. live-path proof now covers:
- `ProcessAssignments -> plan_catchup -> exec_catchup_started -> exec_completed -> in_sync`
2. serialized replacement proof now directly demonstrates:
- old owner alive
- old owner `done` still open before supersede
- `ProcessAssignments(epoch+1)` returns only after old owner `done` closes
3. shutdown proof now covers a live blocked task, not only an already-finished task
Residual note:
1. repeated primary assignment on the same volume still logs a low-severity rebuild-server double-start warning
2. broader control-plane closure remains outside `Phase 09`
File diff suppressed because it is too large Load Diff
+142 -19
View File
@@ -1,7 +1,7 @@
# Phase 09
Date: 2026-03-31
Status: active
Status: complete
Purpose: turn the accepted candidate-safe backend path into a production-grade execution path without reopening accepted V2 recovery semantics
## Why This Phase Exists
@@ -98,30 +98,153 @@ Reject if:
3. the phase quietly expands into product surfaces or unrelated control-plane work
4. the phase has no clear verification mechanism
Status:
- accepted
### P1: Full-Base Execution Closure
Goal:
- make `TransferFullBase` a real production-grade execution path for the chosen `RF=2 sync_all` candidate path
Accepted scope:
1. real TCP full-base transfer
2. explicit local install ownership in `blockvol`
3. second catch-up after extent copy
4. achieved-boundary reporting back to engine
5. local runtime convergence to the achieved boundary
6. fail-closed behavior for transfer/runtime errors
Accepted evidence shape:
1. component proof:
- TCP transfer
- local install
2. one-chain proof:
- `engine plan -> RebuildExecutor -> v2bridge -> blockvol -> InSync`
3. convergence proof:
- `achievedLSN >= targetLSN`
- no split truth between engine and local runtime
4. fail-closed proof:
- connection refused
- epoch mismatch
- no address
- partial transfer
5. runtime proof:
- stale non-empty replica state cleared
- active receiver progress converges
Status:
- accepted
Carry-forward from `P1`:
1. `TransferSnapshot` still not real
2. `TruncateWAL` still not real
3. stronger live runtime ownership still not closed
### P2: Snapshot Execution Closure
Goal:
- make `TransferSnapshot` a real production-grade execution path for the chosen `RF=2 sync_all` candidate path
Accepted scope:
1. real TCP snapshot/base transfer
2. exact snapshot-boundary verification
3. explicit manifest boundary metadata
4. local runtime convergence to the exact snapshot boundary before tail replay
5. single-executor snapshot + tail replay execution chain
6. bounded tail replay to the planned target
Accepted evidence shape:
1. component proof:
- real snapshot image transfer
- exact base-boundary install
2. one-chain proof:
- `engine plan -> RebuildExecutor -> v2bridge -> blockvol -> tail replay -> InSync`
3. exact-boundary proof:
- requested `snapshotLSN` is transferred exactly
- newer checkpoint is rejected rather than silently accepted
4. convergence proof:
- post-install local runtime converges to `snapshotLSN`
- post-replay engine/runtime converge to `targetLSN`
5. cleanup proof:
- temporary snapshot ownership released on success/failure
Status:
- accepted
Carry-forward from `P2`:
1. `TruncateWAL` still not real
2. stronger live runtime ownership still not closed
### P3: Truncation Execution Closure
Goal:
- make `TruncateWAL` a real production-grade execution path for the chosen `RF=2 sync_all` candidate path
Required scope:
1. real truncation execution closure for the truncation-safe replica-ahead case
2. explicit rebuild escalation for replica-ahead cases that are not truncation-safe
3. one-chain proof through the catch-up executor path
4. fail-closed / no-overclaim behavior when local truncation is unsafe
5. no overclaim of broader runtime-ownership closure
Status:
- accepted
Carry-forward from `P3`:
1. truncation-safe vs rebuild-required replica-ahead split still happens at execution time, not planning time
2. stronger live runtime ownership still not closed
### P4: Stronger Live Runtime Ownership
Goal:
- move the accepted execution logic from bounded test/adapter ownership into a stronger live runtime path on the chosen `RF=2 sync_all` volume-server path
Required scope:
1. stronger volume-server/runtime ownership of recovery execution
2. explicit live start / cancel / replace / cleanup semantics
3. real runtime wiring for current execution inputs and addresses
4. one-chain proof on the live runtime path, not only bounded executor tests
5. no overclaim of broader control-plane closure
Status:
- accepted
Carry-forward from `P4`:
1. repeated primary assignment still logs a low-severity rebuild-server double-start warning on the same volume
2. broader control-plane closure remains out of scope for `Phase 09`
## Assignment For `sw`
Current next tasks:
1. define the concrete execution-closure package for `Phase 09`
2. specify what "real" means for:
- `TransferFullBase`
- `TransferSnapshot`
- `TruncateWAL`
3. specify how stronger live runtime execution ownership should work on the volume-server path
4. keep the phase bounded to the chosen candidate path unless new evidence forces expansion
5. hand the package to architect review before tester work begins
1. `Phase 09` is complete
2. no further `P4` implementation work is open in this phase
3. any next work should open under the next phase, not extend `Phase 09` implicitly
## Assignment For `tester`
Current next tasks:
1. prepare the validation oracle for production execution closure
2. require explicit validation targets for:
- real transfer behavior
- truncation execution
- cleanup on success/failure/cancel
- stronger live runtime ownership
3. keep no-overclaim active around:
- validation-grade vs production-grade execution
- chosen path vs future paths/modes
4. review only after architect pre-review passes
1. `Phase 09` validation/bookkeeping is complete
2. keep any residual notes bounded:
- low-severity rebuild-server double-start warning on repeated primary assignment
- broader control-plane closure still belongs to a later phase
+2 -2
View File
@@ -42,14 +42,14 @@ This note is for the dev manager to decide implementation sequencing.
This proposal is grounded in the following current documents:
- `sw-block/.private/phase/phase-04.md`
- `sw-block/design/v2-prototype-roadmap-and-gates.md`
- `sw-block/docs/archive/design/v2-prototype-roadmap-and-gates.md`
- `sw-block/design/v2-acceptance-criteria.md`
- `sw-block/design/v2-detailed-algorithm.zh.md`
In particular:
- `phase-04.md` shows that Phase 04 is correctly centered on sender/session ownership and recovery execution authority
- `v2-prototype-roadmap-and-gates.md` shows that design proof is high, but data/recovery proof and prototype end-to-end proof are still low
- `docs/archive/design/v2-prototype-roadmap-and-gates.md` shows that design proof is high, but data/recovery proof and prototype end-to-end proof are still low
- `v2-acceptance-criteria.md` already requires stronger proof for:
- `A5` non-convergent catch-up escalation
- `A6` explicit recoverability boundary
+37 -31
View File
@@ -1,45 +1,51 @@
# V2 Design
Current WAL V2 design set:
This directory now keeps the current design and process entrypoints for the active V2 line.
Historical planning/review documents were moved to `../docs/archive/design/` to keep this directory smaller and easier to navigate.
## Read First
- `v2-protocol-truths.md`
- `v2-product-completion-overview.md`
- `v2-phase-development-plan.md`
- `v2-semantic-methodology.zh.md`
- `v2-protocol-closure-map.zh.md`
- `v2-algorithm-overview.md`
- `v2-algorithm-overview.zh.md`
- `v2-detailed-algorithm.zh.md`
## Active Process / Workflow
- `protocol-development-process.md`
- `agent_dev_process.md`
## Active Supporting Design
- `v2-acceptance-criteria.md`
- `v2-open-questions.md`
- `v2_scenarios.md`
- `v2-scenario-sources-from-v1.md`
- `v1-v15-v2-comparison.md`
- `wal-replication-v2.md`
- `wal-replication-v2-state-machine.md`
- `wal-replication-v2-orchestrator.md`
- `wal-v2-tiny-prototype.md`
- `wal-v1-to-v2-mapping.md`
- `v2-dist-fsm.md`
- `v2_scenarios.md`
- `v1-v15-v2-comparison.md`
- `v2-scenario-sources-from-v1.md`
- `protocol-development-process.md`
- `v2-acceptance-criteria.md`
- `v2-open-questions.md`
- `v2-first-slice-session-ownership.md`
- `v2-prototype-roadmap-and-gates.md`
- `v2-engine-readiness-review.md`
- `v2-engine-slicing-plan.md`
- `v2-protocol-truths.md`
- `v2-production-roadmap.md`
- `v2-product-completion-overview.md`
- `v2-phase-development-plan.md`
- `phase-07-service-slice-plan.md`
- `phase-08-engine-skeleton-map.md`
- `agent_dev_process.md`
These documents are the working design home for the V2 line.
## Historical / Archived
See `../docs/archive/design/README.md` for archived:
- old roadmaps
- first-slice planning docs
- passed readiness/slicing reviews
- phase-specific design maps for closed phases
## Execution Note
- active development tracking lives under `../.private/phase/`
- current phase contract and slice packages live there rather than in this directory
The original project-level copies under `learn/projects/sw-block/design/` remain as shared references for now.
Execution note:
- active development tracking lives under `../.private/phase/`
- key completed/current phase docs include:
- `../.private/phase/phase-01.md`
- `../.private/phase/phase-02.md`
- `../.private/phase/phase-03.md`
- `../.private/phase/phase-04.md`
- `../.private/phase/phase-4.5.md`
- `../.private/phase/phase-05.md`
- `../.private/phase/phase-06.md`
- `../.private/phase/phase-07.md`
-117
View File
@@ -1,117 +0,0 @@
# A5-A8 Acceptance Traceability
Date: 2026-03-29
Status: Phase 4.5 evidence-hardening
## Purpose
Map each acceptance criterion to specific executable evidence.
Two evidence layers:
- **Simulator** (distsim): protocol-level proof
- **Prototype** (enginev2): ownership/session-level proof
---
## A5: Non-Convergent Catch-Up Escalates Explicitly
**Must prove**: tail-chasing or failed catch-up does not pretend success.
**Pass condition**: explicit `CatchingUp → NeedsRebuild` transition.
| Evidence | Test | File | Layer | Status |
|----------|------|------|-------|--------|
| Tail-chasing converges or aborts | `TestS6_TailChasing_ConvergesOrAborts` | `cluster_test.go` | distsim | PASS |
| Tail-chasing non-convergent → NeedsRebuild | `TestS6_TailChasing_NonConvergent_EscalatesToNeedsRebuild` | `phase02_advanced_test.go` | distsim | PASS |
| Catch-up timeout → NeedsRebuild | `TestP03_CatchupTimeout_EscalatesToNeedsRebuild` | `phase03_timeout_test.go` | distsim | PASS |
| Reservation expiry aborts catch-up | `TestReservationExpiryAbortsCatchup` | `cluster_test.go` | distsim | PASS |
| Flapping budget exceeded → NeedsRebuild | `TestP02_S5_FlappingExceedsBudget_EscalatesToNeedsRebuild` | `phase02_advanced_test.go` | distsim | PASS |
| Catch-up converges or escalates (I3) | `TestI3_CatchUpConvergesOrEscalates` | `phase045_crash_test.go` | distsim | PASS |
| Catch-up timeout in enginev2 | `TestE2E_NeedsRebuild_Escalation` | `p2_test.go` | enginev2 | PASS |
**Verdict**: A5 is well-covered. Both simulator and prototype prove explicit escalation. No pretend-success path exists.
---
## A6: Recoverability Boundary Is Explicit
**Must prove**: recoverable vs unrecoverable gap is decided explicitly.
**Pass condition**: recovery aborts when reservation/payload availability is lost; rebuild is explicit fallback.
| Evidence | Test | File | Layer | Status |
|----------|------|------|-------|--------|
| Reservation expiry aborts catch-up | `TestReservationExpiryAbortsCatchup` | `cluster_test.go` | distsim | PASS |
| WAL GC beyond replica → NeedsRebuild | `TestI5_CheckpointGC_PreservesAckedBoundary` | `phase045_crash_test.go` | distsim | PASS |
| Rebuild from snapshot + tail | `TestReplicaRebuildFromSnapshotAndTail` | `cluster_test.go` | distsim | PASS |
| Smart WAL: resolvable → unresolvable | `TestP02_SmartWAL_RecoverableThenUnrecoverable` | `phase02_advanced_test.go` | distsim | PASS |
| Time-varying payload availability | `TestP02_SmartWAL_TimeVaryingAvailability` | `phase02_advanced_test.go` | distsim | PASS |
| RecoverableLSN is replayability proof | `RecoverableLSN()` in `storage.go` | `storage.go` | distsim | Implemented |
| Handshake outcome: NeedsRebuild | `TestExec_HandshakeOutcome_NeedsRebuild_InvalidatesSession` | `execution_test.go` | enginev2 | PASS |
**Verdict**: A6 is covered. Recovery boundary is decided by explicit reservation + recoverability check, not by optimistic assumption. `RecoverableLSN()` verifies contiguous WAL coverage.
---
## A7: Historical Data Correctness Holds
**Must prove**: recovered data for target LSN is historically correct; current extent cannot fake old history.
**Pass condition**: snapshot + tail rebuild matches reference; current-extent reconstruction of old LSN fails correctness.
| Evidence | Test | File | Layer | Status |
|----------|------|------|-------|--------|
| Snapshot + tail matches reference | `TestReplicaRebuildFromSnapshotAndTail` | `cluster_test.go` | distsim | PASS |
| Historical state not reconstructable after GC | `TestA7_HistoricalState_NotReconstructableAfterGC` | `phase045_crash_test.go` | distsim | PASS |
| `CanReconstructAt()` rejects faked history | `CanReconstructAt()` in `storage.go` | `storage.go` | distsim | Implemented |
| Checkpoint does not leak applied state | `TestI2_CheckpointDoesNotLeakAppliedState` | `phase045_crash_test.go` | distsim | PASS |
| Extent-referenced resolvable records | `TestExtentReferencedResolvableRecordsAreRecoverable` | `cluster_test.go` | distsim | PASS |
| Extent-referenced unresolvable → rebuild | `TestExtentReferencedUnresolvableForcesRebuild` | `cluster_test.go` | distsim | PASS |
| ACK'd flush recoverable after crash (I1) | `TestI1_AckedFlush_RecoverableAfterPrimaryCrash` | `phase045_crash_test.go` | distsim | PASS |
**Verdict**: A7 is now covered with the Phase 4.5 crash-consistency additions. The critical gap ("current extent cannot fake old history") is proven by `CanReconstructAt()` + `TestA7_HistoricalState_NotReconstructableAfterGC`.
---
## A8: Durability Mode Semantics Are Correct
**Must prove**: best_effort, sync_all, sync_quorum behave as intended under mixed replica states.
**Pass condition**: sync_all strict, sync_quorum commits only with true durable quorum, invalid topology rejected.
| Evidence | Test | File | Layer | Status |
|----------|------|------|-------|--------|
| sync_quorum continues with one lagging | `TestSyncQuorumContinuesWithOneLaggingReplica` | `cluster_test.go` | distsim | PASS |
| sync_all blocks with one lagging | `TestSyncAllBlocksWithOneLaggingReplica` | `cluster_test.go` | distsim | PASS |
| sync_quorum mixed states | `TestSyncQuorumWithMixedReplicaStates` | `cluster_test.go` | distsim | PASS |
| sync_all mixed states | `TestSyncAllBlocksWithMixedReplicaStates` | `cluster_test.go` | distsim | PASS |
| Barrier timeout: sync_all blocked | `TestP03_BarrierTimeout_SyncAll_Blocked` | `phase03_timeout_test.go` | distsim | PASS |
| Barrier timeout: sync_quorum commits | `TestP03_BarrierTimeout_SyncQuorum_StillCommits` | `phase03_timeout_test.go` | distsim | PASS |
| Promotion uses RecoverableLSN | `EvaluateCandidateEligibility()` | `cluster.go` | distsim | Implemented |
| Promoted replica has committed prefix (I4) | `TestI4_PromotedReplica_HasCommittedPrefix` | `phase045_crash_test.go` | distsim | PASS |
**Verdict**: A8 is well-covered. sync_all is strict (blocks on lagging), sync_quorum uses true durable quorum (not connection count). Promotion now uses `RecoverableLSN()` for committed-prefix check.
---
## Summary
| Criterion | Simulator Evidence | Prototype Evidence | Status |
|-----------|-------------------|-------------------|--------|
| A5 (catch-up escalation) | 6 tests | 1 test | **Strong** |
| A6 (recoverability boundary) | 6 tests + RecoverableLSN() | 1 test | **Strong** |
| A7 (historical correctness) | 7 tests + CanReconstructAt() | — | **Strong** (new in Phase 4.5) |
| A8 (durability modes) | 7 tests + RecoverableLSN() | — | **Strong** |
**Total executable evidence**: 26 simulator tests + 2 prototype tests + 2 new storage methods.
All A5-A8 acceptance criteria have direct test evidence. No criterion depends solely on design-doc claims.
---
## Still Open (Not Blocking)
| Item | Priority | Why not blocking |
|------|----------|-----------------|
| Predicate exploration / adversarial search | P2 | Manual scenarios already cover known failure classes |
| Catch-up convergence under sustained load | P2 | I3 proves escalation; load-rate modeling is optimization |
| A5-A8 in a single grouped runner view | P3 | Traceability doc serves as grouped evidence for now |
@@ -1,403 +0,0 @@
# Phase 07 Service-Slice Plan
Date: 2026-03-30
Status: draft
Scope: `Phase 07 P0`
## Purpose
Define the first real-system service slice that will host the V2 engine, choose the first concrete integration path in the existing codebase, and map engine adapters onto real modules.
This is a planning document. It does not claim the integration already works.
## Decision
The first service slice should be:
- a single `blockvol` primary on a real volume server
- with one replica target (`RF=2` path)
- driven by the existing master heartbeat / assignment loop
- using the V2 engine only for replication recovery ownership / planning / execution
This is the narrowest real-system slice that still exercises:
1. real assignment delivery
2. real epoch and failover signals
3. real volume-server lifecycle
4. real WAL/checkpoint/base-image truth
5. real changed-address / reconnect behavior
It is narrow enough to avoid reopening the whole system, but real enough to stop hiding behind engine-local mocks.
## Why This Slice
This slice is the right first integration target because:
1. `weed/server/master_grpc_server.go` already delivers block-volume assignments over heartbeat
2. `weed/server/master_block_failover.go` already owns failover / promotion / pending rebuild decisions
3. `weed/storage/blockvol/blockvol.go` already owns the current replication runtime (`shipperGroup`, receiver, WAL retention, checkpoint state)
4. the existing V1/V1.5 failure history is concentrated in exactly this master <-> volume-server <-> blockvol path
So this slice gives maximum validation value with minimum new surface.
## First Concrete Integration Path
The first integration path should be:
1. master receives volume-server heartbeat
2. master updates block registry and emits `BlockVolumeAssignment`
3. volume server receives assignment
4. block volume adapter converts assignment + local storage state into V2 engine inputs
5. V2 engine drives sender/session/recovery state
6. existing block-volume runtime executes the actual data-path work under engine decisions
In code, that path starts here:
- master side:
- `weed/server/master_grpc_server.go`
- `weed/server/master_block_failover.go`
- `weed/server/master_block_registry.go`
- volume / storage side:
- `weed/storage/blockvol/blockvol.go`
- `weed/storage/blockvol/recovery.go`
- `weed/storage/blockvol/wal_shipper.go`
- assignment-handling code under `weed/storage/blockvol/`
- V2 engine side:
- `sw-block/engine/replication/`
## Service-Slice Boundaries
### In-process placement
The V2 engine should initially live:
- in-process with the volume server / `blockvol` runtime
- not in master
- not as a separate service yet
Reason:
- the engine needs local access to storage truth and local recovery execution
- master should remain control-plane authority, not recovery executor
### Control-plane boundary
Master remains authoritative for:
1. epoch
2. role / assignment
3. promotion / failover decision
4. replica membership
The engine consumes these as control inputs. It does not replace master failover policy in `Phase 07`.
### Control-Over-Heartbeat Upgrade Path
For the first V2 product path, the recommended direction is:
- reuse the existing master <-> volume-server heartbeat path as the control carrier
- upgrade the block-specific control semantics carried on that path
- do not immediately invent a separate control service or assignment channel
Why:
1. this is the real Seaweed path already carrying block assignments and confirmations today
2. this gives the fastest route to a real integrated control path
3. it preserves compatibility with existing Seaweed master/volume-server semantics while V2 hardens its own control truth
Concretely, the current V1 path already provides:
1. block assignments delivered in heartbeat responses from `weed/server/master_grpc_server.go`
2. assignment application on the volume server in `weed/server/volume_grpc_client_to_master.go` and `weed/server/volume_server_block.go`
3. assignment confirmation and address-change refresh driven by later heartbeats in `weed/server/master_grpc_server.go` and `weed/server/master_block_registry.go`
4. immediate block heartbeat on selected shipper state changes in `weed/server/volume_grpc_client_to_master.go`
What should be upgraded for V2 is not mainly the transport, but the control contract carried on it:
1. stable `ReplicaID`
2. explicit `Epoch`
3. explicit role / assignment authority
4. explicit apply/confirm semantics
5. explicit stale assignment rejection
6. explicit address-change refresh as endpoint change, not identity change
Current cadence note:
- the block volume heartbeat is periodic (`5 * sleepInterval`) with some immediate state-change heartbeats
- this is acceptable as the first hardening carrier
- it should not be assumed to be the final control responsiveness model
Deferred design decision:
- whether block control should eventually move beyond heartbeat-only carriage into a more explicit control/assignment channel should be decided only after the `Phase 08 P1` real control-delivery path exists and can be measured
That later decision should be based on:
1. failover / reassignment responsiveness
2. assignment confirmation precision
3. operational complexity
4. whether heartbeat carriage remains too coarse for the block-control path
Until then, the preferred direction is:
- strengthen block control semantics over the existing heartbeat path
- do not prematurely create a second control plane
### Storage boundary
`blockvol` remains authoritative for:
1. WAL head / retention reality
2. checkpoint/base-image reality
3. actual catch-up streaming
4. actual rebuild transfer / restore operations
The engine consumes these as storage truth and recovery execution capabilities. It does not replace the storage backend in `Phase 07`.
## First-Slice Identity Mapping
This must be explicit in the first integration slice.
For `RF=2` on the existing master / block registry path:
- stable engine `ReplicaID` should be derived from:
- `<volume-name>/<replica-server-id>`
- not from:
- `DataAddr`
- `CtrlAddr`
- heartbeat transport endpoint
For this slice, the adapter should map:
1. `ReplicaID`
- from master/block-registry identity for the replica host entry
2. `Endpoint`
- from the current replica receiver/data/control addresses reported by the real runtime
3. `Epoch`
- from the confirmed master assignment for the volume
4. `SessionKind`
- from master-driven recovery intent / role transition outcome
This is a hard first-slice requirement because address refresh must not collapse identity back into endpoint-shaped keys.
## Adapter Mapping
### 1. ControlPlaneAdapter
Engine interface today:
- `HandleHeartbeat(serverID, volumes)`
- `HandleFailover(deadServerID)`
Real mapping should be:
- master-side source:
- `weed/server/master_grpc_server.go`
- `weed/server/master_block_failover.go`
- `weed/server/master_block_registry.go`
- volume-server side sink:
- assignment receive/apply path in `weed/storage/blockvol/`
Recommended real shape:
- do not literally push raw heartbeat messages into the engine
- instead introduce a thin adapter that converts confirmed master assignment state into:
- stable `ReplicaID`
- endpoint set
- epoch
- recovery target kind
That keeps master as control owner and the engine as execution owner.
Important note:
- the adapter should treat heartbeat as the transport carrier, not as the final protocol shape
- block-control semantics should be made explicit over that carrier
- if a later phase concludes that heartbeat-only carriage is too coarse, that should be a separate design decision after the real hardening path is measured
### 2. StorageAdapter
Engine interface today:
- `GetRetainedHistory()`
- `PinSnapshot(lsn)` / `ReleaseSnapshot(pin)`
- `PinWALRetention(startLSN)` / `ReleaseWALRetention(pin)`
- `PinFullBase(committedLSN)` / `ReleaseFullBase(pin)`
Real mapping should be:
- retained history source:
- current WAL head/tail/checkpoint state from `weed/storage/blockvol/blockvol.go`
- recovery helpers in `weed/storage/blockvol/recovery.go`
- WAL retention pin:
- existing retention-floor / replica-aware WAL retention machinery around `shipperGroup`
- snapshot pin:
- existing snapshot/checkpoint artifacts in `blockvol`
- full-base pin:
- explicit pinned full-extent export or equivalent consistent base handle from `blockvol`
Important constraint:
- `Phase 07` must not fake this by reconstructing `RetainedHistory` from tests or metadata alone
### 3. Execution Driver / Executor hookup
Engine side already has:
- planner/executor split in `sw-block/engine/replication/driver.go`
- stepwise executors in `sw-block/engine/replication/executor.go`
Real mapping should be:
- engine planner decides:
- zero-gap / catch-up / rebuild
- trusted-base requirement
- replayable-tail requirement
- blockvol runtime performs:
- actual WAL catch-up transport
- actual snapshot/base transfer
- actual truncation / apply operations
Recommended split:
- engine owns contract and state transitions
- blockvol adapter owns concrete I/O work
## First-Slice Acceptance Rule
For the first integration slice, this is a hard rule:
- `blockvol` may execute recovery I/O
- `blockvol` must not own recovery policy
Concretely, `blockvol` must not decide:
1. zero-gap vs catch-up vs rebuild
2. trusted-base validity
3. replayable-tail sufficiency
4. whether rebuild fallback is required
Those decisions must remain in the V2 engine.
The bridge may translate engine decisions into concrete blockvol actions, but it must not re-decide recovery policy underneath the engine.
## First Product Path
The first product path should be:
- `RF=2` block volume replication on the existing heartbeat/assignment loop
- primary + one replica
- failover / reconnect / changed-address handling
- rebuild as the formal non-catch-up recovery path
This is the right first path because it exercises the core correctness boundary without introducing N-replica coordination complexity too early.
## What Must Be Replaced First
Current engine-stage pieces that are still mock/test-only or too abstract:
### Replace first
1. `mockStorage` in engine tests
- replace with a real `blockvol`-backed `StorageAdapter`
2. synthetic control events in engine tests
- replace with assignment-driven events from the real master/volume-server path
3. convenience recovery completion wrappers
- keep them test-only
- real integration should use planner + executor + storage work loop
### Can remain temporarily abstract in Phase 07 P0/P1
1. `ControlPlaneAdapter` exact public shape
- can remain thin while the integration path is being chosen
2. async production scheduler details
- executor can still be driven by a service loop before full background-task architecture is finalized
## Recommended Concrete Modules
### Engine stays here
- `sw-block/engine/replication/`
### First real adapter package should be added near blockvol
Recommended initial location:
- `weed/storage/blockvol/v2bridge/`
Reason:
- keeps V2 engine independent under `sw-block/`
- keeps real-system glue close to blockvol storage truth
- avoids copying engine logic into `weed/`
Suggested contents:
1. `control_adapter.go`
- convert master assignment / local apply path into engine intents
2. `storage_adapter.go`
- expose retained history, pin/release, trusted-base export handles from real blockvol state
3. `executor_bridge.go`
- translate engine executor steps into actual blockvol recovery actions
4. `observe_adapter.go`
- map engine status/logs into service-visible diagnostics
## First Failure Replay Set For Phase 07
The first real-system replay set should be:
1. changed-address restart
- current risk: old identity/address coupling reappears in service glue
2. stale epoch / stale result after failover
- current risk: master and engine disagree on authority timing
3. unreplayable-tail rebuild fallback
- current risk: service glue over-trusts checkpoint/base availability
4. plan/execution cleanup after resource failure
- current risk: blockvol-side resource failures leave engine or service state dangling
5. primary failover to replica with rebuild pending on old primary reconnect
- current risk: old V1/V1.5 semantics leak back into reconnect handling
## Non-Goals For This Slice
Do not use `Phase 07` to:
1. widen catch-up semantics
2. add smart rebuild optimizations
3. redesign all blockvol internals
4. replace the full V1 runtime in one move
5. claim production readiness
## Deliverables For Phase 07 P0
A good `P0` delivery should include:
1. chosen service slice
2. chosen integration path in the current repo
3. adapter-to-module mapping
4. list of test-only adapters to replace first
5. first failure replay set
6. explicit note of what remains outside this first slice
## Short Form
`Phase 07 P0` should start with:
- engine in `sw-block/engine/replication/`
- bridge in `weed/storage/blockvol/v2bridge/`
- first real slice = blockvol primary + one replica on the existing master heartbeat / assignment path
- `ReplicaID = <volume-name>/<replica-server-id>` for the first slice
- `blockvol` executes I/O but does not own recovery policy
- first product path = `RF=2` failover/reconnect/rebuild correctness
@@ -1,301 +0,0 @@
# Phase 08 Engine Skeleton Map
Date: 2026-03-31
Status: active
Purpose: provide a short structural map for the `Phase 08` hardening path so implementation can move faster without reopening accepted V2 boundaries
## Scope
This is not the final standalone `sw-block` architecture.
It is the shortest useful engine skeleton for the accepted `Phase 08` hardening path:
- `RF=2`
- `sync_all`
- existing `Seaweed` master / volume-server heartbeat path
- V2 engine owns recovery policy
- `blockvol` remains the execution backend
## Module Map
### 1. Control plane
Role:
- authoritative control truth
Primary sources:
- `weed/server/master_grpc_server.go`
- `weed/server/master_block_registry.go`
- `weed/server/master_block_failover.go`
- `weed/server/volume_grpc_client_to_master.go`
What it produces:
- confirmed assignment
- `Epoch`
- target `Role`
- failover / promotion / reassignment result
- stable server identity
### 2. Control bridge
Role:
- translate real control truth into V2 engine intent
Primary files:
- `weed/storage/blockvol/v2bridge/control.go`
- `sw-block/bridge/blockvol/control_adapter.go`
- entry path in `weed/server/volume_server_block.go`
What it produces:
- `AssignmentIntent`
- stable `ReplicaID`
- `Endpoint`
- `SessionKind`
### 3. Engine runtime
Role:
- recovery-policy core
Primary files:
- `sw-block/engine/replication/orchestrator.go`
- `sw-block/engine/replication/driver.go`
- `sw-block/engine/replication/executor.go`
- `sw-block/engine/replication/sender.go`
- `sw-block/engine/replication/history.go`
What it decides:
- zero-gap / catch-up / needs-rebuild
- sender/session ownership
- stale authority rejection
- resource acquisition / release
- rebuild source selection
### 4. Storage bridge
Role:
- translate real blockvol storage truth and execution capability into engine-facing adapters
Primary files:
- `weed/storage/blockvol/v2bridge/reader.go`
- `weed/storage/blockvol/v2bridge/pinner.go`
- `weed/storage/blockvol/v2bridge/executor.go`
- `sw-block/bridge/blockvol/storage_adapter.go`
What it provides:
- `RetainedHistory`
- WAL retention pin / release
- snapshot pin / release
- full-base pin / release
- WAL scan execution
### 5. Block runtime
Role:
- execute real I/O
Primary files:
- `weed/storage/blockvol/blockvol.go`
- `weed/storage/blockvol/replica_apply.go`
- `weed/storage/blockvol/replica_barrier.go`
- `weed/storage/blockvol/recovery.go`
- `weed/storage/blockvol/rebuild.go`
- `weed/storage/blockvol/wal_shipper.go`
What it owns:
- WAL
- extent
- flusher
- checkpoint / superblock
- receiver / shipper
- rebuild server
## Execution Order
### Control path
```text
master heartbeat / failover truth
-> BlockVolumeAssignment
-> volume server ProcessAssignments
-> v2bridge control conversion
-> engine ProcessAssignment
-> sender/session state updated
```
### Catch-up path
```text
assignment accepted
-> engine reads retained history
-> engine plans catch-up
-> storage bridge pins WAL retention
-> engine executor drives v2bridge executor
-> blockvol scans WAL / ships entries
-> engine completes session
```
### Rebuild path
```text
assignment accepted
-> engine detects NeedsRebuild
-> engine selects rebuild source
-> storage bridge pins snapshot/full-base/tail
-> executor drives transfer path
-> blockvol performs restore / replay work
-> engine completes rebuild
```
### Local durability path
```text
WriteLBA / Trim
-> WAL append
-> shipping / barrier
-> client-visible durability decision
-> flusher writes extent
-> checkpoint advances
-> retention floor decides WAL reclaimability
```
## Interim Fields
These are currently acceptable only as explicit hardening carry-forwards:
### `localServerID`
Current source:
- `BlockService.listenAddr`
Meaning:
- temporary local identity source for replica/rebuild-side assignment translation
Status:
- interim only
- should become registry-assigned stable server identity later
### `CommittedLSN = CheckpointLSN`
Current source:
- `v2bridge.Reader` / `BlockVol.StatusSnapshot()`
Meaning:
- current V1-style interim mapping where committed truth collapses to local checkpoint truth
Status:
- not final V2 truth
- must become a gate decision before a production-candidate phase
### heartbeat as control carrier
Current source:
- existing master <-> volume-server heartbeat path
Meaning:
- current transport for assignment/control delivery
Status:
- acceptable as current carrier
- not yet a final proof that no separate control channel will ever be needed
## Hard Gates
These should remain explicit in `Phase 08`:
### Gate 1: committed truth
Before production-candidate:
- either separate `CommittedLSN` from `CheckpointLSN`
- or explicitly bound the first candidate path to currently proven pre-checkpoint replay behavior
### Gate 2: live control delivery
Required:
- real assignment delivery must reach the engine on the live path
- not only converter-level proof
### Gate 3: integrated catch-up closure
Required:
- engine -> executor -> `v2bridge` -> blockvol must be proven as one live chain
- not planner proof plus direct WAL-scan proof as separate evidence
### Gate 4: first rebuild execution path
Required:
- rebuild must not remain only a detection outcome
- the chosen product path needs one real executable rebuild closure
### Gate 5: unified replay
Required:
- after control and execution closure land, rerun the accepted failure-class set on the unified live path
## Reuse Map
### Reuse directly
- `weed/server/master_grpc_server.go`
- `weed/server/volume_grpc_client_to_master.go`
- `weed/server/volume_server_block.go`
- `weed/server/master_block_registry.go`
- `weed/server/master_block_failover.go`
- `weed/storage/blockvol/blockvol.go`
- `weed/storage/blockvol/replica_apply.go`
- `weed/storage/blockvol/replica_barrier.go`
- `weed/storage/blockvol/v2bridge/`
### Reuse as implementation reality, not truth
- `shipperGroup`
- `RetentionFloorFn`
- `ReplicaReceiver`
- checkpoint/superblock machinery
- existing failover heuristics
### Do not inherit as V2 semantics
- address-shaped identity
- old degraded/catch-up intuition from V1/V1.5
- `CommittedLSN = CheckpointLSN` as final truth
- blockvol-side recovery policy decisions
## Short Rule
Use this skeleton as:
- a hardening map for the current product path
Do not mistake it for:
- the final standalone `sw-block` architecture
@@ -1,170 +0,0 @@
# V2 Engine Readiness Review
Date: 2026-03-29
Status: active
Purpose: record the decision on whether the current V2 design + prototype + simulator stack is strong enough to begin real V2 engine slicing
## Decision
Current judgment:
- proceed to real V2 engine planning
- do not open a `V2.5` redesign track at this time
This is a planning-readiness decision, not a production-readiness claim.
## Why This Review Exists
The project has now completed:
1. design/FSM closure for the V2 line
2. protocol simulation closure for:
- V1 / V1.5 / V2 comparison
- timeout/race behavior
- ownership/session semantics
3. standalone prototype closure for:
- sender/session ownership
- execution authority
- recovery branching
- minimal historical-data proof
- prototype scenario closure
4. `Phase 4.5` hardening for:
- bounded `CatchUp`
- first-class `Rebuild`
- crash-consistency / restart-recoverability
- `A5-A8` stronger evidence
So the question is no longer:
- "can the prototype be made richer?"
The question is:
- "is the evidence now strong enough to begin real engine slicing?"
## Evidence Summary
### 1. Design / Protocol
Primary docs:
- `sw-block/design/v2-acceptance-criteria.md`
- `sw-block/design/v2-open-questions.md`
- `sw-block/design/v2_scenarios.md`
- `sw-block/design/v1-v15-v2-comparison.md`
- `sw-block/design/v2-prototype-roadmap-and-gates.md`
Judgment:
- protocol story is coherent
- acceptance set exists
- major V1 / V1.5 failures are mapped into V2 scenarios
### 2. Simulator
Primary code/tests:
- `sw-block/prototype/distsim/`
- `sw-block/prototype/distsim/eventsim.go`
- `learn/projects/sw-block/test/results/v2-simulation-review.md`
Judgment:
- strong enough for protocol/design validation
- strong enough to challenge crash-consistency and liveness assumptions
- not a substitute for real engine / hardware proof
### 3. Prototype
Primary code/tests:
- `sw-block/prototype/enginev2/`
- `sw-block/prototype/enginev2/acceptance_test.go`
Judgment:
- ownership is explicit and fenced
- execution authority is explicit and fenced
- bounded `CatchUp` is semantic, not documentary
- `Rebuild` is a first-class sender-owned path
- historical-data and recoverability reasoning are executable
### 4. `A5-A8` Double Evidence
Prototype-side grouped evidence:
- `sw-block/prototype/enginev2/acceptance_test.go`
Simulator-side grouped evidence:
- `sw-block/design/a5-a8-traceability.md`
- `sw-block/prototype/distsim/`
Judgment:
- the critical acceptance items that most affect engine risk now have materially stronger proof on both sides
## What Is Good Enough Now
The following are good enough to begin engine slicing:
1. sender/session ownership model
2. stale authority fencing
3. recovery orchestration shape
4. bounded `CatchUp` contract
5. `Rebuild` as formal path
6. committed/recoverable boundary thinking
7. crash-consistency / restart-recoverability proof style
## What Is Still Not Proven
The following still require real engine work and later real-system validation:
1. actual engine lifecycle integration
2. real storage/backend implementation
3. real control-plane integration
4. real durability / fsync behavior under the actual engine
5. real hardware timing / performance
6. final production observability and failure handling
These are expected gaps. They do not block engine planning.
## Open Risks To Carry Forward
These are not blockers, but they should remain explicit:
1. prototype and simulator are still reduced models
2. rebuild-source quality in the real engine will depend on actual checkpoint/base-image mechanics
3. durability truth in the real engine must still be re-proven against actual persistence behavior
4. predicate exploration can still grow, but should not block engine slicing
## Engine-Planning Decision
Decision:
- start real V2 engine planning
Reason:
1. no current evidence points to a structural flaw requiring `V2.5`
2. the remaining gaps are implementation/system gaps, not prototype ambiguity
3. continuing to extend prototype/simulator breadth would have diminishing returns
## Required Outputs After This Review
1. `sw-block/design/v2-engine-slicing-plan.md`
2. first real engine slice definition
3. explicit non-goals for first engine stage
4. explicit validation plan for engine slices
## Non-Goals Of This Review
This review does not claim:
1. V2 is production-ready
2. V2 should replace V1 immediately
3. all design questions are forever closed
It only claims:
- the project now has enough evidence to begin disciplined real engine slicing
-191
View File
@@ -1,191 +0,0 @@
# V2 Engine Slicing Plan
Date: 2026-03-29
Status: active
Purpose: define the first real V2 engine slices after prototype and `Phase 4.5` closure
## Goal
Move from:
- standalone design/prototype truth under `sw-block/prototype/`
to:
- a real V2 engine core under `sw-block/`
without dragging V1.5 lifecycle assumptions into the implementation.
## Planning Rules
1. reuse V1 ideas and tests selectively, not structurally
2. prefer narrow vertical slices over broad skeletons
3. each slice must preserve the accepted V2 ownership/fencing model
4. keep simulator/prototype as validation support, not as the implementation itself
5. do not mix V2 engine work into `weed/storage/blockvol/`
## First Engine Stage
The first engine stage should build the control/recovery core, not the full storage engine.
That means:
1. per-replica sender identity
2. one active recovery session per replica per epoch
3. sender-owned execution authority
4. explicit recovery outcomes:
- zero gap
- bounded catch-up
- rebuild
5. rebuild execution shell only
- do not hard-code final snapshot + tail vs full base decision logic yet
- keep real rebuild-source choice tied to Slice 3 recoverability inputs
## Recommended Slice Order
### Slice 1: Engine Ownership Core
Purpose:
- carry the accepted `enginev2` ownership/fencing model into the real engine core
Scope:
1. stable per-replica sender object
2. stable recovery-session object
3. session identity fencing
4. endpoint / epoch invalidation
5. sender-group or equivalent ownership registry
Acceptance:
1. stale session results cannot mutate current authority
2. changed-address and epoch-bump invalidation work in engine code
3. the 4 V2-boundary ownership themes remain provable
### Slice 2: Engine Recovery Execution Core
Purpose:
- move the prototype execution APIs into real engine behavior
Scope:
1. connect / handshake / catch-up flow
2. bounded `CatchUp`
3. explicit `NeedsRebuild`
4. sender-owned rebuild execution path
5. rebuild execution shell without final trusted-base selection policy
Acceptance:
1. bounded catch-up does not chase indefinitely
2. rebuild is exclusive from catch-up
3. session completion rules are explicit and fenced
### Slice 3: Engine Data / Recoverability Core
Purpose:
- connect recovery behavior to real retained-history / checkpoint mechanics
Scope:
1. real recoverability decision inputs
2. trusted-base decision for rebuild source
3. minimal real checkpoint/base-image integration
4. real truncation / safe-boundary handling
This is the first slice that should decide, from real engine inputs, between:
1. `snapshot + tail`
2. `full base`
Acceptance:
1. engine can explain why recovery is allowed
2. rebuild-source choice is explicit and testable
3. historical correctness and truncation rules remain intact
### Slice 4: Engine Integration Closure
Purpose:
- bind engine control/recovery core to real orchestration and validation surfaces
Scope:
1. real assignment/control intent entry path
2. engine-facing observability
3. focused real-engine tests for V2-boundary cases
4. first integration review against real failure classes
Acceptance:
1. key V2-boundary failures are reproduced and closed in engine tests
2. engine observability is good enough to debug ownership/recovery failures
3. remaining gaps are system/performance gaps, not control-model ambiguity
## What To Reuse
Good reuse candidates:
1. tests and failure cases from V1 / V1.5
2. narrow utility/data helpers where not coupled to V1 lifecycle
3. selected WAL/history concepts if they fit V2 ownership boundaries
Do not structurally reuse:
1. V1/V1.5 shipper lifecycle
2. address-based identity assumptions
3. `SetReplicaAddrs`-style behavior
4. old recovery control structure
## Where The Work Should Live
Real V2 engine work should continue under:
- `sw-block/`
Recommended next area:
- `sw-block/core/`
or
- `sw-block/engine/`
Exact path can be chosen later, but it should remain separate from:
- `sw-block/prototype/`
- `weed/storage/blockvol/`
## Validation Plan For Engine Slices
Each engine slice should be validated at three levels:
1. prototype alignment
- does engine behavior preserve the accepted prototype invariant?
2. focused engine tests
- does the real engine slice enforce the same contract?
3. scenario mapping
- does at least one important V1/V1.5 failure class remain closed?
## Non-Goals For First Engine Stage
Do not try to do these immediately:
1. full Smart WAL expansion
2. performance optimization
3. V1 replacement/migration plan
4. full product integration
5. all storage/backend redesign at once
## Immediate Next Assignment
The first concrete engine-planning task should be:
1. choose the real V2 engine module location under `sw-block/`
2. define Slice 1 file/module boundaries
3. write a short engine ownership-core spec
4. map 3-5 acceptance scenarios directly onto Slice 1 expectations
@@ -1,159 +0,0 @@
# V2 First Slice: Per-Replica Sender/Session Ownership
Date: 2026-03-27
Status: implementation-ready
Depends-on: Q1 (recovery session), Q6 (orchestrator scope), Q7 (first slice)
## Problem
`SetReplicaAddrs()` replaces the entire `ShipperGroup` atomically. This causes:
1. **State loss on topology change.** All shippers are destroyed and recreated.
Recovery state (`replicaFlushedLSN`, `lastContactTime`, catch-up progress) is lost.
After a changed-address restart, the new shipper starts from scratch.
2. **No per-replica identity.** Shippers are identified by array index. The master
cannot target a specific replica for rebuild/catch-up — it must re-issue the
entire address set.
3. **Background reconnect races.** A reconnect cycle may be in progress when
`SetReplicaAddrs` replaces the group. The in-progress reconnect's connection
objects become orphaned.
## Design
### Per-replica sender identity
`ShipperGroup` changes from `[]*WALShipper` to `map[string]*WALShipper`, keyed by
the replica's canonical data address. Each shipper stores its own `ReplicaID`.
```go
type WALShipper struct {
ReplicaID string // canonical data address — identity across reconnects
// ... existing fields
}
type ShipperGroup struct {
mu sync.RWMutex
shippers map[string]*WALShipper // keyed by ReplicaID
}
```
### ReconcileReplicas replaces SetReplicaAddrs
Instead of replacing the entire group, `ReconcileReplicas` diffs old vs new:
```
ReconcileReplicas(newAddrs []ReplicaAddr):
for each existing shipper:
if NOT in newAddrs → Stop and remove
for each newAddr:
if matching shipper exists → keep (preserve state)
if no match → create new shipper
```
This preserves `replicaFlushedLSN`, `lastContactTime`, catch-up progress, and
background reconnect goroutines for replicas that stay in the set.
`SetReplicaAddrs` becomes a wrapper:
```go
func (v *BlockVol) SetReplicaAddrs(addrs []ReplicaAddr) {
if v.shipperGroup == nil {
v.shipperGroup = NewShipperGroup(nil)
}
v.shipperGroup.ReconcileReplicas(addrs, v.makeShipperFactory())
}
```
### Changed-address restart flow
1. Replica restarts on new port. Heartbeat reports new address.
2. Master detects endpoint change (address differs, same volume).
3. Master sends assignment update to primary with new replica address.
4. Primary's `ReconcileReplicas` receives `[oldAddr1, newAddr2]`.
5. Old shipper for the changed replica is stopped (old address gone from set).
6. New shipper created with new address — but this is a fresh shipper.
7. New shipper bootstraps: Disconnected → Connecting → CatchingUp → InSync.
The improvement over V1.5: the **other** replicas in the set are NOT disturbed.
Only the changed replica gets a fresh shipper. Recovery state for stable replicas
is preserved.
### Recovery session
Each WALShipper already contains the recovery state machine:
- `state` (Disconnected → Connecting → CatchingUp → InSync → Degraded → NeedsRebuild)
- `replicaFlushedLSN` (authoritative progress)
- `lastContactTime` (retention budget)
- `catchupFailures` (escalation counter)
- Background reconnect goroutine
No separate `RecoverySession` object is needed. The WALShipper IS the per-replica
recovery session. The state machine already tracks the session lifecycle.
What changes: the session is no longer destroyed on topology change (unless the
replica itself is removed from the set).
### Coordinator vs primary responsibilities
| Responsibility | Owner |
|---------------|-------|
| Endpoint truth (canonical address) | Coordinator (master) |
| Assignment updates (add/remove replicas) | Coordinator |
| Epoch authority | Coordinator |
| Session creation trigger | Coordinator (via assignment) |
| Session execution (reconnect, catch-up, barrier) | Primary (via WALShipper) |
| Timeout enforcement | Primary |
| Ordered receive/apply | Replica |
| Barrier ack | Replica |
| Heartbeat reporting | Replica |
### Migration from current code
| Current | V2 |
|---------|-----|
| `ShipperGroup.shippers []*WALShipper` | `ShipperGroup.shippers map[string]*WALShipper` |
| `SetReplicaAddrs()` creates all new | `ReconcileReplicas()` diffs and preserves |
| `StopAll()` in demote | `StopAll()` unchanged (stops all) |
| `ShipAll(entry)` iterates slice | `ShipAll(entry)` iterates map values |
| `BarrierAll(lsn)` parallel slice | `BarrierAll(lsn)` parallel map values |
| `MinReplicaFlushedLSN()` iterates slice | Same, iterates map values |
| `ShipperStates()` iterates slice | Same, iterates map values |
| No per-shipper identity | `WALShipper.ReplicaID` = canonical data addr |
### Files changed
| File | Change |
|------|--------|
| `wal_shipper.go` | Add `ReplicaID` field, pass in constructor |
| `shipper_group.go` | `map[string]*WALShipper`, `ReconcileReplicas`, update iterators |
| `blockvol.go` | `SetReplicaAddrs` calls `ReconcileReplicas`, shipper factory |
| `promotion.go` | No change (StopAll unchanged) |
| `dist_group_commit.go` | No change (uses ShipperGroup API) |
| `block_heartbeat.go` | No change (uses ShipperStates) |
### Acceptance bar
The following existing tests must continue to pass:
- All CP13-1 through CP13-7 protocol tests (sync_all_protocol_test.go)
- All adversarial tests (sync_all_adversarial_test.go)
- All baseline tests (sync_all_bug_test.go)
- All rebuild tests (rebuild_v1_test.go)
The following CP13-8 tests validate the V2 improvement:
- `TestCP13_SyncAll_ReplicaRestart_Rejoin` — changed-address recovery
- `TestAdversarial_ReconnectUsesHandshakeNotBootstrap` — V2 reconnect protocol
- `TestAdversarial_CatchupMultipleDisconnects` — state preservation across reconnects
New tests to add:
- `TestReconcileReplicas_PreservesExistingShipper` — stable replica keeps state
- `TestReconcileReplicas_RemovesStaleShipper` — removed replica stopped
- `TestReconcileReplicas_AddsNewShipper` — new replica bootstraps
- `TestReconcileReplicas_MixedUpdate` — one kept, one removed, one added
## Non-goals for this slice
- Smart WAL payload classes
- Recovery reservation protocol
- Full coordinator orchestration
- New transport layer
@@ -1,193 +0,0 @@
# V2 First Slice: Per-Replica Sender and Recovery Session Ownership
Date: 2026-03-27
## Purpose
This document defines the first real V2 implementation slice.
The slice is intentionally narrow:
- per-replica sender ownership
- explicit recovery session ownership
- clear coordinator vs primary responsibility
This is the first step toward a standalone V2 block engine under `sw-block/`.
## Why This Slice First
It directly addresses the clearest V1.5 structural limits:
- sender identity loss when replica sets are refreshed
- changed-address restart recovery complexity
- repeated reconnect cycles without stable per-replica ownership
- adversarial Phase 13 boundary tests that V1.5 cannot cleanly satisfy
It also avoids jumping too early into:
- Smart WAL
- new backend storage layout
- full production transport redesign
## Core Decision
Use:
- **one sender owner per replica**
- **at most one active recovery session per replica per epoch**
Healthy replicas may only need their steady sender object.
Degraded / reconnecting replicas gain an explicit recovery session owned by the primary.
## Ownership Split
### Coordinator
Owns:
- replica identity / endpoint truth
- assignment updates
- epoch authority
- session creation / destruction intent
Does not own:
- byte-by-byte catch-up execution
- local sender loop scheduling
### Primary
Owns:
- per-replica sender objects
- per-replica recovery session execution
- reconnect / catch-up progress
- timeout enforcement for active session
- transition from:
- normal sender
- to recovery session
- back to normal sender
### Replica
Owns:
- receive/apply path
- barrier ack
- heartbeat/reporting
Replica remains passive from the recovery-orchestration point of view.
## Data Model
## Sender Owner
Per replica, maintain a stable sender owner with:
- replica logical ID
- current endpoint
- current epoch view
- steady-state health/status
- optional active recovery session reference
## Recovery Session
Per replica, per epoch:
- `ReplicaID`
- `Epoch`
- `EndpointVersion` or equivalent endpoint truth
- `State`
- `connecting`
- `catching_up`
- `in_sync`
- `needs_rebuild`
- `StartLSN`
- `TargetLSN`
- timeout / deadline metadata
## Session Rules
1. only one active session per replica per epoch
2. new assignment for same replica:
- supersedes old session only if epoch/session generation is newer
3. stale session must not continue after:
- epoch bump
- endpoint truth change
- explicit coordinator replacement
## Minimal State Transitions
### Healthy path
1. replica sender exists
2. sender ships normally
3. replica remains `InSync`
### Recovery path
1. sender detects or is told replica is not healthy
2. coordinator provides valid assignment/endpoint truth
3. primary creates recovery session
4. session connects
5. session catches up if recoverable
6. on success:
- session closes
- steady sender resumes normal state
### Rebuild path
1. session determines catch-up is not sufficient
2. session transitions to `needs_rebuild`
3. higher layer rebuild flow takes over
## What This Slice Does Not Include
Not in the first slice:
- Smart WAL payload classes in production
- snapshot pinning / GC logic
- new on-disk engine
- frontend publication changes
- full production event scheduler
## Proposed V2 Workspace Target
Do this under `sw-block/`, not `weed/storage/blockvol/`.
Suggested area:
- `sw-block/prototype/enginev2/`
Suggested first files:
- `sw-block/prototype/enginev2/session.go`
- `sw-block/prototype/enginev2/sender.go`
- `sw-block/prototype/enginev2/group.go`
- `sw-block/prototype/enginev2/session_test.go`
The first code does not need full storage I/O.
It should prove ownership and transition shape first.
## Acceptance For This Slice
The slice is good enough when:
1. sender identity is stable per replica
2. changed-address reassignment updates the right sender owner
3. multiple reconnect cycles do not lose recovery ownership
4. stale session does not survive epoch bump
5. the 4 Phase 13 V2-boundary tests have a clear path to become satisfiable
## Relationship To Existing Simulator
This slice should align with:
- `v2-acceptance-criteria.md`
- `v2-open-questions.md`
- `v1-v15-v2-comparison.md`
- `distsim` / `eventsim` behavior
The simulator remains the design oracle.
The first implementation slice should not contradict it.
+178 -45
View File
@@ -1,6 +1,6 @@
# V2 Phase Development Plan
Date: 2026-03-31
Date: 2026-04-02
Status: active
Purpose: define the execution-oriented phase plan after the current candidate-path work, with explicit module status and target phase ownership
@@ -24,37 +24,46 @@ This document is the planning bridge between:
Use these rules for all later phases:
1. one phase should close one meaningful product/engineering outcome
2. every phase must have a clear verification mechanism
3. phases should prefer real code/test/evidence over wording-only progress
4. later phases may reuse V1 engineering reality, but must not inherit V1 recovery semantics as truth
5. a phase is too small if it does not move the overall product-completion state clearly
2. every phase must have a clear delivery object and a clear closed-loop validation mechanism
3. every slice inside a phase should also name:
- what is delivered
- what loop is proven closed
- what reject shapes remain insufficient
4. phases should prefer real code/test/evidence over wording-only progress
5. later phases may reuse V1 engineering reality, but must not inherit V1 recovery semantics as truth
6. a phase is too small if it does not move the overall product-completion state clearly
## Current Baseline
Current accepted/closing path through `Phase 08`:
Current accepted path through `Phase 09`:
1. protocol/algo truth set is strong
2. engine recovery core is strong
3. real control delivery exists on the chosen path
4. real one-chain catch-up and rebuild closure exist on the chosen path
5. unified hardening validation exists on the chosen path
6. one bounded candidate statement exists for:
5. production-grade execution closure is accepted on the chosen path:
- real `TransferFullBase`
- real `TransferSnapshot`
- real `TruncateWAL`
- stronger live runtime ownership on the volume-server path
6. unified hardening validation exists on the chosen path
7. one bounded accepted path exists for:
- `RF=2`
- `sync_all`
- existing master / volume-server heartbeat path
Phase-accounting note:
1. this document assumes the current `Phase 08` path and bookkeeping are being closed consistently
2. if `Phase 08` bookkeeping is still open, read the candidate statement items above as the current accepted/closing path, not as a fully closed phase label
1. `Phase 08` is closed
2. `Phase 09` is also closed
3. this roadmap should now be read from the post-`Phase 09` state, not the post-`Phase 08` state
This means the next phases should focus mainly on:
1. production-grade execution completeness
2. stronger runtime ownership
3. stronger control-plane closure
4. later product-surface rebinding
5. production hardening
1. stronger control-plane closure
2. later product-surface rebinding
3. production hardening
4. bounded cleanup of low-severity residuals without reopening accepted execution semantics
## Phase Roadmap
@@ -89,12 +98,40 @@ Workload:
1. large
2. this is likely the single biggest remaining engineering phase
Status:
1. complete
2. accepted closeout exists in `../.private/phase/phase-09.md`
### Phase 10: Real Control-Plane Closure
Goal:
1. strengthen from accepted assignment-entry closure to fuller end-to-end control-plane closure
Why this is next:
1. `Phase 09` already closed the main backend execution gaps
2. the most important remaining product risk is no longer storage execution itself
3. it is now control-path completeness:
- heartbeat / gRPC delivery
- reassignment / result convergence
- cleaner local identity than transport-shaped `listenAddr`
4. `Phase 10` can also absorb bounded low-severity cleanup discovered during `Phase 09` if it is directly relevant to live control/runtime ownership
Current accepted progress inside `Phase 10`:
1. `P1` accepted:
- stable identity and control-truth closure on the chosen block assignment wire
2. `P2` accepted:
- reassignment/result convergence through the accepted volume-server-side chosen-path ingress
3. `P3` accepted:
- bounded repeated-assignment / idempotence cleanup on the chosen live path
4. `P4` accepted:
- master-driven heartbeat / gRPC control-loop closure on the chosen path
5. `Phase 10` is now closed:
- bounded end-to-end control-plane closure for the chosen path is accepted
Must prove:
1. heartbeat/gRPC-level delivery is real for the chosen path
@@ -112,6 +149,13 @@ Verification mechanism:
1. real failover/reassignment tests at the fuller control-plane level
2. identity/fencing assertions through the end-to-end path
Suggested first targets:
1. keep accepted `Phase 10` control-plane closure closed
2. start `Phase 11` with one bounded product-surface rebinding slice
3. prefer selected surface proofs over broad surface explosion
4. keep any residual control-path cleanup narrow; do not reopen accepted `Phase 10` closure casually
Workload:
1. medium-large
@@ -134,6 +178,18 @@ Candidate areas:
3. `NVMe`
4. `iSCSI`
Recommended first slice:
1. start with bounded `snapshot product path` rebinding
2. defer `CSI` and `NVMe` / `iSCSI` until one simpler product-visible surface is already accepted
Suggested slice order:
1. `P1` snapshot product-path rebinding
2. `P2` `CSI` rebinding
3. `P3` `NVMe` / `iSCSI` front-end rebinding
4. `P4` broader workflow closure such as snapshot restore/clone if still needed
Verification mechanism:
1. selected surface integration tests
@@ -168,28 +224,106 @@ Workload:
1. large
Recommended initial planning cut:
1. treat `P0` as hardening-plan freeze
2. first hardening slice should likely target restart / recovery disturbance before soak or perf
Current slice order:
1. `P0` hardening-plan freeze
2. `P1` restart / recovery disturbance hardening
3. `P2` soak / long-run stability hardening
4. `P3` diagnosability / blocker accounting / runbook hardening
5. `P4` performance floor / rollout-gate hardening
Slice delivery / closed-loop bar:
1. every `Phase 12` slice must end with:
- one bounded delivery object
- one bounded closed-loop validation object
- one explicit no-overclaim boundary
2. “tests exist” is not enough:
- the tests must close the loop from disturbance/input to visible accepted truth
3. “code changed” is also not required:
- a hardening slice may legitimately close by proving existing production code is already correct under the targeted disturbance class
Current status:
1. `P0` accepted:
- hardening object frozen as the accepted chosen path from `Phase 09` + `Phase 10` + `Phase 11`
- slice order frozen as `P1` / `P2` / `P3` / `P4`
- evidence ladder frozen as disturbance correctness, soak, diagnosability, then perf/rollout gates
2. `P1` accepted:
- acceptance object = correctness under restart/disturbance on the chosen path
- not soak, not diagnosability, not performance, not rollout readiness
3. `P2` accepted:
- acceptance object = bounded soak / long-run stability on the chosen path
- repeated-cycle coherence and bounded runtime-state hygiene are accepted inside a bounded test envelope
4. `P3` accepted:
- acceptance object = bounded diagnosability / blocker accounting / runbook hardening on the chosen path
- bounded operator-visible diagnosis surfaces and finite blocker accounting are accepted
5. `P4` active:
- acceptance object = bounded performance floor / rollout-gate hardening on the chosen path
- not broad rollout readiness beyond the named launch envelope
Current `P4` first delivery shape:
1. proof-first hardening slice with explicit measured floor and launch-gate artifacts
2. one bounded performance package:
- named workload envelope
- repeatable measurement harness
- explicit floor values
3. one explicit rollout-gate artifact:
- finite supported launch envelope
- cleared blockers/gates
- remaining blockers/gates
4. current evidence shape:
- measured floor values are tied to one named accepted workload envelope
- cost/resource trade-offs are explicit
- rollout discussion is bounded by an explicit finite gate package
5. current reuse boundary:
- accepted chosen-path runtime/control/product surfaces remain stable unless perf-floor work exposes a real bug or measurement gap
- focused benchmarks/tests and bounded launch-gate artifacts carry the main delivery burden
Closed-loop expectation for `P4` review:
1. one bounded workload envelope runs on the accepted chosen path
2. measured floor values and cost characteristics are explicit
3. launch claims map back to accepted prior slices plus the measured envelope
4. remaining rollout blockers are explicit and finite
5. claims remain bounded to measured floor / named launch envelope only
After `Phase 12`:
1. move to a productionization program, not more protocol-discovery phases
2. freeze production blockers and the supported launch envelope
3. run a limited internal pilot with incident-driven hardening
4. perform controlled rollout only after explicit launch-gate review
## Module Status Map
| Module area | Current status | Current owner phase | Next target phase | Notes |
| ------------------------------------------------------------- | ---------------------------- | ------------------------ | ----------------- | ------------------------------------------------------------------------------------------ |
| `sw-block/engine/replication` core FSM/orchestrator/driver | Strong | `Phase 08` accepted | `Phase 09` | Main later work is runtime/product execution closure, not new core semantics. |
| Engine executor real I/O boundary (`CatchUpIO` / `RebuildIO`) | Strong on chosen path | `Phase 08 P2/P3` | `Phase 09` | Keep the boundary; make underlying transfer/truncate production-grade. |
| `weed/storage/blockvol/v2bridge/control.go` | Strong on chosen path | `Phase 08 P1` | `Phase 10` | Next step is fuller control-plane closure, not new mapping semantics. |
| `weed/storage/blockvol/v2bridge/reader.go` | Strong | `Phase 08 P2/P3` | `Phase 09/10` | Keep comments/status aligned with candidate-path committed-truth decision. |
| `weed/storage/blockvol/v2bridge/pinner.go` | Strong | `Phase 08 P1/P3` | `Phase 09` | Retention safety proven; later work is product-grade execution under that safety. |
| `weed/storage/blockvol/v2bridge/executor.go` WAL scan | Strong | `Phase 08 P2` | `Phase 09` | Real scan is good; later work is real transfer/truncate completeness. |
| `v2bridge` `TransferFullBase` | Partial | `Phase 08 P2/P4` | `Phase 09` | Validation-grade now; target is real production streaming. |
| `v2bridge` `TransferSnapshot` | Partial | `Phase 08 P2/P4` | `Phase 09` | Validation-grade now; target is real image transfer. |
| `v2bridge` `TruncateWAL` | Weak/stub | `Phase 08 P4` bound | `Phase 09` | Must become a real executable path. |
| `weed/server/volume_server_block.go` V2 assignment intake | Medium-strong | `Phase 08 P1` | `Phase 09/10` | Real intake exists; later work is stronger runtime ownership + fuller control-plane proof. |
| `blockvol` WAL/flusher/checkpoint runtime | Reuse reality | Existing production code | `Phase 09` | Reuse implementation; do not let old semantics redefine V2 truth. |
| `blockvol` rebuild transport/server reality | Reuse with redesign boundary | Existing production code | `Phase 09` | Good area for production execution closure work. |
| local server identity (`localServerID`) | Partial | `Phase 08` bounded | `Phase 10` | Still transport-shaped; should become cleaner under control-plane closure. |
| Snapshot product path | Partial/reuse candidate | not core in `Phase 08` | `Phase 11` | Reuse implementation, but V2 semantics own placement and claims. |
| `CSI` integration | Deferred reuse candidate | not core in `Phase 08` | `Phase 11` | Product surface, not next core closure target. |
| `NVMe` / `iSCSI` front-ends | Deferred reuse candidate | not core in `Phase 08` | `Phase 11` | Rebind after backend path is stronger. |
| Testrunner / infra / metrics | Strong support layer | existing | `Phase 10-12` | Reuse to validate later control-plane and hardening phases. |
| `sw-block/engine/replication` core FSM/orchestrator/driver | Strong | `Phase 09` accepted | `Phase 10-12` | Main later work is control-plane/runtime integration and hardening, not new core semantics. |
| Engine executor real I/O boundary (`CatchUpIO` / `RebuildIO`) | Strong on chosen path | `Phase 09` accepted | `Phase 10/12` | Keep the boundary stable; later work is orchestration/control proof and hardening. |
| `weed/storage/blockvol/v2bridge/control.go` | Strong on chosen path | `Phase 08/09/10` accepted | `Phase 12` | Chosen-path control mapping is accepted; later work is hardening, not new mapping semantics. |
| `weed/storage/blockvol/v2bridge/reader.go` | Strong | `Phase 09` accepted | `Phase 12` | Mostly stable; later work is verification/hardening, not new semantics. |
| `weed/storage/blockvol/v2bridge/pinner.go` | Strong | `Phase 09` accepted | `Phase 12` | Retention safety proven; later work is hardening under disturbance. |
| `weed/storage/blockvol/v2bridge/executor.go` WAL scan | Strong | `Phase 09` accepted | `Phase 12` | Real execution path closed on chosen path; later work is hardening. |
| `v2bridge` `TransferFullBase` | Strong on chosen path | `Phase 09 P1` accepted | `Phase 12` | Execution closure accepted; do not reopen casually. |
| `v2bridge` `TransferSnapshot` | Strong on chosen path | `Phase 09 P2` accepted | `Phase 12` | Execution closure accepted; do not reopen casually. |
| `v2bridge` `TruncateWAL` | Strong on chosen path | `Phase 09 P3` accepted | `Phase 12` | Narrow Option A contract accepted; later work is hardening/planning improvement. |
| `weed/server/volume_server_block.go` V2 assignment intake | Strong on chosen path | `Phase 10 P4` accepted | `Phase 11/12` | VS-side ingress, convergence, idempotence, and bounded master-driven closure are accepted; later work is product-surface integration and hardening. |
| `weed/server/block_recovery.go` live runtime ownership | Strong on chosen path | `Phase 09/10` accepted | `Phase 11/12` | Serialized ownership and bounded control-plane integration are accepted; later work is product-surface integration and hardening. |
| `blockvol` WAL/flusher/checkpoint runtime | Reuse reality | Existing production code | `Phase 12` | Reuse implementation; later work is disturbance/restart hardening. |
| `blockvol` rebuild transport/server reality | Reuse with redesign boundary | Existing production code | `Phase 12` | Bounded chosen-path integration is accepted; later work is hardening under disturbance. |
| local server identity (`localServerID`) | Strong on chosen path | `Phase 10 P1` accepted | `Phase 12` | Canonical `volumeServerId` now backs chosen-path local identity; later work is hardening only. |
| Snapshot product path | Strong on chosen path | `Phase 11` accepted | `Phase 12` | Product-visible snapshot create/list/delete closure and restore workflow closure are accepted; later work is hardening, not rebinding. |
| `CSI` integration | Strong on chosen path | `Phase 11` accepted | `Phase 12` | Bounded controller/node lifecycle rebinding accepted; later work is hardening. |
| `NVMe` / `iSCSI` front-ends | Strong on chosen path | `Phase 11` accepted | `Phase 12` | Publication/address truth rebinding accepted; later work is runtime/perf hardening. |
| Testrunner / infra / metrics | Strong support layer | existing | `Phase 11-12` | Reuse to validate later product-surface and hardening phases. |
## Completion-State Targets
@@ -199,7 +333,7 @@ Use these rough targets to judge whether a phase is moving the product meaningfu
| Phase | Expected completion move |
| ---------- | ----------------------------------------------------------------------------- |
| `Phase 09` | from validation-grade backend execution to production-grade backend execution |
| `Phase 09` | from validation-grade backend execution to accepted execution closure on the chosen path |
| `Phase 10` | from bounded control-entry proof to stronger end-to-end control-plane closure |
| `Phase 11` | from backend-ready path to selected product-surface readiness |
| `Phase 12` | from candidate-safe to production-safe |
@@ -209,23 +343,22 @@ Use these rough targets to judge whether a phase is moving the product meaningfu
If the goal is to maximize product completion efficiently, the recommended order is:
1. finish `Phase 08` bookkeeping cleanly
2. `Phase 09` production execution closure
3. `Phase 10` real control-plane closure
4. `Phase 11` product surface rebinding
5. `Phase 12` production hardening
1. keep `Phase 09` closed and do not reopen accepted execution semantics casually
2. keep `Phase 10` closed and do not reopen accepted bounded control-plane closure casually
3. move next to `Phase 11` product surface rebinding
4. then `Phase 12` production hardening
The most important near-term engineering weight should go to `Phase 09`.
The most important near-term engineering weight should now go to `Phase 12`.
## Short Summary
The V2 line already has a real bounded candidate path.
The next development plan should treat later work as product-completion phases, not more protocol discovery.
The V2 line now has accepted execution closure on one bounded chosen path.
The next development plan should treat later work as control/product completion phases, not more protocol discovery.
The main heavy engineering work still ahead is:
1. production-grade execution
2. stronger runtime/control closure
3. later product-surface rebinding
4. production hardening
1. stronger end-to-end control-plane closure
2. later product-surface rebinding
3. production hardening
4. bounded cleanup of residual operational rough edges without reopening accepted semantics
@@ -23,7 +23,7 @@ This document is the product-completion view.
It complements:
1. `v2-protocol-truths.md` for accepted semantics
2. `v2-production-roadmap.md` for the older roadmap ladder
2. `../docs/archive/design/v2-production-roadmap.md` for the older roadmap ladder
3. `../.private/phase/phase-08.md` for current phase contract
## Current Position
@@ -55,13 +55,13 @@ These levels are rough engineering estimates, not exact percentages.
| Simulator / prototype evidence | Strong | Main failure classes and protocol boundaries are already well-exercised. |
| Engine recovery core | Strong | Sender/session/orchestrator/driver/executor are substantially implemented. |
| Weed bridge integration | Strong | Reader / pinner / control / executor are real and tested on the chosen path. |
| Integrated candidate path | Medium-strong | `P1` + `P2` + `P3` prove one bounded candidate path. |
| Runtime ownership inside live server loop | Medium | Real intake exists, but full product-grade recovery ownership is not yet fully closed. |
| Production-grade data transfer | Medium-weak | Validation-grade transfer exists; full production byte streaming is still incomplete. |
| Truncation / replica-ahead execution | Weak | Detection exists; full execution path is still incomplete. |
| End-to-end control-plane closure | Medium | `ProcessAssignments()` is real; full heartbeat/gRPC proof is still bounded. |
| Product surfaces (`CSI`, `NVMe`, `iSCSI`, snapshot productization) | Partial | Mostly reuse candidates, but not the current core closure target. |
| Production hardening / ops | Partial | Candidate-level evidence exists; production-grade hardening is still ahead. |
| Integrated candidate path | Strong on chosen path | Backend, control-plane, and selected product surfaces are now accepted on one bounded chosen path. |
| Runtime ownership inside live server loop | Strong on chosen path | Accepted chosen-path execution/control ownership exists; later work is restart/disturbance hardening, not first-closure rebinding. |
| Production-grade data transfer | Strong on chosen path | `TransferFullBase` and `TransferSnapshot` execution closure are accepted on the chosen path; later work is hardening. |
| Truncation / replica-ahead execution | Strong on chosen path | `TruncateWAL` narrow chosen-path closure is accepted; later work is hardening/planning improvement. |
| End-to-end control-plane closure | Strong on chosen path | `Phase 10` accepted bounded end-to-end control-path closure on the chosen path. |
| Product surfaces (`CSI`, `NVMe`, `iSCSI`, snapshot productization) | Strong on chosen path | `Phase 11` accepted bounded product-surface rebinding on the chosen path. |
| Production hardening / ops | Partial | `Phase 12` is now the next active stage. |
## Reuse Strategy
@@ -103,14 +103,14 @@ These can reuse implementation, but their semantic placement must remain V2-owne
| Module area | Current treatment | Near-term plan |
|-------------|-------------------|----------------|
| Recovery engine | V2-owned | Continue closing runtime/product path under accepted semantics. |
| `v2bridge` | V2 boundary adapter | Keep expanding real I/O/runtime closure without leaking policy downward. |
| Recovery engine | V2-owned | Keep semantics stable and focus next on restart/disturbance hardening. |
| `v2bridge` | V2 boundary adapter | Chosen-path execution closure is accepted; later work is hardening without leaking policy downward. |
| `blockvol` WAL/flusher/runtime | Reuse reality | Reuse implementation, but do not let V1 replication semantics redefine V2 truth. |
| Snapshot capability | Reuse implementation, V2-owned semantics | Do not make this a main near-term phase goal until core execution/runtime closure is stronger. |
| `CSI` | Later product surface | Rebind after the V2-backed candidate path is stable enough. |
| `NVMe` / `iSCSI` | Later product surface | Reuse as front-end adapters once the backend candidate path is stronger. |
| Rebuild server / transfer mechanisms | Reuse with redesign boundary | Good candidate for later production execution closure work. |
| Control plane | Reuse existing path | Continue from `ProcessAssignments()` toward stronger end-to-end closure. |
| Snapshot capability | Reuse implementation, V2-owned semantics | Rebinding is accepted on the chosen path; later work is hardening. |
| `CSI` | Accepted product surface on chosen path | Keep the bounded contract stable and harden under disturbance. |
| `NVMe` / `iSCSI` | Accepted front-end adapters on chosen path | Keep publication/address truth stable and harden runtime behavior. |
| Rebuild server / transfer mechanisms | Reuse with redesign boundary | Chosen-path execution closure is accepted; later work is disturbance hardening. |
| Control plane | Reuse existing path | Bounded chosen-path closure is accepted; later work is restart/disturbance hardening. |
## What The Candidate Path Already Proves
@@ -120,8 +120,8 @@ For the chosen `RF=2 sync_all` path, the project can already claim:
2. stale epoch/session fencing through the integrated path
3. real catch-up one-chain closure on the chosen path
4. rebuild control/execution chain proven on the chosen path
- validation-grade execution closure
- not yet production-grade block/image streaming
- chosen-path execution closure accepted in `Phase 09`
- later work is restart/disturbance/perf hardening rather than first-path closure
5. replay of accepted failure classes on the unified live path
6. one real failover / reassignment cycle
7. one true simultaneous-overlap retention safety proof
@@ -131,25 +131,16 @@ For the chosen `RF=2 sync_all` path, the project can already claim:
## What Is Still Missing For Product Completion
The biggest remaining product-completion gaps are:
The biggest remaining product-completion gaps are now production-hardening gaps:
1. production-grade rebuild data transfer
- `TransferFullBase` must become real streaming, not only accessibility validation
- `TransferSnapshot` must become real image streaming, not only checkpoint validation
2. replica-ahead physical correction
- `TruncateWAL` must stop being a stub
3. stronger live runtime ownership
- the V2 recovery driver/executors should become a more complete live runtime path, not only a bounded hardening path
4. stronger control-plane closure
- current proof reaches `ProcessAssignments()`
- full heartbeat/gRPC-level closure is still bounded
5. product-surface rebinding
- `CSI`
- `NVMe`
- `iSCSI`
- snapshot product path
6. production hardening
- restart / soak / repeated disturbance / diagnosis quality
1. restart / recovery disturbance hardening
- accepted chosen-path behavior must remain correct under restart, rejoin, and repeated failover
2. long-run / soak stability
- accepted behavior must remain stable across repeated cycles and longer-running operation
3. operational diagnosability
- blockers, symptoms, and operator-visible diagnosis quality must be explicit
4. performance floor and rollout gates
- production claims need bounded floor numbers and explicit rollout criteria
## Recommended Completion Roadmap
@@ -186,6 +177,10 @@ Target:
1. strengthen from accepted assignment-entry closure to fuller end-to-end control-path closure
Status:
1. accepted and closed on the chosen path
Main work:
1. heartbeat/gRPC-level proof
@@ -198,6 +193,10 @@ Target:
1. connect product-facing surfaces to the V2-backed block path
Status:
1. accepted and closed on the chosen path
Candidate areas:
1. snapshot product path
@@ -205,6 +204,18 @@ Candidate areas:
3. `NVMe`
4. `iSCSI`
Recommended first cut:
1. snapshot product path first
2. `CSI` and `NVMe` / `iSCSI` after one bounded product-visible surface is already accepted
Suggested order inside `Phase 11`:
1. `P1` snapshot product path
2. `P2` `CSI`
3. `P3` `NVMe` / `iSCSI`
4. `P4` broader residual workflow closure if still required
Rule:
Do this after the backend engine/runtime path is strong enough, not before.
@@ -215,6 +226,10 @@ Target:
1. move from candidate-safe to production-safe
Status:
1. next active stage
Main work:
1. soak / restart / repeated failover
@@ -255,13 +270,12 @@ In short:
## Short Summary
The V2 line is already beyond "algorithm only".
It has a real bounded candidate path.
It has an accepted bounded chosen path through backend, control-plane, and selected product surfaces.
But the remaining work is still substantial, and it is mostly engineering work:
1. production-grade execution
2. stronger runtime/control closure
3. product-surface rebinding
4. production hardening
1. production hardening under restart / disturbance
2. long-run stability and diagnosability
3. performance floor and rollout gating
That is the practical path from the current candidate-safe engine to a production-ready block product.
-199
View File
@@ -1,199 +0,0 @@
# V2 Production Roadmap
Date: 2026-03-30
Status: active
Purpose: define the path from the accepted V2 engine core to a production candidate
## Current Position
Completed:
1. design / FSM closure
2. simulator / protocol validation
3. prototype closure
4. evidence hardening
5. engine core slices:
- Slice 1 ownership core
- Slice 2 recovery execution core
- Slice 3 data / recoverability core
- Slice 4 integration closure
Current stage:
- entering broader engine implementation
This means the main risk is no longer:
- whether the V2 idea stands up
The main risk is:
- whether the accepted engine core can be turned into a real system without reintroducing V1/V1.5 structure and semantics
## Roadmap Summary
1. Phase 06: broader engine implementation stage
2. Phase 07: real-system integration / product-path decision
3. Phase 08: pre-production hardening
4. Phase 09: performance / scale / soak validation
5. Phase 10: production candidate and rollout gate
## Phase 06
### Goal
Connect the accepted engine core to:
1. real control truth
2. real storage truth
3. explicit engine execution steps
### Outputs
1. control-plane adapter into the engine core
2. storage/base/recoverability adapters
3. explicit execution-driver model where synchronous helpers are no longer sufficient
4. validation against selected real failure classes
### Gate
At the end of Phase 06, the project should be able to say:
- the engine core can live inside a real system shape
## Phase 07
### Goal
Move from engine-local correctness to a real runnable subsystem.
### Outputs
1. service-style runnable engine slice
2. integration with real control and storage surfaces
3. crash/failover/restart integration tests
4. decision on the first viable product path
### Gate
At the end of Phase 07, the project should be able to say:
- the engine can run as a real subsystem, not only as an isolated core
## Phase 08
### Goal
Turn correctness into operational safety.
### Outputs
1. observability hardening
2. operator/debug flows
3. recovery/runbook procedures
4. config surface cleanup
5. realistic durability/restart validation
### Gate
At the end of Phase 08, the project should be able to say:
- operators can run, debug, and recover the system safely
## Phase 09
### Goal
Prove viability under load and over time.
### Outputs
1. throughput / latency baselines
2. rebuild / catch-up cost characterization
3. steady-state overhead measurement
4. soak testing
5. scale and failure-under-load validation
### Gate
At the end of Phase 09, the project should be able to say:
- the design is not only correct, but viable at useful scale and duration
## Phase 10
### Goal
Produce a controlled production candidate.
### Outputs
1. feature-gated production candidate
2. rollback strategy
3. migration/coexistence plan with V1
4. staged rollout plan
5. production acceptance checklist
### Gate
At the end of Phase 10, the project should be able to say:
- the system is ready for a controlled production rollout
## Cross-Phase Rules
### Rule 1: Do not reopen protocol shape casually
The accepted core should remain stable unless new implementation evidence forces a change.
### Rule 2: Use V1 as validation source, not design template
Use:
1. `learn/projects/sw-block/`
2. `weed/storage/block*`
for:
1. failure gates
2. constraints
3. integration references
Do not use them as the default V2 architecture template.
### Rule 3: Keep `CatchUp` narrow
Do not let later implementation phases re-expand `CatchUp` into a broad, optimistic, long-lived recovery mode.
### Rule 4: Keep evidence quality ahead of object growth
New work should preferentially improve:
1. traceability
2. diagnosability
3. real-failure validation
4. operational confidence
not simply add new objects, states, or mechanisms.
## Production Readiness Ladder
The project should move through this ladder explicitly:
1. proof-of-design
2. proof-of-engine-shape
3. proof-of-runnable-engine-stage
4. proof-of-operable-system
5. proof-of-viable-production-candidate
Current ladder position:
- between `2` and `3`
- engine core accepted; broader runnable engine stage underway
## Next Documents To Maintain
1. `sw-block/.private/phase/phase-06.md`
2. `sw-block/design/v2-engine-readiness-review.md`
3. `sw-block/design/v2-engine-slicing-plan.md`
4. this roadmap
@@ -0,0 +1,549 @@
# V2 协议闭环图
日期:2026-04-02
状态:active
读者:架构设计、实现负责人、tester、reviewer
## 1. 文档目标
这份文档不是单纯介绍算法。
它的目标是把 `V2` 在当前 chosen path 上的协议结构整理成一张“闭环图”,回答下面几个问题:
1. `V2` 当前有哪些正式状态对象
2. 这些对象之间有哪些关键事件和迁移
3. `V2` 当前维持哪些语义约束
4. 这些约束分别由哪些证明义务支撑
5. 这些证明义务已经映射到哪些 phase / slice / 实现点 / 测试点
这份文档想说明的是:
- `V2` 不是一组散乱 patch
- 而是在明确边界内逐步建立的协议闭环
这里的“闭环”是有范围的。
当前默认边界仍然是:
1. `RF=2`
2. `sync_all`
3. 现有 master / volume-server heartbeat path
4. `blockvol` 作为当前执行 backend
所以本文不宣称“所有模式全部完备”。
它宣称的是:
- 在 chosen path 上,`V2` 已经建立了一个结构化、可验证、可扩展的协议闭环框架
### 1.1 五层模型总览(Mermaid
自上而下:从代码与运行时,到语义、证明与工程落地(读图:下层是承载,上层是约束与 close)。
```mermaid
flowchart TB
subgraph L1["Layer 1 物理实现"]
I1[master / VS / gRPC / blockvol]
end
subgraph L2["Layer 2 状态机"]
S1[控制 / 恢复 / 数据边界 / 上报]
end
subgraph L3["Layer 3 语义约束"]
C1[ownership]
C2[identity]
C3[boundary]
C4[convergence]
C5[idempotence]
end
subgraph L4["Layer 4 证明义务"]
P1[约束 → 最小证据包]
end
subgraph L5["Layer 5 工程映射"]
E1[实现点]
E2[观测点]
E3[测试 / one-chain]
E4[phase / slice]
end
L1 --> L2
L2 --> L3
L3 --> L4
L4 --> L5
```
## 2. V2 的五层模型
## Layer 1:物理实现层
这一层列出当前承载 `V2` truth 的真实工程对象。
### 2.1 主要实现对象
1. master 侧:
- `master_grpc_server.go`
- `master_grpc_server_block.go`
- `master_block_failover.go`
- `BlockAssignmentQueue`
2. volume server 侧:
- `volume_grpc_client_to_master.go`
- `volume_server_block.go`
- `CollectBlockVolumeHeartbeat()`
3. V2 控制 / 恢复桥接:
- `v2bridge/control.go`
- `RecoveryManager`
4. V2 执行层:
- `CatchUpExecutor`
- `RebuildExecutor`
- `v2bridge/executor.go`
5. backend 执行层:
- `blockvol`
- `WAL`
- `snapshot`
- `flusher`
### 2.2 这一层的意义
这一层回答的是:
1. 协议最终在哪些真实代码路径里运行
2. 哪些对象是真正的 authority carrier
3. 哪些地方是观测点
但它本身不定义协议语义。
## Layer 2:状态机层
这一层定义 `V2` 的正式状态对象与关键事件。
### 2.3 控制面状态对象
| 对象 | 作用 | 典型字段 |
|------|------|----------|
| Assignment truth | master/VS 之间的控制意图 | `Path`, `Epoch`, `Role`, replica identity, replica addrs |
| Stable identity | 防止地址形态混淆 authority | `ServerID`, local server identity |
| Role truth | 定义当前是 primary / replica / rebuilding | `Role`, `LeaseTtlMs` |
### 2.4 恢复状态对象
| 对象 | 作用 | 典型状态 |
|------|------|----------|
| Sender | V2 的恢复主体 | `in_sync`, `catchup`, `needs_rebuild`, disconnected |
| Session | 一次恢复意图的 authority 载体 | created, superseded, removed |
| Recovery task | live runtime owner | running, draining, done |
### 2.5 数据边界状态对象
| 对象 | 作用 |
|------|------|
| `CommittedLSN` | 当前对外可承诺、可用于 recovery 目标的边界 |
| `CheckpointLSN` | 稳定物化边界 |
| `WALHeadLSN` | 当前 WAL 最高边界 |
| `receivedLSN` | receiver 当前已连续接收边界 |
| `targetLSN` | recovery plan 的目标边界 |
| `achievedLSN` | 实际 rebuild / transfer 达到的边界 |
| `snapshotBaseLSN` | snapshot 所代表的基线边界 |
### 2.6 对外可见状态对象
| 对象 | 作用 |
|------|------|
| Heartbeat truth | VS 对 master / 外部报告的 block 状态 |
| Reported replica addr | 当前 externally visible replica truth |
| Reported role / epoch | 当前 externally visible control truth |
### 2.7 关键事件
当前 chosen path 上最关键的事件包括:
1. `AssignmentDelivered`
2. `EpochBumped`
3. `SessionCreated`
4. `SessionSuperseded`
5. `SessionRemoved`
6. `CatchUpPlanned`
7. `CatchUpCompleted`
8. `RebuildStarted`
9. `RebuildCommitted`
10. `SnapshotTransferred`
11. `TruncationEscalated`
12. `RepeatedAssignmentDelivered`
13. `HeartbeatCollected`
### 2.8 Truth 流水线(Mermaid
`V2` 的主要工作,是保证这条链上的各层 truth 在关键场景下**不长期分裂**no persistent split truth)。
```mermaid
flowchart TD
MT[master truth\nassignment 意图]
AD[assignment delivery\nproto / heartbeat / gRPC]
VI[VS ingest truth\nAssignmentsFromProto → ProcessAssignments]
ES[engine / session truth\norchestrator / sender]
RO[runtime ownership truth\nRecoveryManager / tasks]
DB[data-boundary truth\nLSN / checkpoint / achieved]
HB[heartbeat / reporting truth\nCollectBlockVolumeHeartbeat]
MT --> AD
AD --> VI
VI --> ES
ES --> RO
RO --> DB
DB --> HB
```
文字版(与上图一致):
```text
master truth → delivery → VS ingest → engine/session → runtime owner → data boundary → heartbeat/reporting
```
### 2.9 V1 与 V2:哪些“未显式状态”会让结果不确定
这一节用**同一套 truth 流水线**来对比:不是比较“代码行数”,而是比较**协议层是否显式持有状态**。
下列概括针对**常见 V1 工程形态**与 **V2 chosen-path 显式化** 的对比,用于直觉理解;具体实现细节以代码与 phase 证据为准。
| Truth 环节 | V1 常见风险(状态隐含时) | V2 显式化后更可回答的问题 |
|-------------|---------------------------|---------------------------|
| master truth | 仅地址/临时约定,failover 后身份易混淆 | stable `ServerID``epoch` 是否一致 |
| delivery | 重复投递、重复副作用 | 同 truth 是否幂等(`P3` |
| VS ingest | 隐式 fallback 成地址身份 | 是否 fail-closed`P1` |
| engine/session | 旧 session 与新 session 边界不清 | supersede 后旧 authority 是否失效 |
| runtime owner | goroutine 级“好像还在跑” | 是否 serialized drain`Phase 09 P4` |
| data boundary | “完成”与本地 LSN 不一致 | `achievedLSN` 与 checkpoint/receiver 是否收敛(`Phase 09 P1` |
| heartbeat | 上报与控制意图漂移 | 与 assignment 是否一致(`Phase 10 P2` |
不确定性的典型结构:
```mermaid
flowchart LR
subgraph V1style["状态未显式时"]
U1[身份弱]
U2[owner 弱]
U3[边界弱]
end
subgraph out["表现"]
X1[时序敏感]
X2[重试改变结果]
X3[split truth]
end
V1style --> out
```
**一句话**`V1` 在很多路径上仍然可用,但上述环节一旦缺少显式对象,系统在边界场景下会更容易出现**结果不确定**(依赖时序、依赖重试、或内部 truth 与外部报告不一致)。`V2` 的方向是把它们变成**可命名状态 + 可证义务**。
更细的“方法层”叙述见:`v2-semantic-methodology.zh.md` 第 10 节。
## Layer 3:语义约束层
这一层定义协议必须长期维持的核心约束。
### 3.1 Ownership constraints
目标:
- 明确当前谁拥有 recovery authority
当前约束:
1. 同一 replica 不能同时存在两个合法 live owner
2. supersede 后旧 owner 必须失效
3. shutdown 后不得残留 live owner
4. 旧 session 不能在新 truth 下继续提交有效结果
### 3.2 Identity constraints
目标:
- 不用 transport address 猜身份
当前约束:
1. stable `ServerID` 是 control truth 的正式部分
2. `ReplicaID` 应从 `<path>/<ServerID>` 构造
3. 缺失 stable ID 时 chosen path 应 fail closed
4. local server identity 应使用 canonical `volumeServerId`
### 3.3 Boundary safety constraints
目标:
- rebuild / snapshot / truncate 的边界必须物理成立
当前约束:
1. full-base rebuild 的完成边界必须显式暴露并与 runtime 对齐
2. snapshot rebuild 的边界必须由 manifest / hash / base LSN 约束
3. truncation 只有在安全条件成立时才允许本地修复
4. 做不到安全修复时必须 escalate,而不是伪装成功
### 3.4 Convergence constraints
目标:
- 不允许长期 split truth
当前约束:
1. assignment truth、runtime truth、heartbeat truth 必须收敛
2. reassignment 后旧 truth 不应继续对外可见
3. `achievedLSN`、checkpoint、receiver progress 在 accepted contract 下应收敛
4. control truth 变化后,旧 runtime residue 不应残留
### 3.5 Idempotence constraints
目标:
- 同样 truth 重复出现时,不应不断产生额外副作用
当前约束:
1. repeated unchanged assignment 不应重复触发 recovery
2. repeated unchanged delivery 不应重复 relisten / restart
3. repeated delivery 不应破坏已收敛的 truth
## Layer 4:证明义务层
这一层把上述语义约束变成“必须被证明”的义务。
## 4. 当前主要证明义务地图
| 语义约束 | 证明义务 | 对应 phase / slice | 当前状态 |
|----------|----------|--------------------|----------|
| ownership | old owner 被 drainreplacement 前不得重叠 | `Phase 09 P4` | accepted |
| ownership | shutdown 后 active task = 0 | `Phase 09 P4` | accepted |
| identity | stable ID survives proto/decode/ingress | `Phase 10 P1` | accepted |
| identity | missing stable ID fails closed | `Phase 10 P1` | accepted |
| convergence | reassignment 后 old sender removed / new sender present | `Phase 10 P2` | accepted |
| convergence | heartbeat truth 收敛到新 replica truth | `Phase 10 P2` | accepted |
| convergence | stale runtime residue removed | `Phase 10 P2` | accepted |
| idempotence | repeated unchanged assignment 不增加 V2 side effect | `Phase 10 P3` | accepted |
| idempotence | repeated unchanged assignment 不重复 V1 relisten/setup | `Phase 10 P3` | accepted |
| boundary safety | full-base achieved boundary 与 runtime/engine accounting 对齐 | `Phase 09 P1` | accepted |
| boundary safety | snapshot boundary exactness and fail-closed | `Phase 09 P2` | accepted |
| boundary safety | unsafe truncate escalates to rebuild | `Phase 09 P3` | accepted |
| control-loop closure | master-originated truth through fuller heartbeat/gRPC loop | `Phase 10 P4` | accepted |
### 4.1 证明义务不是穷举
这里的义务不是说:
- 所有状态空间已经被穷举证明
而是说:
- 在 chosen path 上,关键协议约束都被映射成了具体、可检查、可 close 的证明义务
这比“随机多跑一些 case”更强,因为它更明确。
## 5. 已经关闭的主要协议闭环
为了更直观,可以把当前 accepted 闭环按主题来看。
### 5.1 Recovery ownership 闭环
已关闭:
1. live recovery owner 的 start / cancel / replace / drain
2. stale owner removal
3. no overlap replacement
主要来源:
- `Phase 09 P4`
- `Phase 10 P2`
### 5.2 Identity/control truth 闭环
已关闭:
1. stable ID on wire
2. local canonical identity
3. `ReplicaID` 不再依赖 address fallback
4. missing ID fail closed
主要来源:
- `Phase 10 P1`
### 5.3 Boundary safety 闭环
已关闭:
1. full-base rebuild achieved boundary closure
2. snapshot exact-boundary closure
3. truncation safe/unsafe split and escalate
主要来源:
- `Phase 09 P1`
- `Phase 09 P2`
- `Phase 09 P3`
### 5.4 Reassignment / convergence 闭环
已关闭:
1. reassignment 后 old sender removed
2. new sender created
3. heartbeat truth updated
4. stale runtime residue removed
主要来源:
- `Phase 10 P2`
### 5.5 Repeated unchanged truth 闭环
已关闭:
1. repeated unchanged assignment is idempotent
2. no duplicate orchestrator/recovery side effects
3. no duplicate relisten/setup side effects
主要来源:
- `Phase 10 P3`
## 6. 当前仍然开放的闭环
当前主要剩余的 open item 是:
### 6.1 Master-driven control-loop closure
问题不是 VS 本地处理是否成立,而是:
1. master 产生的 truth 是否经过真实 heartbeat / gRPC loop 到达 VS
2. 到达后是否仍保持 accepted identity / convergence / idempotence
3. 对外报告是否仍与同一 master-originated truth 一致
当前对应 slice
- `Phase 10 P4`
这是当前协议闭环图上最大的未关闭项。
## Layer 5:工程映射层
这一层把抽象对象映射回真实实现、观测点和测试。
## 7. 语义对象到实现点的映射
| 语义对象 / 约束 | 主要实现点 | 主要观测点 | 主要测试/证据 |
|-----------------|-----------|-----------|--------------|
| Stable identity | `master.proto`, `block_heartbeat_proto.go`, `v2bridge/control.go` | sender registry, local server ID | `qa_block_identity_test.go` |
| Assignment ingress | `AssignmentsFromProto()`, `ProcessAssignments()` | engine sender / role application | `qa_block_identity_test.go`, `qa_block_convergence_test.go` |
| Recovery ownership | `block_recovery.go` | task map, done channel, sender state | `block_recovery_test.go` |
| Reassignment convergence | `ProcessAssignments()`, `CollectBlockVolumeHeartbeat()` | sender registry, runtime task map, heartbeat output | `qa_block_convergence_test.go` |
| Repeated-assignment idempotence | `volume_server_block.go` assignment tracking | V2 event log, repl state, heartbeat output | `qa_block_idempotence_test.go` |
| Full-base boundary closure | `v2bridge/executor.go`, `blockvol` rebuild install path | checkpoint, head, achieved progress | `transfer_test.go` / one-chain tests |
| Snapshot boundary closure | snapshot export/import path | base LSN, hash, post-install convergence | snapshot rebuild tests |
| Truncation safety | `TruncateToLSN()`, executor escalation path | sender state, local LSN state | truncation tests |
## 8. Phase 到语义约束的映射
### 8.1 `Phase 09`
`Phase 09` 的主题是:
- backend execution closure
它主要关掉的是:
1. boundary safety
2. recovery execution realism
3. live runtime ownership
### 8.2 `Phase 10`
`Phase 10` 的主题是:
- control-plane closure
它主要关掉的是:
1. identity truth
2. reassignment convergence
3. idempotence
4. fuller master-driven control-loop proof
## 9. 一个简化的“从语义到开发”流程
如果以后再开新 slice,可以用下面这个框架:
### 9.1 先定义状态对象
例如:
1. assignment truth
2. runtime owner
3. heartbeat truth
### 9.2 再定义关键事件
例如:
1. epoch bump
2. repeated delivery
3. crash / restart
### 9.3 再定义要关闭的语义约束
例如:
1. no split truth
2. fail closed
3. idempotence
### 9.4 再写证明义务
例如:
1. old owner drained
2. unsafe path escalates
3. repeated unchanged truth does not create side effects
### 9.5 最后才写实现和测试
例如:
1. 改哪个入口点
2. 观测哪个 runtime / heartbeat / event log
3. 用哪种 one-chain proof close
## 10. 这份闭环图想表达什么
它想表达的不是:
- `V2` 所有模式全部完成
它想表达的是:
1. `V2` 已经有正式状态对象,而不是只靠代码隐含状态
2. `V2` 已经有显式语义约束,而不是主要靠补 bug 建立正确性
3. `V2` 已经把关键 correctness 问题写成证明义务,而不是只靠随机 case
4. `V2` 已经把这些义务映射回 phase、实现点和测试点
5. 在 chosen path 上,协议闭环已经大体成形,只剩有限 open item
## 11. 推荐和哪些文档一起阅读
建议按下面顺序阅读:
1. `v2-semantic-methodology.zh.md`
2. `v2-detailed-algorithm.zh.md`
3. `v2-protocol-closure-map.zh.md`
4. `v2-product-completion-overview.md`
5. `v2-phase-development-plan.md`
这样可以依次看到:
1. 方法
2. 算法
3. 闭环地图
4. 产品完成度
5. phase 推进计划
+55
View File
@@ -372,6 +372,57 @@ Evidence anchor:
- strong in Phase 07/08 direction
- should remain active in later implementation phases
### T16. Full-base rebuild completes against an explicit achieved boundary
Short form:
- `full_base` rebuild requires `AchievedLSN >= TargetLSN`
- engine and local runtime must converge to the same achieved boundary
Meaning:
- the engine plans a frozen minimum target `TargetLSN`
- the backend may produce an actual rebuilt boundary `AchievedLSN`
- exact-target extent equality is not required on a mutable-extent backend
- after install, `checkpoint`, `nextLSN`, receiver progress, flusher checkpoint, and engine-visible rebuild completion must all align to the same `AchievedLSN`
Prevents:
- local runtime truth advancing beyond engine/accounting truth
- rebuild completion at one boundary while storage/runtime state reflects another
- treating "at least target" as safe without making the newer achieved boundary explicit
Evidence anchor:
- strengthened by `Phase 09` backend execution closure work
- phase-level decision exists
- real-system proof still depends on executor/runtime alignment
### T17. Extent/WAL recovery split must be fixed before replay begins
Short form:
- `extent copy + WAL replay` is only correct if the split boundary is explicit and gap-free
Meaning:
- extent data must represent a known recovery boundary
- WAL replay must start from the matching next boundary
- if unflushed writes could otherwise fall between extent and replay, the backend must first fix the split boundary
- snapshot/CoW/bitmap export paths follow the same rule: transport must read from a stable recovery view, not a mutating mixed state
Prevents:
- missing writes between extent copy and replay
- point-in-time export that mixes two different recovery views
- rebuild correctness depending on timing accidents instead of an explicit boundary contract
Evidence anchor:
- present in rebuild correctness reasoning from V1
- strengthened by `Phase 09` full-base execution work
- future snapshot/bitmap-based paths must preserve it explicitly
## Current Strongest Evidence By Layer
| Layer | Main value |
@@ -434,6 +485,8 @@ Later phases must not regress these:
6. trusted-base choice must remain explicit and causal
7. service glue must not silently re-decide recovery policy
8. reuse reality, but do not inherit old semantics as V2 truth
9. full-base rebuild must converge to one explicit achieved boundary
10. extent/WAL recovery split must be fixed before replay
## Review Rule
@@ -517,6 +570,8 @@ Primary alignment focus:
- T10 real storage truth into engine decisions
- T11 trusted-base proof remains explicit through service glue
- T14 `blockvol` executes I/O but does not own recovery policy
- T16 full-base rebuild converges to one explicit achieved boundary
- T17 extent/WAL split boundary remains explicit and gap-free
Main strengthening:
@@ -1,239 +0,0 @@
# V2 Prototype Roadmap And Gates
Date: 2026-03-27
Status: active
Purpose: define the remaining prototype roadmap, the validation gates between stages, and the decision point between real V2 engine work and possible V2.5 redesign
## Current Position
V2 design/FSM/simulator work is sufficiently closed for serious prototyping, but not frozen against later `V2.5` adjustments.
Current state:
- design proof: high
- execution proof: medium
- data/recovery proof: low
- prototype end-to-end proof: low
Rough prototype progress:
- `25%` to `35%`
This is early executable prototype, not engine-ready prototype.
## Roadmap Goal
Answer this question with prototype evidence:
- can V2 become a real engine path?
- or should it become `V2.5` before real implementation begins?
## Step 1: Execution Authority Closure
Purpose:
- finish the sender / recovery-session authority model so stale work is unambiguously rejected
Scope:
1. ownership-only `AttachSession()` / `SupersedeSession()`
2. execution begins only through execution APIs
3. stale handshake / progress / completion fenced by `sessionID`
4. endpoint bump / epoch bump invalidate execution authority
5. sender-group preserve-or-kill behavior is explicit
Done when:
1. all execution APIs are sender-gated and reject stale `sessionID`
2. session creation is separated from execution start
3. phase ordering is enforced
4. endpoint bump / epoch bump invalidate execution authority correctly
5. mixed add/remove/update reconciliation preserves or kills state exactly as intended
Main files:
- `sw-block/prototype/enginev2/`
- `sw-block/prototype/distsim/`
- `learn/projects/sw-block/phases/phase-13-v2-boundary-tests.md`
Key gate:
- old recovery work cannot mutate current sender state at any execution stage
## Step 2: Orchestrated Recovery Prototype
Purpose:
- move from good local sender APIs to an actual prototype recovery flow driven by assignment/update intent
Scope:
1. assignment/update intent creates or supersedes recovery attempts
2. reconnect / reassignment / catch-up / rebuild decision path
3. sender-group becomes orchestration entry point
4. explicit outcome branching:
- zero-gap fast completion
- positive-gap catch-up
- unrecoverable gap -> `NeedsRebuild`
Done when:
1. the prototype expresses a realistic recovery flow from topology/control intent
2. sender-group drives recovery creation, not only unit helpers
3. recovery outcomes are explicit and testable
4. orchestrator responsibility is clear enough to narrow `v2-open-questions.md` item 6
Key gate:
- recovery control is no longer scattered across helper calls; it has one clear orchestration path
## Step 3: Minimal Historical Data Prototype
Purpose:
- prove the recovery model against real data-history assumptions, not only control logic
Scope:
1. minimal WAL/history model, not full engine
2. enough to exercise:
- catch-up range
- retained prefix/window
- rebuild fallback
- historical correctness at target LSN
3. enough reservation/recoverability state to make recovery explicit
Done when:
1. the prototype can prove why a gap is recoverable or unrecoverable
2. catch-up and rebuild decisions are backed by minimal data/history state
3. `v2-open-questions.md` items 3, 4, 5 are closed or sharply narrowed
4. prototype evidence strengthens acceptance criteria `A5`, `A6`, and `A7`
Key gate:
- the prototype must explain why recovery is allowed, not just that policy says it is
## Step 4: Prototype Scenario Closure
Purpose:
- make the prototype itself demonstrate the V2 story end-to-end
Scope:
1. map key V2 scenarios onto the prototype
2. express the 4 V2-boundary cases against prototype behavior
3. add one small end-to-end harness inside `sw-block/prototype/`
4. align prototype evidence with acceptance criteria
Done when:
1. prototype behavior can be reviewed scenario-by-scenario
2. key V1/V1.5 failures have prototype equivalents
3. prototype outcomes match intended V2 design claims
4. remaining gaps are clearly real-engine gaps, not protocol/prototype ambiguity
Key gate:
- a reviewer can trace:
- acceptance criteria -> scenario -> prototype behavior
without hand-waving
## Gates
### Gate 1: Design Closed Enough
Status:
- mostly passed
Meaning:
1. acceptance criteria exist
2. core simulator exists
3. ownership gap from V1.5 is understood
### Gate 2: Execution Authority Closed
Passes after Step 1.
Meaning:
- stale execution results cannot mutate current authority
### Gate 3: Orchestrated Recovery Closed
Passes after Step 2.
Meaning:
- recovery flow is controlled by one coherent orchestration model
### Gate 4: Historical Data Model Closed
Passes after Step 3.
Meaning:
- catch-up vs rebuild is backed by executable data-history logic
### Gate 5: Prototype Convincing
Passes after Step 4.
Meaning:
- enough evidence exists to choose:
- real V2 engine path
- or `V2.5` redesign
## Decision Gate After Step 4
### Path A: Real V2 Engine Planning
Choose this if:
1. prototype control logic is coherent
2. recovery boundary is explicit
3. boundary cases are convincing
4. no major structural flaw remains
Outputs:
1. real engine slicing plan
2. migration/integration plan into future standalone `sw-block`
3. explicit non-goals for first production version
### Path B: V2.5 Redesign
Choose this if the prototype reveals:
1. ownership/orchestration still too fragile
2. recovery boundary still too implicit
3. historical correctness model too costly or too unclear
4. too much complexity leaks into the hot path
Output:
- write `V2.5` as a design/prototype correction before engine work
## What Not To Do Yet
1. no Smart WAL expansion beyond what Step 3 minimally needs
2. no backend/storage-engine redesign
3. no V1 production integration
4. no frontend/wire protocol work
5. no performance optimization as a primary goal
## Practical Summary
Current sequence:
1. finish execution authority
2. build orchestrated recovery
3. add minimal historical-data proof
4. close key scenarios against the prototype
5. decide:
- V2 engine
- or `V2.5`
@@ -0,0 +1,547 @@
# V2 语义建模与协议开发方法
日期:2026-04-02
状态:active
读者:架构设计、实现负责人、tester、reviewer
## 1. 文档目标
这份文档回答的不是“某个函数怎么改”,而是下面几个更上层的问题:
1. 为什么 `V2` 不应该主要依赖“多跑一些随机模拟”来建立正确性信心
2. 为什么需要先定义状态机,再在其上叠加语义约束
3. 什么叫“证明义务”,它和普通 testcase 有什么不同
4. 如何把状态、约束、证明义务重新连接回实现、测试和 slice close
这份文档不是具体协议规格。
它是 `V2` 的方法论文档。
它的作用是把下面这条路线讲清楚:
1. 先定义系统状态空间
2. 再定义约束和语义
3. 再导出必须被证明的义务
4. 最后映射到实现、测试、review 和 phase slice
## 2. 为什么不能主要依赖 random simulation
随机模拟是有价值的,但它不是 closure framework。
它更适合:
1. 发现意外交互
2. 挖掘边缘 case
3. 暴露直觉之外的坏结果
4. 提高对某个设计的经验性信心
它不擅长直接回答下面这些问题:
1. 系统真正承诺的 truth 是什么
2. 哪些状态迁移是合法的,哪些必须被拒绝
3. 哪些失败应该 fail closed,哪些可以自动恢复
4. 为什么某个结果在协议上是正确的,而不是“测试刚好没炸”
5. 哪些性质是必须长期保持的不变量
所以 `V2` 的基本方法不是:
- 多跑随机事件,直到感觉系统比较稳
而是:
1. 先定义状态空间
2. 先定义协议语义
3. 先定义证明义务
4. 再用 simulator、one-chain test、真实链路测试去验证这些义务
一句话:
- random simulation 是 discovery tool
- 不是 protocol closure 的主骨架
### 2.1 两种建立信心的路径(直观对比)
```mermaid
flowchart LR
subgraph A["以 random simulation 为主"]
A1[随机事件序列] --> A2[观察是否崩溃/出错]
A2 --> A3[扩大随机范围或时长]
end
subgraph B["以语义框架为主"]
B1[状态机 + 事件] --> B2[语义约束 / 不变量]
B2 --> B3[证明义务]
B3 --> B4[定向测试 / one-chain / 真实链路证据]
B4 --> B5[slice close]
end
```
要点:
- `A` 擅长发现意外,但不直接回答“协议承诺了什么”。
- `B` 先定义可证目标,再用证据 close,而不是只靠“跑得久没炸”。
## 3. 五层抽象模型
为了把 `V2` 做成一个可推演、可 close、可落回开发的系统,可以把它分成五层。
### 3.0 五层堆叠(一览)
```mermaid
flowchart TB
L5["Layer 5:工程映射\n实现点 / 观测点 / 测试 / phase slice"]
L4["Layer 4:证明义务\n每条约束对应的最小证据"]
L3["Layer 3:语义约束\n不变量 / authority / boundary / convergence"]
L2["Layer 2:状态机\n状态对象 + 事件 + 合法迁移"]
L1["Layer 1:物理实现\ncode / goroutine / gRPC / blockvol"]
L1 --> L2
L2 --> L3
L3 --> L4
L4 --> L5
```
读图方式:从下往上是“从代码到语义”,从上往下是“从语义到落地”。
### 3.1 第一层:物理实现层
这一层是实际运行的工程对象:
1. `master`
2. `volume server`
3. `ProcessAssignments()`
4. `ControlBridge`
5. `RecoveryManager`
6. `CatchUpExecutor`
7. `RebuildExecutor`
8. heartbeat / gRPC path
9. `blockvol` / `WAL` / `snapshot` / `flusher`
这一层回答的是:
- 系统最终跑在哪里
- 哪些代码路径真正执行
但它本身不是协议语义。
### 3.2 第二层:状态机层
这一层定义系统有哪些正式状态对象,以及事件如何推动状态迁移。
这一层回答的是:
1. 系统里真正的状态是什么
2. 哪些事件会改变状态
3. 状态如何从一个点迁移到另一个点
典型状态对象包括:
1. 控制面状态:
- `epoch`
- `role`
- assignment truth
- stable `ServerID`
2. 恢复状态:
- sender
- session
- recovery owner
- `in_sync` / `needs_rebuild` / `catchup`
3. 数据边界状态:
- `CheckpointLSN`
- `WALHeadLSN`
- `receivedLSN`
- `targetLSN`
- `achievedLSN`
4. 对外可见状态:
- heartbeat truth
- reporting addresses
- externally visible ownership
典型事件包括:
1. `AssignmentDelivered`
2. `EpochBumped`
3. `SessionCreated`
4. `SessionSuperseded`
5. `CatchUpCompleted`
6. `RebuildCommitted`
7. `TruncationEscalated`
8. `RepeatedAssignmentDelivered`
9. `HeartbeatCollected`
10. `Crash` / `Restart`
### 3.3 第三层:语义约束层
这一层不再只是说“系统会怎么动”,而是说“系统应该满足什么规律”。
这一层回答的是:
1. 哪些状态组合是允许的
2. 哪些状态组合是禁止的
3. 哪些迁移必须 fail closed
4. 哪些 truth 最终必须收敛
这层通常包含五类约束:
1. ownership 约束
2. identity 约束
3. boundary safety 约束
4. convergence 约束
5. idempotence 约束
这层的价值是把“会跑”变成“跑得对”。
### 3.4 第四层:证明义务层
这一层把语义约束变成有限个必须被证明的场景。
它回答的是:
1. 每条约束最少需要哪些证据
2. 哪些事件组合最能破坏这条约束
3. 怎样设计 proof case,而不是瞎跑随机 case
证明义务不是随便写一个测试。
它是对某条语义约束的最小必要证明。
### 3.5 第五层:工程映射层
这一层把抽象重新落回工程:
1. 哪条语义约束由哪个实现点负责
2. 哪些观测点能证明它成立
3. 哪些测试或 one-chain proof 覆盖它
4. 哪个 phase / slice 对应关闭它
如果没有这一层,模型容易停在 PPT。
有了这一层,模型才能真正指导实现和 close。
## 4. 什么是“语义约束”
语义约束是叠加在状态机之上的规则。
它不是代码风格,也不是临时 if 判断。
它是系统长期必须满足的性质。
### 4.1 ownership 约束
目标是明确“当前谁有 authority”。
例子:
1. 同一时刻,同一 replica 最多只能有一个 live recovery owner
2. 新 session 生效后,旧 owner 不能继续提交结果
3. supersede 后旧 goroutine 必须 drain
### 4.2 identity 约束
目标是明确“谁是谁”,而不是靠地址字符串猜身份。
例子:
1. stable `ServerID` 不能在 wire 上丢失
2. `ReplicaID` 应从 `<path>/<ServerID>` 构造,而不是从 transport address 推断
3. 缺失 stable identity 时应 fail closed,而不是随手 fallback
### 4.3 boundary safety 约束
目标是明确恢复边界必须在物理上成立。
例子:
1. snapshot 边界必须和 manifest 对齐
2. truncation 只有在安全条件成立时才能本地修正
3. full-base rebuild 的完成边界必须和 runtime / engine accounting 对齐
### 4.4 convergence 约束
目标是防止多个 truth 长期分裂。
例子:
1. master truth、runtime truth、heartbeat truth 最终必须收敛
2. reassignment 后旧 truth 不应继续对外可见
3. `achievedLSN`、checkpoint、receiver progress 应在 accepted contract 下收敛
### 4.5 idempotence 约束
目标是保证重复不变更的输入不会不断产生额外副作用。
例子:
1. repeated assignment 不应反复触发 recovery side effect
2. repeated unchanged delivery 不应重复 relisten / restart
3. repeated heartbeat delivery 不应破坏已收敛 truth
## 5. 什么是“证明义务”
证明义务不是“多写一些测试”。
证明义务的定义是:
- 为了证明某条语义约束成立,必须提供的最小证据单元
### 5.1 一个证明义务通常包含三部分
1. 要保护的约束
2. 最容易破坏它的事件组合
3. 可以观察到结果的观测点
### 5.2 证明义务的例子
#### 例子 A:唯一 owner
语义约束:
- 同一 replica 只能有一个 live owner
证明义务:
1. epoch bump 后旧 owner 被 drain
2. replacement 开始前旧 owner 已退出
3. shutdown 后不再残留 active owner
#### 例子 Bboundary safety
语义约束:
- 不安全 truncation 不得伪装成成功
证明义务:
1. safe case 本地修正成功
2. unsafe case 返回 escalation
3. sender state 进入 `needs_rebuild` 而不是错误地 `in_sync`
#### 例子 Cidentity preservation
语义约束:
- stable ID 不得在 wire 上被地址 fallback 取代
证明义务:
1. proto round-trip preserves stable ID
2. real ingress path preserves stable ID
3. missing stable ID fails closed
### 5.3 为什么证明义务比 random simulation 更有效
因为它是围绕“必须成立的语义”来构造场景,而不是随机撞运气。
它回答的是:
- 我们到底在证明什么
而不只是:
- 我们又跑过了一些 case
## 6. 从语义约束到 slice close
`V2` 的 phase / slice 应该由语义约束驱动,而不是由“某个模块看起来需要改”驱动。
建议每个 slice 都回答五个问题:
1. 本 slice 关心哪些状态对象
2. 本 slice 关心哪些关键事件
3. 本 slice 要关闭哪条语义约束
4. 本 slice 需要哪些证明义务
5. 这些义务映射到哪些实现点和观测点
### 6.1 一个简单模板
#### Step 1:状态对象
例如:
- assignment truth
- recovery owner
- heartbeat truth
#### Step 2:关键事件
例如:
- epoch bump
- repeated assignment
- master-driven delivery
#### Step 3:语义约束
例如:
- unique owner
- no split truth
- idempotence
#### Step 4:证明义务
例如:
- old owner drained
- new truth becomes visible
- repeated delivery does not create new side effects
#### Step 5:工程映射
例如:
- `ProcessAssignments()`
- `RecoveryManager`
- `CollectBlockVolumeHeartbeat()`
- `AssignmentsToProto/FromProto`
## 7. simulator 在这个方法里的位置
simulator 仍然非常重要,但它处在语义框架之下。
它的价值主要是:
1. 探索复杂交互
2. 帮助寻找高风险事件组合
3. 验证某条约束在更大状态空间里是否容易被打破
4. 作为真实实现之前的设计验证层
它不应该替代:
1. contract
2. invariant
3. proof obligation
4. one-chain evidence
所以正确关系是:
1. 先有语义框架
2. 再用 simulator 扩大探索和验证覆盖
而不是反过来。
## 8. 直观理解:为什么这种方法比“逐渐修 bug”更强
很多工程系统的成长路径是:
1. 先做一个能工作的版本
2. 线上或测试出 bug
3. 修一个点
4. 再遇到新边界
5. 再补一个 patch
这种路线可以快速落地,但容易产生:
1. 隐含 truth
2. patch pile
3. 边界不清
4. 系统很难解释为什么是对的
`V2` 这套方法的差异在于:
1. 先抽象状态和事件
2. 先定义 authority、boundary、convergence、idempotence
3. 再把这些落回实现
4. 做不到的地方缩窄 contract,而不是继续模糊化
这会让前期更慢,但长期更稳,也更适合做为开源协议工程方法。
## 9. 方法总结
`V2` 的推荐方法可以压缩成一句话:
- 先定义状态机,再定义语义约束,再定义证明义务,最后把这些映射回工程实现与测试。
进一步展开,就是:
1. 状态机定义系统会怎么动
2. 语义约束定义系统应该满足什么
3. 证明义务定义我们必须提供哪些证据
4. 工程映射定义这些证据如何在代码与测试里落地
这条路径不是为了让系统“更学术”。
它的目标是让系统在复杂故障和恢复场景下:
1. 更可解释
2. 更可 close
3. 更不依赖运气
4. 更不容易退化成 patch-driven correctness
## 10. V1 与 V2:哪些状态在 V1 中常未显式建模,导致结果不确定
这一节不是贬低 `V1` 工程价值。
它想说明的是:
- 很多传统路径**能跑**,是因为实现里堆了经验与 patch
- 但在**协议层**若没有显式状态,遇到边界场景时,**系统行为会难以形式化预测**
下面用“缺失的显式状态”来对照:这些缺口在 happy path 往往不明显,在 failover / rebuild / 重复控制消息 / identity 变化时会放大为**不确定或 split truth**。
### 10.1 对照表:显式状态与典型后果
| 维度 | V1 常见情况(概括) | 若未显式建模时的典型不确定 | V2 的显式化方向(概括) |
|------|---------------------|---------------------------|-------------------------|
| 副本/节点身份 | 常隐含在 `ip:port`、连接串、临时约定里 | failover 后“同名不同人”、重复地址、owner 混淆 | stable `ServerID` + `ReplicaID = path/serverID` + wire 携带 |
| 恢复 authority | 常隐含在“当前 goroutine/函数在跑” | 旧任务与新任务重叠、cancel 不到执行层 | `session` + `RecoveryManager` + supersede/drain 语义 |
| 控制代际 / 围栏 | `epoch` 不一定贯穿所有层 | 旧消息/旧计划仍生效、幽灵进度 | `epoch` + sender/session 失效规则 |
| rebuild 完成边界 | 常停在“拷完 extent / 跑完某段逻辑” | engine 认为完成 vs 本地实际 LSN 不一致 | `achievedLSN` 与 runtime checkpoint/receiver 对齐 |
| snapshot / tail 边界 | 常弱化为“尽量一致” | 边界漂移、静默接受错误镜像 | manifest + `BaseLSN` + fail-closed |
| replica-ahead / truncate | 常混在一个“修一下元数据”的路径里 | extent 已污染却宣称已修好 | safe / unsafe 分流 + escalate |
| 控制面 vs 运行时 vs 上报 | 常分散在多个模块,缺少统一 truth | master 以为 AVS 报 B,内部还在跑 C | convergence 证明:ingress/runtime/heartbeat 对齐 |
| 重复 assignment / 心跳投递 | 常靠“再跑一次应该差不多” | 重复 side effect、重复 relisten | idempotence:同 truth 不重复触发 |
说明:
- 上表是对**工程形态的概括**,不是逐文件审计结论。
- `V1` 的具体代码路径里可能已经**部分**具备某些字段或行为,但若未上升到**协议级显式对象**,在 review 和演进时仍容易漂移。
### 10.2 不确定性的结构(Mermaid
```mermaid
flowchart TB
subgraph missing["常见缺口:协议层未显式建模"]
M1[身份 truth 弱]
M2[恢复 owner 弱]
M3[完成边界弱]
M4[控制/运行/上报分裂]
end
subgraph symptoms["边界场景症状"]
S1[结果依赖时序/重试]
S2[同一输入不同次运行行为不同]
S3[表面成功但内部 truth 不一致]
S4[难以回答为什么是对的]
end
missing --> symptoms
```
### 10.3 和 `V2` 方法的关系
`V2` 的做法不是“多写一点 if”。
它是把上表里的缺口,尽量变成:
1. 正式状态对象
2. 语义约束
3. 证明义务
4. 工程映射与测试证据
这样“不确定”会从**黑盒运气**变成**可命名、可测试、可 close 的缺口清单**。
## 11. 推荐和哪些文档一起阅读
建议按下面顺序阅读:
1. `protocol-development-process.md`
2. `v2-semantic-methodology.zh.md`
3. `v2-detailed-algorithm.zh.md`
4. `v2-protocol-closure-map.zh.md`
5. `v2-phase-development-plan.md`
这样可以依次看到:
1. 开发流程
2. 方法论
3. 具体算法
4. 当前协议闭环地图
5. phase 级执行计划
另见:`v2-protocol-closure-map.zh.md` 中的 truth 流水线图与 `Phase` 映射表。
+91 -45
View File
@@ -1,23 +1,42 @@
package replication
import "fmt"
import (
"errors"
"fmt"
)
// ErrTruncationUnsafe is returned by CatchUpIO.TruncateWAL when the
// replica's ahead entries have already been flushed to extent. The
// CatchUpExecutor detects this and escalates the sender to NeedsRebuild
// instead of failing with a generic error.
var ErrTruncationUnsafe = errors.New("truncation unsafe: ahead entries flushed to extent")
// === Phase 06 P2 / Phase 08 P2: Stepwise Executor ===
// CatchUpIO is the I/O interface that the catch-up executor calls to
// perform real WAL streaming. Implemented by the v2bridge executor.
// perform real WAL streaming and truncation. Implemented by v2bridge executor.
// The engine defines this interface; it does NOT import weed/.
type CatchUpIO interface {
// StreamWALEntries reads WAL entries from startExclusive+1 to endInclusive.
// Returns the highest LSN successfully transferred.
StreamWALEntries(startExclusive, endInclusive uint64) (transferredTo uint64, err error)
// TruncateWAL performs real local correction for replica-ahead recovery.
// After completion, the replica's local runtime (WALHeadLSN, nextLSN,
// receiver progress) must be at exactly truncateLSN — not above.
TruncateWAL(truncateLSN uint64) error
}
// RebuildIO is the I/O interface that the rebuild executor calls to
// perform real data transfer. Implemented by the v2bridge executor.
type RebuildIO interface {
// TransferFullBase transfers the full extent image at committedLSN.
TransferFullBase(committedLSN uint64) error
// TransferFullBase transfers the full extent image. committedLSN is
// the engine's frozen minimum target — the transfer must cover at
// least this boundary. Returns achievedLSN: the actual boundary
// reached after install + any second catch-up. achievedLSN >= committedLSN.
// The engine uses achievedLSN for progress recording so local runtime
// state and engine-visible completion converge to the same boundary.
TransferFullBase(committedLSN uint64) (achievedLSN uint64, err error)
// TransferSnapshot transfers a checkpoint/snapshot at snapshotLSN.
TransferSnapshot(snapshotLSN uint64) error
// StreamWALEntries for tail replay after snapshot transfer.
@@ -77,57 +96,78 @@ func (e *CatchUpExecutor) Execute(progressLSNs []uint64, startTick uint64) error
e.driver.Orchestrator.Log.Record(e.replicaID, e.sessID, "exec_catchup_started",
fmt.Sprintf("target=%d tick=%d io=%v", e.plan.CatchUpTarget, startTick, e.IO != nil))
// Step 2: progress — either via real IO bridge or caller-supplied LSNs.
if e.IO != nil {
// Real I/O path: stream WAL entries through the bridge.
transferred, err := e.IO.StreamWALEntries(e.plan.CatchUpStartLSN, e.plan.CatchUpTarget)
if err != nil {
e.release(fmt.Sprintf("io_stream_failed: %s", err))
return err
}
tick := startTick + 1
if err := s.RecordCatchUpProgress(e.sessID, transferred, tick); err != nil {
e.release(fmt.Sprintf("progress_after_io: %s", err))
return err
}
} else {
// Test path: caller-supplied progress LSNs.
for i, lsn := range progressLSNs {
if !s.HasActiveSession() || s.SessionID() != e.sessID {
e.release("session_invalidated_mid_execution")
return fmt.Errorf("session invalidated during catch-up step %d", i)
}
// Step 2: progress — skip for truncation-only plans (replica ahead,
// no WAL replay needed). Detect by: TruncateLSN > 0 and start > target.
isTruncationOnly := e.plan.TruncateLSN > 0 && e.plan.CatchUpStartLSN >= e.plan.CatchUpTarget
tick := startTick + uint64(i+1)
if err := s.RecordCatchUpProgress(e.sessID, lsn, tick); err != nil {
e.release(fmt.Sprintf("progress_failed_step_%d: %s", i, err))
return err
}
if e.OnStep != nil {
e.OnStep(i)
}
v, err := s.CheckBudget(e.sessID, tick)
if !isTruncationOnly {
if e.IO != nil {
// Real I/O path: stream WAL entries through the bridge.
transferred, err := e.IO.StreamWALEntries(e.plan.CatchUpStartLSN, e.plan.CatchUpTarget)
if err != nil {
e.release(fmt.Sprintf("budget_check_failed: %s", err))
e.release(fmt.Sprintf("io_stream_failed: %s", err))
return err
}
if v != BudgetOK {
e.release(fmt.Sprintf("budget_escalated: %s", v))
return fmt.Errorf("budget violation at step %d: %s", i, v)
tick := startTick + 1
if err := s.RecordCatchUpProgress(e.sessID, transferred, tick); err != nil {
e.release(fmt.Sprintf("progress_after_io: %s", err))
return err
}
} else {
// Test path: caller-supplied progress LSNs.
for i, lsn := range progressLSNs {
if !s.HasActiveSession() || s.SessionID() != e.sessID {
e.release("session_invalidated_mid_execution")
return fmt.Errorf("session invalidated during catch-up step %d", i)
}
tick := startTick + uint64(i+1)
if err := s.RecordCatchUpProgress(e.sessID, lsn, tick); err != nil {
e.release(fmt.Sprintf("progress_failed_step_%d: %s", i, err))
return err
}
if e.OnStep != nil {
e.OnStep(i)
}
v, err := s.CheckBudget(e.sessID, tick)
if err != nil {
e.release(fmt.Sprintf("budget_check_failed: %s", err))
return err
}
if v != BudgetOK {
e.release(fmt.Sprintf("budget_escalated: %s", v))
return fmt.Errorf("budget violation at step %d: %s", i, v)
}
}
}
}
// Step 3: truncation (if required).
if e.plan.TruncateLSN > 0 {
// Real I/O: perform physical local correction before recording.
if e.IO != nil {
if err := e.IO.TruncateWAL(e.plan.TruncateLSN); err != nil {
// Escalation: if truncation is unsafe (flushed-ahead),
// transition sender to NeedsRebuild instead of generic failure.
if errors.Is(err, ErrTruncationUnsafe) {
s.InvalidateSession("flushed_ahead_needs_rebuild", StateNeedsRebuild)
e.release(fmt.Sprintf("truncation_escalated_to_rebuild: %s", err))
e.driver.Orchestrator.Log.Record(e.replicaID, e.sessID, "truncation_escalated",
fmt.Sprintf("truncate_to=%d err=%s", e.plan.TruncateLSN, err))
return fmt.Errorf("truncation escalated to rebuild: %w", err)
}
e.release(fmt.Sprintf("truncation_io_failed: %s", err))
return err
}
}
if err := s.RecordTruncation(e.sessID, e.plan.TruncateLSN); err != nil {
e.release(fmt.Sprintf("truncation_failed: %s", err))
return err
}
e.driver.Orchestrator.Log.Record(e.replicaID, e.sessID, "exec_truncation",
fmt.Sprintf("truncated_to=%d", e.plan.TruncateLSN))
fmt.Sprintf("truncated_to=%d io=%v", e.plan.TruncateLSN, e.IO != nil))
}
// Step 4: complete.
@@ -263,14 +303,20 @@ func (e *RebuildExecutor) Execute() error {
return err
}
} else {
// Real I/O: transfer full base through bridge.
// Full-base rebuild: transfer extent and use the achieved boundary.
// The IO returns achievedLSN >= plan.RebuildTargetLSN.
// Engine records progress at achievedLSN so local runtime state
// and engine-visible completion converge to the same boundary.
achieved := plan.RebuildTargetLSN // default: test mode (no IO)
if e.IO != nil {
if err := e.IO.TransferFullBase(plan.RebuildTargetLSN); err != nil {
e.release(fmt.Sprintf("io_full_base_failed: %s", err))
return err
var ioErr error
achieved, ioErr = e.IO.TransferFullBase(plan.RebuildTargetLSN)
if ioErr != nil {
e.release(fmt.Sprintf("io_full_base_failed: %s", ioErr))
return ioErr
}
}
if err := s.RecordRebuildTransferProgress(e.sessID, plan.RebuildTargetLSN); err != nil {
if err := s.RecordRebuildTransferProgress(e.sessID, achieved); err != nil {
e.release(fmt.Sprintf("transfer_progress_failed: %s", err))
return err
}
+266
View File
@@ -954,6 +954,272 @@ func (v *BlockVol) SetV2RetentionFloor(fn func() (uint64, bool)) {
}
}
// ---------------------------------------------------------------------------
// RebuildInstaller — named local install primitive for V2 rebuild
// ---------------------------------------------------------------------------
// RebuildInstaller writes received extent data to the blockvol's extent
// region during a rebuild transfer. This is the authoritative local install
// primitive: it owns the write path and the durable state handoff.
//
// Commit performs the full state handoff: fsync extent, clear dirty map,
// reset WAL, update superblock with checkpoint boundary, sync superblock,
// and advance nextLSN. After Commit, the volume's local state is
// authoritative at the given snapshot boundary.
//
// Usage:
//
// installer := vol.NewRebuildInstaller()
// for each chunk received over TCP:
// installer.WriteChunk(data)
// installer.Commit(snapshotLSN) // full state handoff
type RebuildInstaller struct {
vol *BlockVol
fd *os.File
extentStart uint64
volumeSize uint64
offset uint64
committed bool
}
// NewRebuildInstaller creates an installer that writes to this volume's
// extent region. The caller feeds chunks and calls Commit when done.
func (v *BlockVol) NewRebuildInstaller() *RebuildInstaller {
return &RebuildInstaller{
vol: v,
fd: v.fd,
extentStart: v.super.WALOffset + v.super.WALSize,
volumeSize: v.super.VolumeSize,
}
}
// WriteChunk writes a chunk of extent data at the current offset.
// Chunks must arrive in order (sequential append to the extent region).
func (ri *RebuildInstaller) WriteChunk(data []byte) error {
if ri.committed {
return fmt.Errorf("rebuild install: already committed")
}
if ri.offset+uint64(len(data)) > ri.volumeSize {
return fmt.Errorf("rebuild install: extent overflow at %d+%d > %d",
ri.offset, len(data), ri.volumeSize)
}
_, err := ri.fd.WriteAt(data, int64(ri.extentStart+ri.offset))
if err != nil {
return fmt.Errorf("rebuild install: write at offset %d: %w", ri.offset, err)
}
ri.offset += uint64(len(data))
return nil
}
// Commit performs the full local state handoff after extent data is written.
// snapshotLSN is the primary's nextLSN at the time the extent copy started
// (returned in MsgRebuildDone). After Commit:
// - extent data is fsynced
// - dirty map is cleared (stale WAL references invalidated)
// - WAL is reset (no valid entries for old data)
// - superblock records the new checkpoint boundary
// - flusher checkpoint is synced
// - nextLSN is advanced to snapshotLSN
//
// This matches the state handoff in rebuild.go:rebuildFullExtent and
// blockvol.go:RestoreFromSnapshot — any path that replaces the local base
// must do the same state clearing.
func (ri *RebuildInstaller) Commit(snapshotLSN uint64) error {
if ri.committed {
return fmt.Errorf("rebuild install: already committed")
}
ri.committed = true
v := ri.vol
// 1. Fsync extent data.
if err := v.fd.Sync(); err != nil {
return fmt.Errorf("rebuild install: fsync extent: %w", err)
}
// 2. Clear dirty map — all data now in extent, stale WAL refs invalid.
v.dirtyMap.Clear()
// 3. Reset WAL — no valid entries for old data.
v.wal.Reset()
// 4. Persist clean superblock state so crash recovery doesn't replay stale WAL.
checkpointLSN := uint64(0)
if snapshotLSN > 0 {
checkpointLSN = snapshotLSN - 1
}
v.mu.Lock()
v.super.WALHead = 0
v.super.WALTail = 0
v.super.WALCheckpointLSN = checkpointLSN
if _, err := v.fd.Seek(0, 0); err != nil {
v.mu.Unlock()
return fmt.Errorf("rebuild install: seek superblock: %w", err)
}
if _, err := v.super.WriteTo(v.fd); err != nil {
v.mu.Unlock()
return fmt.Errorf("rebuild install: write superblock: %w", err)
}
if err := v.fd.Sync(); err != nil {
v.mu.Unlock()
return fmt.Errorf("rebuild install: sync superblock: %w", err)
}
v.mu.Unlock()
// 5. Sync flusher's internal checkpoint with the rebuilt superblock state.
if v.flusher != nil {
v.flusher.SetCheckpointLSN(checkpointLSN)
}
// 6. Set nextLSN to the rebuilt boundary. Unconditional store, not
// monotonic-advance: a stale replica may have a higher nextLSN than
// the rebuilt boundary, and that stale value must be overwritten.
v.nextLSN.Store(snapshotLSN)
// 7. Set receiver progress to the rebuilt boundary so subsequent WAL
// shipping from the primary starts from the correct point. Unconditional
// set, not monotonic-advance, for the same reason as nextLSN.
if v.replRecv != nil {
v.replRecv.mu.Lock()
target := snapshotLSN - 1
v.replRecv.receivedLSN = target
v.replRecv.mu.Unlock()
}
return nil
}
// BytesWritten returns how many bytes have been written so far.
func (ri *RebuildInstaller) BytesWritten() uint64 {
return ri.offset
}
// SyncReceiverProgress sets the replica receiver's receivedLSN to
// achievedLSN so that subsequent WAL shipping from the primary does not
// hit contiguous-LSN rejection. Called after the full rebuild completes
// (extent install + optional second catch-up). Unconditional set, not
// monotonic-advance: after rebuild, the achieved boundary IS the truth.
func (v *BlockVol) SyncReceiverProgress(achievedLSN uint64) {
if v.replRecv != nil {
v.replRecv.mu.Lock()
v.replRecv.receivedLSN = achievedLSN
v.replRecv.mu.Unlock()
}
}
// ReceivedLSN returns the replica receiver's current receivedLSN, or 0
// if no receiver is active. Used for convergence verification.
func (v *BlockVol) ReceivedLSN() uint64 {
if v.replRecv == nil {
return 0
}
v.replRecv.mu.Lock()
defer v.replRecv.mu.Unlock()
return v.replRecv.receivedLSN
}
// ApplyRebuildEntry decodes and applies a WAL entry during rebuild.
// Used by the V2 executor for second catch-up after extent install.
// Unlike ReplicaReceiver.applyEntry, no contiguous LSN enforcement
// (catch-up entries arrive in order but may have gaps from flushed entries).
func (v *BlockVol) ApplyRebuildEntry(payload []byte) error {
return applyRebuildEntry(v, payload)
}
// ErrTruncationUnsafe is returned by TruncateToLSN when the replica's
// ahead entries have already been flushed to extent. The caller should
// escalate to a full rebuild instead.
var ErrTruncationUnsafe = errors.New("blockvol: truncation unsafe, ahead entries flushed to extent")
// TruncateToLSN performs local correction for replica-ahead recovery.
// After completion, the volume's runtime state is at exactly truncateLSN:
// - dirtyMap cleared (stale WAL references from ahead entries invalidated)
// - WAL reset (ahead entries discarded without flushing them to extent)
// - superblock updated (WALCheckpointLSN = truncateLSN)
// - flusher checkpoint synced
// - nextLSN = truncateLSN + 1
// - receiver progress = truncateLSN
//
// IMPORTANT: This does NOT flush before reset. Ahead entries in the WAL
// are discarded without being written to extent, so the extent retains
// only data that was flushed BEFORE the ahead entries arrived. For the
// typical replica-ahead scenario (a few unflushed entries after failover),
// this is correct: the extent has the primary's data, and discarding the
// WAL removes the ahead entries.
//
// If ahead entries were already flushed to extent by the background flusher,
// those blocks remain in the extent. The primary will overwrite them during
// subsequent WAL shipping. If immediate data consistency is required for
// already-flushed ahead blocks, a full rebuild should be used instead.
func (v *BlockVol) TruncateToLSN(truncateLSN uint64) error {
// Pause flusher WITHOUT flushing — we must clear the dirty map BEFORE
// any flush runs, so ahead entries are never written to extent.
if v.flusher != nil {
v.flusher.Pause()
defer v.flusher.Resume()
}
// Exclusive I/O lock: drain all concurrent WriteLBA / receiver apply
// before mutating WAL/dirty map/superblock. Same pattern as
// RestoreSnapshot and ImportSnapshot.
v.ioMu.Lock()
defer v.ioMu.Unlock()
// Safety check AFTER flusher is paused and I/O is drained.
// Truncation is safe ONLY when checkpointLSN == truncateLSN:
// checkpoint > truncateLSN: ahead entries already flushed to extent
// checkpoint < truncateLSN: kept entries (checkpoint, truncateLSN]
// may still be in WAL only — truncate would discard them
// checkpoint == truncateLSN: extent has exactly the kept state,
// ahead entries are WAL-only and can be safely discarded
if v.super.WALCheckpointLSN != truncateLSN {
return fmt.Errorf("%w: checkpoint %d != truncateLSN %d",
ErrTruncationUnsafe, v.super.WALCheckpointLSN, truncateLSN)
}
// Clear dirty map — stale WAL references from ahead entries are invalidated.
// After this, reads fall through to extent (which has pre-ahead data).
v.dirtyMap.Clear()
// Reset WAL — ahead entries are discarded WITHOUT flushing to extent.
v.wal.Reset()
// Persist truncated state in superblock.
v.mu.Lock()
v.super.WALHead = 0
v.super.WALTail = 0
v.super.WALCheckpointLSN = truncateLSN
if _, err := v.fd.Seek(0, 0); err != nil {
v.mu.Unlock()
return fmt.Errorf("blockvol: truncate seek superblock: %w", err)
}
if _, err := v.super.WriteTo(v.fd); err != nil {
v.mu.Unlock()
return fmt.Errorf("blockvol: truncate write superblock: %w", err)
}
if err := v.fd.Sync(); err != nil {
v.mu.Unlock()
return fmt.Errorf("blockvol: truncate sync superblock: %w", err)
}
v.mu.Unlock()
// Sync flusher checkpoint.
if v.flusher != nil {
v.flusher.SetCheckpointLSN(truncateLSN)
}
// Set nextLSN (unconditional — truncation replaces truth).
v.nextLSN.Store(truncateLSN + 1)
// Align receiver progress.
if v.replRecv != nil {
v.replRecv.mu.Lock()
v.replRecv.receivedLSN = truncateLSN
v.replRecv.mu.Unlock()
}
return nil
}
// ScanWALEntries reads WAL entries from fromLSN using the real ScanFrom mechanism.
// This is the entry point for the V2 bridge executor's catch-up path.
//
+125 -2
View File
@@ -1,6 +1,7 @@
package blockvol
import (
"context"
"encoding/binary"
"errors"
"fmt"
@@ -111,6 +112,8 @@ func (s *RebuildServer) handleConn(conn net.Conn) {
s.handleWALCatchUp(conn, req)
case RebuildFullExtent:
s.handleFullExtent(conn)
case RebuildSnapshot:
s.handleSnapshotExport(conn, req)
default:
WriteFrame(conn, MsgRebuildError, []byte("UNKNOWN_TYPE"))
}
@@ -146,8 +149,18 @@ func (s *RebuildServer) handleWALCatchUp(conn net.Conn, req RebuildRequest) {
}
func (s *RebuildServer) handleFullExtent(conn net.Conn) {
// Capture snapshot LSN before streaming -- client will use this
// for a second catch-up scan to capture writes during copy.
// Flush outstanding WAL entries to extent before streaming.
// Without this, entries in the WAL but not yet flushed to extent
// would be missing from the copied image AND from the second catch-up
// range (which starts at snapshotLSN, past these entries).
if err := s.vol.ForceFlush(); err != nil {
WriteFrame(conn, MsgRebuildError, []byte(fmt.Sprintf("FLUSH_FAILED: %v", err)))
return
}
// Capture snapshot LSN after flush — all entries up to snapshotLSN-1
// are now in the extent. The client uses snapshotLSN as the second
// catch-up start to capture writes that arrive during the copy.
snapshotLSN := s.vol.nextLSN.Load()
extentStart := s.vol.super.WALOffset + s.vol.super.WALSize
@@ -177,6 +190,116 @@ func (s *RebuildServer) handleFullExtent(conn net.Conn) {
WriteFrame(conn, MsgRebuildDone, lsnBuf)
}
// handleSnapshotExport creates a temporary snapshot at the current checkpoint,
// verifies it matches the requested BaseLSN (FromLSN in the request), and
// streams the snapshot image with an explicit manifest carrying the BaseLSN.
//
// Protocol:
// 1. Client sends RebuildRequest{Type: RebuildSnapshot, FromLSN: requestedBaseLSN}
// 2. Server creates temp snapshot, verifies BaseLSN == requestedBaseLSN
// 3. Server sends MsgRebuildEntry with JSON manifest
// 4. Server sends MsgRebuildExtent chunks (snapshot image data)
// 5. Server sends MsgRebuildDone with BaseLSN
// 6. Server deletes temp snapshot
func (s *RebuildServer) handleSnapshotExport(conn net.Conn, req RebuildRequest) {
requestedLSN := req.FromLSN
// Flush to ensure checkpoint is current.
if err := s.vol.ForceFlush(); err != nil {
WriteFrame(conn, MsgRebuildError, []byte(fmt.Sprintf("FLUSH_FAILED: %v", err)))
return
}
// Verify current checkpoint matches the requested boundary.
checkpointLSN := s.vol.flusher.CheckpointLSN()
if checkpointLSN != requestedLSN {
WriteFrame(conn, MsgRebuildError,
[]byte(fmt.Sprintf("SNAPSHOT_BOUNDARY_MISMATCH: have checkpoint %d, requested %d",
checkpointLSN, requestedLSN)))
return
}
// Create temp snapshot at the current checkpoint.
tempSnapID := exportTempSnapBase + exportTempSnapSeq.Add(1)
if err := s.vol.CreateSnapshot(tempSnapID); err != nil {
WriteFrame(conn, MsgRebuildError, []byte(fmt.Sprintf("SNAPSHOT_CREATE_FAILED: %v", err)))
return
}
defer s.vol.DeleteSnapshot(tempSnapID)
// Verify the snapshot's BaseLSN is exactly what was requested.
s.vol.snapMu.RLock()
snap, ok := s.vol.snapshots[tempSnapID]
s.vol.snapMu.RUnlock()
if !ok {
WriteFrame(conn, MsgRebuildError, []byte("SNAPSHOT_LOST"))
return
}
if snap.header.BaseLSN != requestedLSN {
WriteFrame(conn, MsgRebuildError,
[]byte(fmt.Sprintf("SNAPSHOT_BASELNS_MISMATCH: snap %d, requested %d",
snap.header.BaseLSN, requestedLSN)))
return
}
// Export: stream snapshot image through conn, compute SHA-256.
// Use a pipe to connect ExportSnapshot's io.Writer to frame-based sending.
pr, pw := io.Pipe()
exportDone := make(chan exportResult, 1)
go func() {
manifest, err := s.vol.ExportSnapshot(context.Background(), pw, ExportOptions{
SnapshotID: tempSnapID,
})
pw.CloseWithError(err)
exportDone <- exportResult{manifest, err}
}()
// Stream export data as MsgRebuildExtent frames.
buf := make([]byte, rebuildExtentChunkSize)
for {
n, err := pr.Read(buf)
if n > 0 {
if werr := WriteFrame(conn, MsgRebuildExtent, buf[:n]); werr != nil {
pr.CloseWithError(werr)
<-exportDone
return
}
}
if err == io.EOF {
break
}
if err != nil {
WriteFrame(conn, MsgRebuildError, []byte(fmt.Sprintf("EXPORT_READ: %v", err)))
<-exportDone
return
}
}
result := <-exportDone
if result.err != nil {
WriteFrame(conn, MsgRebuildError, []byte(fmt.Sprintf("EXPORT_FAILED: %v", result.err)))
return
}
// Send manifest as MsgRebuildEntry (JSON), then MsgRebuildDone with BaseLSN.
manifestJSON, err := MarshalManifest(result.manifest)
if err != nil {
WriteFrame(conn, MsgRebuildError, []byte(fmt.Sprintf("MANIFEST_MARSHAL: %v", err)))
return
}
WriteFrame(conn, MsgRebuildEntry, manifestJSON)
lsnBuf := make([]byte, 8)
binary.BigEndian.PutUint64(lsnBuf, requestedLSN)
WriteFrame(conn, MsgRebuildDone, lsnBuf)
}
type exportResult struct {
manifest *SnapshotArtifactManifest
err error
}
// ---------------------------------------------------------------------------
// Rebuild Client (rebuilding replica side)
// ---------------------------------------------------------------------------
+1
View File
@@ -132,6 +132,7 @@ const (
const (
RebuildWALCatchUp byte = 0x01
RebuildFullExtent byte = 0x02
RebuildSnapshot byte = 0x03 // P2: exact snapshot export at requested BaseLSN
)
// RebuildRequest is sent by the rebuilding replica to the primary.
+76
View File
@@ -42,6 +42,11 @@ type SnapshotArtifactManifest struct {
SHA256 string `json:"sha256"`
Compression string `json:"compression"`
ExportToolVersion string `json:"export_tool_version"`
// BaseLSN is the exact snapshot boundary — the highest LSN whose effects
// are included in the exported image. Added for V2 rebuild execution
// so the receiver can verify the imported base equals the planned target.
// Zero for manifests created before this field was added.
BaseLSN uint64 `json:"base_lsn,omitempty"`
}
var (
@@ -147,6 +152,14 @@ func (v *BlockVol) ExportSnapshot(ctx context.Context, w io.Writer, opts ExportO
}
}
// Read snapshot BaseLSN for the manifest.
var baseLSN uint64
v.snapMu.RLock()
if snap, ok := v.snapshots[snapID]; ok {
baseLSN = snap.header.BaseLSN
}
v.snapMu.RUnlock()
h := sha256.New()
mw := io.MultiWriter(w, h)
@@ -192,6 +205,7 @@ func (v *BlockVol) ExportSnapshot(ctx context.Context, w io.Writer, opts ExportO
SHA256: hex.EncodeToString(h.Sum(nil)),
Compression: "none",
ExportToolVersion: ExportToolVersion,
BaseLSN: baseLSN,
}
return manifest, nil
@@ -325,3 +339,65 @@ func (v *BlockVol) ImportSnapshot(ctx context.Context, manifest *SnapshotArtifac
return nil
}
// ImportSnapshotForRebuild imports a snapshot artifact and converges all
// local runtime state to the exact snapshot boundary (baseLSN). This is
// the rebuild-oriented import primitive for V2 snapshot_tail execution.
//
// Unlike generic ImportSnapshot, this method:
// - requires manifest.BaseLSN > 0 (exact boundary must be explicit)
// - verifies the imported base equals the requested snapshotLSN
// - converges WALCheckpointLSN, nextLSN, flusher, and receiver to baseLSN
//
// After this call, the volume is at exactly snapshotLSN. The caller can
// then replay WAL tail entries from snapshotLSN+1 to targetLSN.
func (v *BlockVol) ImportSnapshotForRebuild(ctx context.Context, manifest *SnapshotArtifactManifest, r io.Reader, snapshotLSN uint64) error {
// Validate: manifest must carry explicit BaseLSN matching the requested boundary.
if manifest.BaseLSN == 0 {
return fmt.Errorf("blockvol: rebuild import requires explicit BaseLSN in manifest")
}
if manifest.BaseLSN != snapshotLSN {
return fmt.Errorf("blockvol: rebuild import boundary mismatch: manifest BaseLSN=%d != requested snapshotLSN=%d",
manifest.BaseLSN, snapshotLSN)
}
// Use generic import with AllowOverwrite (rebuild target may have stale data).
if err := v.ImportSnapshot(ctx, manifest, r, ImportOptions{AllowOverwrite: true}); err != nil {
return err
}
// Converge all runtime state to the exact snapshot boundary.
// This is the same state handoff as RebuildInstaller.Commit but
// for the snapshot_tail path, where the boundary is exact.
v.mu.Lock()
v.super.WALCheckpointLSN = snapshotLSN
if _, err := v.fd.Seek(0, 0); err != nil {
v.mu.Unlock()
return fmt.Errorf("blockvol: rebuild import seek superblock: %w", err)
}
if _, err := v.super.WriteTo(v.fd); err != nil {
v.mu.Unlock()
return fmt.Errorf("blockvol: rebuild import write superblock: %w", err)
}
if err := v.fd.Sync(); err != nil {
v.mu.Unlock()
return fmt.Errorf("blockvol: rebuild import sync superblock: %w", err)
}
v.mu.Unlock()
if v.flusher != nil {
v.flusher.SetCheckpointLSN(snapshotLSN)
}
// Set nextLSN (unconditional, not monotonic — rebuild replaces truth).
v.nextLSN.Store(snapshotLSN + 1)
// Align receiver progress.
if v.replRecv != nil {
v.replRecv.mu.Lock()
v.replRecv.receivedLSN = snapshotLSN
v.replRecv.mu.Unlock()
}
return nil
}
+11 -10
View File
@@ -167,7 +167,7 @@ func TestExecutor_RealBlockVol_StreamWALEntries(t *testing.T) {
t.Fatalf("HeadLSN=%d, want >= 3", headLSN)
}
executor := NewExecutor(vol)
executor := NewExecutor(vol, "")
// Stream from start to head.
transferred, err := executor.StreamWALEntries(0, headLSN)
@@ -192,7 +192,7 @@ func TestExecutor_RealBlockVol_StreamPartialRange(t *testing.T) {
reader := NewReader(vol)
state := reader.ReadState()
executor := NewExecutor(vol)
executor := NewExecutor(vol, "")
// Stream only entries 2-4 (partial range).
startLSN := uint64(1) // exclusive: start after LSN 1
@@ -211,21 +211,22 @@ func TestExecutor_RealBlockVol_StreamPartialRange(t *testing.T) {
t.Logf("partial stream: %d→%d, transferred to %d", startLSN, endLSN, transferred)
}
// --- Stubs remain stubs ---
// --- Error paths ---
func TestExecutor_Stubs_ReturnError(t *testing.T) {
func TestExecutor_ErrorPaths(t *testing.T) {
vol := createTestVol(t)
defer vol.Close()
executor := NewExecutor(vol)
executor := NewExecutor(vol, "")
if err := executor.TransferSnapshot(50); err == nil {
t.Fatal("TransferSnapshot should be stub")
t.Fatal("TransferSnapshot should fail on missing checkpoint")
}
if err := executor.TransferFullBase(100); err == nil {
t.Fatal("TransferFullBase should be stub")
if _, err := executor.TransferFullBase(100); err == nil {
t.Fatal("TransferFullBase should fail without rebuild address")
}
if err := executor.TruncateWAL(50); err == nil {
t.Fatal("TruncateWAL should be stub")
// TruncateWAL is now real (P3). Verify it works on a fresh vol.
if err := executor.TruncateWAL(0); err != nil {
t.Fatalf("TruncateWAL(0) on fresh vol: %v", err)
}
}
@@ -24,7 +24,7 @@ func setupChainTest(t *testing.T) (*engine.RecoveryDriver, *bridge.ControlAdapte
reader := NewReader(vol)
pinner := NewPinner(vol)
executor := NewExecutor(vol)
executor := NewExecutor(vol, "")
sa := bridge.NewStorageAdapter(
&readerShim{reader},
@@ -115,7 +115,7 @@ func TestP2_CatchUpClosure_OneChain(t *testing.T) {
// --- ONE CHAIN: Full-base rebuild closure ---
func TestP2_RebuildClosure_OneChain(t *testing.T) {
driver, ca, reader, executor, pinner := setupChainTest(t)
driver, ca, reader, _, pinner := setupChainTest(t)
vol := reader.vol
// Write + flush → force rebuild condition.
@@ -148,9 +148,9 @@ func TestP2_RebuildClosure_OneChain(t *testing.T) {
t.Fatalf("rebuild plan: %v", err)
}
// Step 4: engine RebuildExecutor — wired to real v2bridge I/O.
// Step 4: engine RebuildExecutor — test mode (IO=nil) for FSM proof.
// Real snapshot_tail I/O is proven by TestP2_SnapshotTailRebuild_OneChain.
exec := engine.NewRebuildExecutor(driver, rebuildPlan)
exec.IO = executor // v2bridge.Executor implements RebuildIO
if err := exec.Execute(); err != nil {
t.Fatalf("rebuild executor: %v", err)
}
+350 -38
View File
@@ -1,8 +1,15 @@
package v2bridge
import (
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"errors"
"fmt"
"log"
"net"
engine "github.com/seaweedfs/seaweedfs/sw-block/engine/replication"
"github.com/seaweedfs/seaweedfs/weed/storage/blockvol"
)
@@ -11,35 +18,53 @@ import (
// recovery policy.
//
// Implements engine.CatchUpIO and engine.RebuildIO interfaces.
// Phase 08 P2: StreamWALEntries, TransferFullBase, TransferSnapshot real.
// TruncateWAL: stub.
type Executor struct {
vol *blockvol.BlockVol
}
// NewExecutor creates an executor for a real blockvol instance.
func NewExecutor(vol *blockvol.BlockVol) *Executor {
return &Executor{vol: vol}
}
// StreamWALEntries reads WAL entries from startExclusive+1 to endInclusive
// using BlockVol.ScanWALEntries (real ScanFrom mechanism).
// Returns the highest LSN successfully scanned.
//
// This is the real catch-up data path. The callback receives each entry
// for shipping to the replica (network-layer apply is the caller's job).
// Mode detection via rebuildAddr:
// - rebuildAddr == "": catch-up mode. StreamWALEntries reads local WAL.
// - rebuildAddr != "": rebuild mode. StreamWALEntries connects to primary
// via TCP, receives entries, and applies them to the local vol.
// TransferFullBase and TransferSnapshot also use TCP.
type Executor struct {
vol *blockvol.BlockVol
rebuildAddr string // primary's rebuild server address
}
// NewExecutor creates an executor.
// - vol: the blockvol instance this executor operates on.
// For catch-up: the primary's vol (reads WAL).
// For rebuild: the replica's vol (receives and installs data).
// - rebuildAddr: primary's rebuild server address.
// Required for rebuild operations. May be empty for catch-up only.
func NewExecutor(vol *blockvol.BlockVol, rebuildAddr string) *Executor {
return &Executor{vol: vol, rebuildAddr: rebuildAddr}
}
// StreamWALEntries reads WAL entries from startExclusive+1 to endInclusive.
//
// Mode depends on rebuildAddr:
// - No rebuildAddr (catch-up mode): reads from local vol's WAL via ScanWALEntries.
// Returns the highest LSN successfully scanned. Entries are read but not
// applied locally (the caller ships them to the replica).
// - With rebuildAddr (rebuild tail-replay mode): connects to the primary's
// rebuild server via TCP, receives entries, and applies each to the local
// vol via ApplyRebuildEntry. This is the single-executor path for
// snapshot_tail rebuild — no test shim needed.
func (e *Executor) StreamWALEntries(startExclusive, endInclusive uint64) (uint64, error) {
if e.vol == nil {
return 0, fmt.Errorf("no blockvol instance")
}
if e.rebuildAddr != "" {
// Rebuild tail-replay: TCP → apply to local vol.
return e.streamAndApplyRemote(startExclusive, endInclusive)
}
// Catch-up: local WAL scan.
var highestLSN uint64
err := e.vol.ScanWALEntries(startExclusive+1, func(entry *blockvol.WALEntry) error {
if entry.LSN > endInclusive {
return nil // past requested range, stop
return nil
}
// In production: ship entry to replica over network.
// Here: track the highest LSN successfully read.
highestLSN = entry.LSN
return nil
})
@@ -49,36 +74,323 @@ func (e *Executor) StreamWALEntries(startExclusive, endInclusive uint64) (uint64
return highestLSN, nil
}
// TransferSnapshot validates the checkpoint/snapshot at snapshotLSN is accessible.
// In production: streams the checkpoint image to the replica.
// streamAndApplyRemote connects to the primary's rebuild server, requests
// WAL entries from startExclusive+1, and applies them locally up to
// endInclusive. Returns the highest LSN successfully applied.
//
// Used by both TransferFullBase (second catch-up) and the snapshot_tail
// rebuild path (tail replay after snapshot install).
func (e *Executor) streamAndApplyRemote(startExclusive, endInclusive uint64) (uint64, error) {
conn, err := net.Dial("tcp", e.rebuildAddr)
if err != nil {
return 0, fmt.Errorf("WAL replay connect %s: %w", e.rebuildAddr, err)
}
defer conn.Close()
// Request WAL entries starting from startExclusive+1.
// The rebuild server's handleWALCatchUp scans from FromLSN onwards.
req := blockvol.RebuildRequest{
Type: blockvol.RebuildWALCatchUp,
FromLSN: startExclusive + 1,
Epoch: e.vol.Epoch(),
}
if err := blockvol.WriteFrame(conn, blockvol.MsgRebuildReq, blockvol.EncodeRebuildRequest(req)); err != nil {
return 0, fmt.Errorf("WAL replay send request: %w", err)
}
var highestLSN uint64
var applied, skipped int
for {
msgType, payload, err := blockvol.ReadFrame(conn)
if err != nil {
return highestLSN, fmt.Errorf("WAL replay read: %w", err)
}
switch msgType {
case blockvol.MsgRebuildEntry:
// Bound to endInclusive: skip entries past the target.
if endInclusive > 0 && len(payload) >= 8 {
entryLSN := binary.LittleEndian.Uint64(payload[:8])
if entryLSN > endInclusive {
skipped++
continue
}
}
if err := e.vol.ApplyRebuildEntry(payload); err != nil {
return highestLSN, fmt.Errorf("WAL replay apply: %w", err)
}
if len(payload) >= 8 {
highestLSN = binary.LittleEndian.Uint64(payload[:8])
}
applied++
case blockvol.MsgRebuildDone:
// Sync receiver progress to the highest applied entry.
if highestLSN > 0 {
e.vol.SyncReceiverProgress(highestLSN)
}
log.Printf("v2bridge: WAL replay applied=%d skipped=%d from=%d target=%d highest=%d",
applied, skipped, startExclusive+1, endInclusive, highestLSN)
return highestLSN, nil
case blockvol.MsgRebuildError:
return highestLSN, fmt.Errorf("WAL replay server error: %s", string(payload))
default:
return highestLSN, fmt.Errorf("WAL replay unexpected message 0x%02x", msgType)
}
}
}
// TransferFullBase connects to the primary's rebuild server over TCP,
// receives the full extent image, installs it locally with full state
// handoff (clear dirty map, reset WAL, update superblock), then performs
// a second catch-up bounded to committedLSN to cover any writes that
// arrived during the copy.
//
// committedLSN is the engine's frozen minimum target (plan.RebuildTargetLSN).
// The executor validates that the server's snapshot covers this target.
//
// Returns achievedLSN: the actual boundary reached after install + second
// catch-up. achievedLSN >= committedLSN. The engine uses achievedLSN for
// progress recording so local runtime and engine-visible state converge.
func (e *Executor) TransferFullBase(committedLSN uint64) (uint64, error) {
if e.vol == nil {
return 0, fmt.Errorf("no blockvol instance")
}
if e.rebuildAddr == "" {
return 0, fmt.Errorf("no rebuild address configured")
}
// Phase 1: extent copy + full state handoff.
snapshotLSN, err := e.transferExtent()
if err != nil {
return 0, err
}
// Validate: the server's snapshot must cover the engine's frozen target.
if committedLSN > 0 && snapshotLSN > 0 && snapshotLSN <= committedLSN {
return 0, fmt.Errorf("rebuild: server snapshot %d does not cover target %d",
snapshotLSN, committedLSN)
}
log.Printf("v2bridge: TransferFullBase phase 1 complete: extent installed, snapshotLSN=%d target=%d",
snapshotLSN, committedLSN)
// Phase 2: second catch-up — replay WAL entries from snapshotLSN,
// bounded to committedLSN. Uses streamAndApplyRemote (same TCP path
// as rebuild tail replay).
if snapshotLSN > 0 {
// startExclusive = snapshotLSN - 1 so FromLSN = snapshotLSN.
_, err := e.streamAndApplyRemote(snapshotLSN-1, committedLSN)
if err != nil {
return 0, fmt.Errorf("rebuild second catch-up: %w", err)
}
log.Printf("v2bridge: TransferFullBase phase 2 complete: second catch-up snapshotLSN=%d→target=%d",
snapshotLSN, committedLSN)
}
// achievedLSN: the actual boundary after all phases.
achievedLSN := e.vol.StatusSnapshot().WALHeadLSN
e.vol.SyncReceiverProgress(achievedLSN)
log.Printf("v2bridge: TransferFullBase done: target=%d achieved=%d", committedLSN, achievedLSN)
return achievedLSN, nil
}
// transferExtent connects to the rebuild server, receives the full extent,
// and installs it with full state handoff. Returns the server's snapshotLSN.
func (e *Executor) transferExtent() (snapshotLSN uint64, err error) {
conn, err := net.Dial("tcp", e.rebuildAddr)
if err != nil {
return 0, fmt.Errorf("rebuild connect %s: %w", e.rebuildAddr, err)
}
defer conn.Close()
req := blockvol.RebuildRequest{
Type: blockvol.RebuildFullExtent,
Epoch: e.vol.Epoch(),
}
if err := blockvol.WriteFrame(conn, blockvol.MsgRebuildReq, blockvol.EncodeRebuildRequest(req)); err != nil {
return 0, fmt.Errorf("rebuild send request: %w", err)
}
installer := e.vol.NewRebuildInstaller()
for {
msgType, payload, err := blockvol.ReadFrame(conn)
if err != nil {
return 0, fmt.Errorf("rebuild read frame: %w", err)
}
switch msgType {
case blockvol.MsgRebuildExtent:
if err := installer.WriteChunk(payload); err != nil {
return 0, fmt.Errorf("rebuild install chunk: %w", err)
}
case blockvol.MsgRebuildDone:
if len(payload) >= 8 {
snapshotLSN = binary.BigEndian.Uint64(payload[:8])
}
if err := installer.Commit(snapshotLSN); err != nil {
return 0, fmt.Errorf("rebuild install commit: %w", err)
}
log.Printf("v2bridge: extent installed: %d bytes, snapshotLSN=%d from %s",
installer.BytesWritten(), snapshotLSN, e.rebuildAddr)
return snapshotLSN, nil
case blockvol.MsgRebuildError:
return 0, fmt.Errorf("rebuild server error: %s", string(payload))
default:
return 0, fmt.Errorf("rebuild unexpected message 0x%02x", msgType)
}
}
}
// TransferSnapshot connects to the primary's rebuild server, requests an
// exact snapshot export at snapshotLSN, streams the image directly to disk
// (no memory buffering), verifies SHA-256, and converges all local runtime
// state to snapshotLSN.
//
// Unlike TransferFullBase (conservative >= target), TransferSnapshot
// requires an EXACT boundary. This is enforced at three levels:
// - server: verifies checkpoint == requestedLSN before export
// - manifest: carries explicit BaseLSN
// - client: verifies manifest.BaseLSN == snapshotLSN before commit
//
// On partial failure (mid-stream disconnect, write error), the extent
// may contain mixed data. This is the same limitation as P1 full-base
// and V1 rebuild: on failure, the engine does not complete the rebuild,
// and master re-issues the assignment for a fresh attempt.
func (e *Executor) TransferSnapshot(snapshotLSN uint64) error {
if e.vol == nil {
return fmt.Errorf("no blockvol instance")
}
snap := e.vol.StatusSnapshot()
if snap.CheckpointLSN != snapshotLSN {
return fmt.Errorf("no checkpoint at LSN %d (have %d)", snapshotLSN, snap.CheckpointLSN)
if e.rebuildAddr == "" {
return fmt.Errorf("no rebuild address configured")
}
conn, err := net.Dial("tcp", e.rebuildAddr)
if err != nil {
return fmt.Errorf("snapshot connect %s: %w", e.rebuildAddr, err)
}
defer conn.Close()
req := blockvol.RebuildRequest{
Type: blockvol.RebuildSnapshot,
FromLSN: snapshotLSN,
Epoch: e.vol.Epoch(),
}
if err := blockvol.WriteFrame(conn, blockvol.MsgRebuildReq, blockvol.EncodeRebuildRequest(req)); err != nil {
return fmt.Errorf("snapshot send request: %w", err)
}
// Stream snapshot image directly to disk via RebuildInstaller.
// Compute SHA-256 inline — no memory buffering of the full image.
installer := e.vol.NewRebuildInstaller()
hash := sha256.New()
var manifestJSON []byte
var serverBaseLSN uint64
for {
msgType, payload, err := blockvol.ReadFrame(conn)
if err != nil {
return fmt.Errorf("snapshot read frame: %w", err)
}
switch msgType {
case blockvol.MsgRebuildExtent:
// Write chunk to extent AND hash inline.
hash.Write(payload)
if err := installer.WriteChunk(payload); err != nil {
return fmt.Errorf("snapshot install chunk: %w", err)
}
case blockvol.MsgRebuildEntry:
// Manifest (JSON). Sent after all extent chunks.
manifestJSON = payload
case blockvol.MsgRebuildDone:
if len(payload) >= 8 {
serverBaseLSN = binary.BigEndian.Uint64(payload[:8])
}
goto transferComplete
case blockvol.MsgRebuildError:
return fmt.Errorf("snapshot server error: %s", string(payload))
default:
return fmt.Errorf("snapshot unexpected message 0x%02x", msgType)
}
}
transferComplete:
// Validate server boundary.
if serverBaseLSN != snapshotLSN {
return fmt.Errorf("snapshot boundary mismatch: server=%d requested=%d",
serverBaseLSN, snapshotLSN)
}
// Parse and validate manifest.
if len(manifestJSON) == 0 {
return fmt.Errorf("snapshot: no manifest received")
}
manifest, err := blockvol.UnmarshalManifest(manifestJSON)
if err != nil {
return fmt.Errorf("snapshot manifest: %w", err)
}
if manifest.BaseLSN != snapshotLSN {
return fmt.Errorf("snapshot manifest BaseLSN=%d != requested %d",
manifest.BaseLSN, snapshotLSN)
}
// Verify SHA-256 (computed inline during streaming).
gotHash := hex.EncodeToString(hash.Sum(nil))
if gotHash != manifest.SHA256 {
return fmt.Errorf("snapshot checksum mismatch: got %s, want %s", gotHash, manifest.SHA256)
}
// Commit: state handoff with exact snapshot boundary.
// snapshotLSN IS the last entry (BaseLSN). Pass snapshotLSN+1 to Commit
// so nextLSN = snapshotLSN+1 and checkpointLSN = snapshotLSN.
if err := installer.Commit(snapshotLSN + 1); err != nil {
return fmt.Errorf("snapshot install commit: %w", err)
}
log.Printf("v2bridge: TransferSnapshot installed: %d bytes, BaseLSN=%d, SHA256 verified",
installer.BytesWritten(), snapshotLSN)
return nil
}
// TransferFullBase reads the full extent image from blockvol for rebuild.
// In production: streams the extent to the replica over network.
// Here: validates the extent is readable at the committed boundary.
func (e *Executor) TransferFullBase(committedLSN uint64) error {
// TruncateWAL performs real local correction for replica-ahead recovery.
//
// Detection rule: truncation is safe only when the kept base boundary already
// matches the local checkpoint. This is determined inside `TruncateToLSN`
// after the flusher is paused and I/O is drained:
// - CheckpointLSN == truncateLSN: safe — extent has the exact kept base,
// and ahead entries exist only above that boundary.
// - CheckpointLSN != truncateLSN: unsafe — either ahead entries already
// contaminated extent (`>`) or part of the kept range still exists only
// in WAL (`<`). Returns an error so the engine escalates to rebuild.
//
// On success (truncation-safe case): delegates to blockvol.TruncateToLSN
// which pauses the flusher, clears dirty map, resets WAL, and converges
// all runtime state to exactly truncateLSN.
func (e *Executor) TruncateWAL(truncateLSN uint64) error {
if e.vol == nil {
return fmt.Errorf("no blockvol instance")
}
snap := e.vol.StatusSnapshot()
if committedLSN > snap.WALHeadLSN {
return fmt.Errorf("committed LSN %d beyond WAL head %d", committedLSN, snap.WALHeadLSN)
if err := e.vol.TruncateToLSN(truncateLSN); err != nil {
// If blockvol reports truncation unsafe, wrap with the engine's
// sentinel so CatchUpExecutor can detect and escalate to rebuild.
if errors.Is(err, blockvol.ErrTruncationUnsafe) {
return fmt.Errorf("%w: %v", engine.ErrTruncationUnsafe, err)
}
return fmt.Errorf("truncate WAL to %d: %w", truncateLSN, err)
}
// In production: read extent blocks and stream to replica.
// For now: validate the extent is accessible at this point.
log.Printf("v2bridge: TruncateWAL complete: truncateLSN=%d", truncateLSN)
return nil
}
// TruncateWAL removes entries beyond truncateLSN. Stub for P1.
func (e *Executor) TruncateWAL(truncateLSN uint64) error {
return fmt.Errorf("TruncateWAL not implemented in P1")
}
@@ -175,7 +175,7 @@ func TestP2_FC3_RealCatchUp_Forced(t *testing.T) {
// Even though engine classifies as ZeroGap (committed=0),
// we can verify the real WAL scan works by directly streaming.
executor := NewExecutor(vol)
executor := NewExecutor(vol, "")
transferred, err := executor.StreamWALEntries(0, state.WALHeadLSN)
if err != nil {
t.Fatalf("FC3: real WAL scan failed: %v", err)
@@ -22,7 +22,7 @@ func setupHardening(t *testing.T) (*engine.RecoveryDriver, *bridge.ControlAdapte
reader := NewReader(vol)
pinner := NewPinner(vol)
executor := NewExecutor(vol)
executor := NewExecutor(vol, "")
sa := bridge.NewStorageAdapter(&readerShim{reader}, &pinnerShim{pinner})
ca := bridge.NewControlAdapter()
@@ -157,7 +157,7 @@ func TestP3_Matrix_StaleEpoch(t *testing.T) {
// --- Matrix 3: Unrecoverable gap / needs-rebuild ---
func TestP3_Matrix_NeedsRebuild(t *testing.T) {
driver, ca, reader, executor, pinner := setupHardening(t)
driver, ca, reader, _, pinner := setupHardening(t)
vol := reader.vol
for i := 0; i < 20; i++ {
@@ -191,8 +191,9 @@ func TestP3_Matrix_NeedsRebuild(t *testing.T) {
driver.Orchestrator.ProcessAssignment(rebuildIntent)
rebuildPlan, _ := driver.PlanRebuild("v1/vs2")
// IO=nil: FSM test mode. Real snapshot_tail I/O is proven by
// TestP2_SnapshotTailRebuild_OneChain in snapshot_transfer_test.go.
exec := engine.NewRebuildExecutor(driver, rebuildPlan)
exec.IO = executor
if err := exec.Execute(); err != nil {
t.Fatal(err)
}
@@ -0,0 +1,194 @@
package v2bridge
import (
"testing"
"github.com/seaweedfs/seaweedfs/weed/storage/blockvol"
)
// ============================================================
// Phase 09 P2: Adversarial tests for snapshot transfer
// ============================================================
// --- Adversarial 1: Snapshot on non-empty replica with higher state ---
func TestAdversarial_SnapshotOverwritesHigherState(t *testing.T) {
dir := t.TempDir()
// Primary: 10 entries, flush → checkpoint at 10.
primaryVol, checkpointLSN := setupSnapshotPrimary(t, dir, 10)
defer primaryVol.Close()
rebuildServer, err := blockvol.NewRebuildServer(primaryVol, "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
rebuildServer.Serve()
defer rebuildServer.Stop()
// Replica: has MORE data than primary (20 entries, different pattern).
replicaVol := createTestVolNamed(t, dir, "replica.blockvol")
defer replicaVol.Close()
for i := 0; i < 20; i++ {
replicaVol.WriteLBA(uint64(i), makeBlock(byte('Z')))
}
replicaBefore := NewReader(replicaVol).ReadState()
t.Logf("replica before: head=%d (higher than primary checkpoint=%d)",
replicaBefore.WALHeadLSN, checkpointLSN)
executor := NewExecutor(replicaVol, rebuildServer.Addr())
if err := executor.TransferSnapshot(checkpointLSN); err != nil {
t.Fatalf("TransferSnapshot: %v", err)
}
// Replica must now match primary (not have its old 'Z' data).
verifyLBAMatch(t, primaryVol, replicaVol, 10)
// Runtime must converge to snapshot boundary, NOT to old higher state.
replicaAfter := NewReader(replicaVol).ReadState()
if replicaAfter.WALHeadLSN != checkpointLSN {
t.Fatalf("WALHeadLSN=%d, want %d (must converge DOWN to snapshot)",
replicaAfter.WALHeadLSN, checkpointLSN)
}
t.Logf("adversarial 1: snapshot correctly overwrote higher replica state (%d → %d)",
replicaBefore.WALHeadLSN, checkpointLSN)
}
// --- Adversarial 2: Full-base achievedLSN > target with bounded second catch-up ---
func TestAdversarial_FullBase_SecondCatchUpBounded(t *testing.T) {
dir := t.TempDir()
primaryVol := createTestVolNamed(t, dir, "primary.blockvol")
defer primaryVol.Close()
// Write 10, flush → checkpoint at 10.
for i := 0; i < 10; i++ {
primaryVol.WriteLBA(uint64(i), makeBlock(byte('A'+i%26)))
}
primaryVol.ForceFlush()
// Write 10 more (tail, unflushed).
for i := 10; i < 20; i++ {
primaryVol.WriteLBA(uint64(i), makeBlock(byte('T')))
}
// Capture target BEFORE rebuild server starts.
targetLSN := uint64(10) // only want up to the checkpoint
rebuildServer, err := blockvol.NewRebuildServer(primaryVol, "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
rebuildServer.Serve()
defer rebuildServer.Stop()
replicaVol := createTestVolNamed(t, dir, "replica.blockvol")
defer replicaVol.Close()
executor := NewExecutor(replicaVol, rebuildServer.Addr())
// TransferFullBase with targetLSN=10 (rebuild server will flush + snapshot
// everything including the tail entries → achievedLSN will be > 10).
achievedLSN, err := executor.TransferFullBase(targetLSN)
if err != nil {
t.Fatalf("TransferFullBase: %v", err)
}
t.Logf("full-base: target=%d achieved=%d (server included tail entries)",
targetLSN, achievedLSN)
// achievedLSN must be >= target (may be higher due to server flush).
if achievedLSN < targetLSN {
t.Fatalf("achievedLSN=%d < target=%d", achievedLSN, targetLSN)
}
// The second catch-up should have been bounded to targetLSN.
// Any entries applied should not exceed target.
// (TransferFullBase's secondCatchUp bounds to targetLSN.)
// Verify at least the first 10 LBAs match.
verifyLBAMatch(t, primaryVol, replicaVol, 10)
t.Logf("adversarial 2: full-base second catch-up bounded correctly (target=%d achieved=%d)",
targetLSN, achievedLSN)
}
// --- Adversarial 3: Double snapshot rebuild on same replica ---
func TestAdversarial_DoubleSnapshotRebuild(t *testing.T) {
dir := t.TempDir()
primaryVol := createTestVolNamed(t, dir, "primary.blockvol")
defer primaryVol.Close()
// First era: 'A' data.
for i := 0; i < 10; i++ {
primaryVol.WriteLBA(uint64(i), makeBlock(byte('A'+i%26)))
}
primaryVol.ForceFlush()
checkpoint1 := NewReader(primaryVol).ReadState().CheckpointLSN
rebuildServer1, err := blockvol.NewRebuildServer(primaryVol, "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
rebuildServer1.Serve()
replicaVol := createTestVolNamed(t, dir, "replica.blockvol")
defer replicaVol.Close()
// First snapshot rebuild.
executor1 := NewExecutor(replicaVol, rebuildServer1.Addr())
if err := executor1.TransferSnapshot(checkpoint1); err != nil {
t.Fatalf("first snapshot: %v", err)
}
rebuildServer1.Stop()
verifyLBAMatch(t, primaryVol, replicaVol, 10)
t.Logf("first rebuild: checkpoint=%d, data='A' verified", checkpoint1)
// Second era: overwrite with 'Z' data.
for i := 0; i < 10; i++ {
primaryVol.WriteLBA(uint64(i), makeBlock(byte('Z')))
}
primaryVol.ForceFlush()
checkpoint2 := NewReader(primaryVol).ReadState().CheckpointLSN
rebuildServer2, err := blockvol.NewRebuildServer(primaryVol, "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
rebuildServer2.Serve()
defer rebuildServer2.Stop()
// Second snapshot rebuild on SAME replica.
executor2 := NewExecutor(replicaVol, rebuildServer2.Addr())
if err := executor2.TransferSnapshot(checkpoint2); err != nil {
t.Fatalf("second snapshot: %v", err)
}
// Replica must have 'Z' data, not stale 'A'.
blockSize := replicaVol.Info().BlockSize
for i := 0; i < 10; i++ {
data, err := replicaVol.ReadLBA(uint64(i), blockSize)
if err != nil {
t.Fatalf("ReadLBA(%d): %v", i, err)
}
if data[0] != byte('Z') {
t.Fatalf("LBA %d: got %c, want 'Z' — stale first-rebuild data leaked", i, data[0])
}
}
// Runtime converged to second checkpoint.
state := NewReader(replicaVol).ReadState()
if state.CheckpointLSN != checkpoint2 {
t.Fatalf("checkpoint=%d, want %d", state.CheckpointLSN, checkpoint2)
}
t.Logf("adversarial 3: double snapshot — second rebuild correctly replaced first (checkpoint %d → %d)",
checkpoint1, checkpoint2)
}
@@ -0,0 +1,384 @@
package v2bridge
import (
"os"
"path/filepath"
"testing"
bridge "github.com/seaweedfs/seaweedfs/sw-block/bridge/blockvol"
engine "github.com/seaweedfs/seaweedfs/sw-block/engine/replication"
"github.com/seaweedfs/seaweedfs/weed/storage/blockvol"
)
// ============================================================
// Phase 09 P2: Snapshot execution closure (snapshot_tail)
//
// Proofs:
// 1. Component: TCP snapshot transfer + exact boundary install
// 2. One-chain: engine plan → RebuildExecutor → TransferSnapshot → tail replay → InSync
// 3. Boundary-drift: checkpoint advances after plan → fail closed
// 4. Convergence: post-import runtime = snapshotLSN, post-tail = targetLSN
// 5. Cleanup: temp snapshot released on all paths
// ============================================================
// setupSnapshotPrimary creates a primary vol with data, flushes it, and
// returns the vol + its checkpoint LSN (= snapshot boundary for snapshot_tail).
func setupSnapshotPrimary(t *testing.T, dir string, lbaCount int) (*blockvol.BlockVol, uint64) {
t.Helper()
vol := createTestVolNamed(t, dir, "primary.blockvol")
for i := 0; i < lbaCount; i++ {
vol.WriteLBA(uint64(i), makeBlock(byte('A'+i%26)))
}
vol.ForceFlush()
state := NewReader(vol).ReadState()
return vol, state.CheckpointLSN
}
// --- Component Proof: TCP snapshot transfer + exact boundary ---
func TestP2_TransferSnapshot_RealTCP(t *testing.T) {
dir := t.TempDir()
primaryVol, checkpointLSN := setupSnapshotPrimary(t, dir, 20)
defer primaryVol.Close()
t.Logf("primary checkpoint: %d", checkpointLSN)
if checkpointLSN == 0 {
t.Fatal("checkpoint must be > 0 after flush")
}
rebuildServer, err := blockvol.NewRebuildServer(primaryVol, "127.0.0.1:0")
if err != nil {
t.Fatalf("NewRebuildServer: %v", err)
}
rebuildServer.Serve()
defer rebuildServer.Stop()
replicaVol := createTestVolNamed(t, dir, "replica.blockvol")
defer replicaVol.Close()
executor := NewExecutor(replicaVol, rebuildServer.Addr())
// Transfer snapshot at exact checkpointLSN.
if err := executor.TransferSnapshot(checkpointLSN); err != nil {
t.Fatalf("TransferSnapshot: %v", err)
}
// Verify: replica data matches primary at the snapshot boundary.
verifyLBAMatch(t, primaryVol, replicaVol, 20)
// Verify: local runtime converged to exact snapshotLSN.
replicaState := NewReader(replicaVol).ReadState()
if replicaState.CheckpointLSN != checkpointLSN {
t.Fatalf("checkpoint: got %d, want %d", replicaState.CheckpointLSN, checkpointLSN)
}
if replicaState.WALHeadLSN != checkpointLSN {
t.Fatalf("WALHeadLSN: got %d, want %d", replicaState.WALHeadLSN, checkpointLSN)
}
t.Logf("P2 component: snapshot transferred at exact BaseLSN=%d, runtime converged", checkpointLSN)
}
// --- One-Chain Proof: engine → TransferSnapshot → tail replay → InSync ---
func TestP2_SnapshotTailRebuild_OneChain(t *testing.T) {
dir := t.TempDir()
// Primary: write 10 entries, flush (creates checkpoint), then write 5 more (tail).
primaryVol := createTestVolNamed(t, dir, "primary.blockvol")
defer primaryVol.Close()
for i := 0; i < 10; i++ {
primaryVol.WriteLBA(uint64(i), makeBlock(byte('A'+i%26)))
}
primaryVol.ForceFlush()
checkpointLSN := NewReader(primaryVol).ReadState().CheckpointLSN
t.Logf("checkpoint after first flush: %d", checkpointLSN)
// Write tail entries AFTER checkpoint.
for i := 10; i < 15; i++ {
primaryVol.WriteLBA(uint64(i), makeBlock(byte('A'+i%26)))
}
primaryState := NewReader(primaryVol).ReadState()
t.Logf("primary: head=%d tail=%d committed=%d checkpoint=%d",
primaryState.WALHeadLSN, primaryState.WALTailLSN,
primaryState.CommittedLSN, primaryState.CheckpointLSN)
rebuildServer, err := blockvol.NewRebuildServer(primaryVol, "127.0.0.1:0")
if err != nil {
t.Fatalf("NewRebuildServer: %v", err)
}
rebuildServer.Serve()
defer rebuildServer.Stop()
replicaVol := createTestVolNamed(t, dir, "replica.blockvol")
defer replicaVol.Close()
// Engine setup: StorageAdapter reads from PRIMARY with TRUSTED checkpoint.
// This forces snapshot_tail path (checkpoint is trusted + replayable tail).
primaryReader := NewReader(primaryVol)
primaryPinner := NewPinner(primaryVol)
sa := bridge.NewStorageAdapter(
&readerShim{primaryReader},
&pinnerShim{primaryPinner},
)
ca := bridge.NewControlAdapter()
driver := engine.NewRecoveryDriver(sa)
// Assignment + plan.
intent := ca.ToAssignmentIntent(
bridge.MasterAssignment{VolumeName: "vol1", Epoch: 1, Role: "primary"},
[]bridge.MasterAssignment{
{VolumeName: "vol1", ReplicaServerID: "vs2", Role: "replica",
DataAddr: "10.0.0.2:9333", CtrlAddr: "10.0.0.2:9334"},
},
)
driver.Orchestrator.ProcessAssignment(intent)
plan, _ := driver.PlanRecovery("vol1/vs2", 0)
if plan.Outcome != engine.OutcomeNeedsRebuild {
t.Fatalf("outcome=%s", plan.Outcome)
}
rebuildIntent := ca.ToAssignmentIntent(
bridge.MasterAssignment{VolumeName: "vol1", Epoch: 1, Role: "primary"},
[]bridge.MasterAssignment{
{VolumeName: "vol1", ReplicaServerID: "vs2", Role: "rebuilding",
DataAddr: "10.0.0.2:9333", CtrlAddr: "10.0.0.2:9334"},
},
)
driver.Orchestrator.ProcessAssignment(rebuildIntent)
rebuildPlan, err := driver.PlanRebuild("vol1/vs2")
if err != nil {
t.Fatalf("PlanRebuild: %v", err)
}
if rebuildPlan.RebuildSource != engine.RebuildSnapshotTail {
t.Fatalf("source=%s, want snapshot_tail", rebuildPlan.RebuildSource)
}
t.Logf("plan: source=%s snapshot=%d target=%d",
rebuildPlan.RebuildSource, rebuildPlan.RebuildSnapshotLSN, rebuildPlan.RebuildTargetLSN)
// Execute: single executor handles BOTH TransferSnapshot and
// StreamWALEntries. When rebuildAddr is set, StreamWALEntries connects
// to the primary via TCP and applies entries to the local replica.
// No test shim needed — this is the production path.
replicaExecutor := NewExecutor(replicaVol, rebuildServer.Addr())
exec := engine.NewRebuildExecutor(driver, rebuildPlan)
exec.IO = replicaExecutor
if err := exec.Execute(); err != nil {
t.Fatalf("Execute: %v", err)
}
// Verify sender state → InSync.
s := driver.Orchestrator.Registry.Sender("vol1/vs2")
if s.State() != engine.StateInSync {
t.Fatalf("state=%s, want InSync", s.State())
}
// Verify pins released.
if primaryPinner.ActiveHoldCount() != 0 {
t.Fatalf("%d pins leaked", primaryPinner.ActiveHoldCount())
}
// Verify all 15 LBAs match (10 from snapshot + 5 from tail replay).
verifyLBAMatch(t, primaryVol, replicaVol, 15)
// Verify observability.
events := driver.Orchestrator.Log.EventsFor("vol1/vs2")
hasStarted, hasCompleted := false, false
for _, ev := range events {
if ev.Event == "exec_rebuild_started" {
hasStarted = true
}
if ev.Event == "exec_rebuild_completed" {
hasCompleted = true
}
}
if !hasStarted || !hasCompleted {
t.Fatalf("observability: started=%v completed=%v", hasStarted, hasCompleted)
}
t.Log("P2 one-chain: plan(snapshot_tail) → TransferSnapshot → tail replay → InSync → data verified")
}
// --- Boundary-drift: checkpoint advances after plan → fail closed ---
func TestP2_TransferSnapshot_BoundaryDrift(t *testing.T) {
dir := t.TempDir()
primaryVol, checkpointLSN := setupSnapshotPrimary(t, dir, 10)
defer primaryVol.Close()
// Write more + flush → checkpoint advances past the original value.
for i := 10; i < 20; i++ {
primaryVol.WriteLBA(uint64(i), makeBlock(byte('X')))
}
primaryVol.ForceFlush()
newCheckpoint := NewReader(primaryVol).ReadState().CheckpointLSN
t.Logf("checkpoint advanced: %d → %d", checkpointLSN, newCheckpoint)
if newCheckpoint == checkpointLSN {
t.Fatal("checkpoint must have advanced for boundary-drift test")
}
rebuildServer, err := blockvol.NewRebuildServer(primaryVol, "127.0.0.1:0")
if err != nil {
t.Fatalf("NewRebuildServer: %v", err)
}
rebuildServer.Serve()
defer rebuildServer.Stop()
replicaVol := createTestVolNamed(t, dir, "replica.blockvol")
defer replicaVol.Close()
executor := NewExecutor(replicaVol, rebuildServer.Addr())
// Request snapshot at OLD checkpoint → server should reject (boundary mismatch).
err = executor.TransferSnapshot(checkpointLSN)
if err == nil {
t.Fatal("should fail: checkpoint advanced past requested boundary")
}
t.Logf("boundary drift: %v", err)
}
// --- Fail-Closed: no rebuild address ---
func TestP2_TransferSnapshot_NoAddress(t *testing.T) {
vol := createTestVol(t)
defer vol.Close()
executor := NewExecutor(vol, "")
err := executor.TransferSnapshot(10)
if err == nil {
t.Fatal("should fail without rebuild address")
}
}
// --- Convergence: post-import runtime state ---
func TestP2_TransferSnapshot_RuntimeConvergence(t *testing.T) {
dir := t.TempDir()
primaryVol, checkpointLSN := setupSnapshotPrimary(t, dir, 20)
defer primaryVol.Close()
rebuildServer, err := blockvol.NewRebuildServer(primaryVol, "127.0.0.1:0")
if err != nil {
t.Fatalf("NewRebuildServer: %v", err)
}
rebuildServer.Serve()
defer rebuildServer.Stop()
// Replica: has stale higher state (like the P1 stale-higher test).
replicaVol := createTestVolNamed(t, dir, "replica.blockvol")
defer replicaVol.Close()
for i := 0; i < 30; i++ {
replicaVol.WriteLBA(uint64(i), makeBlock(byte('R')))
}
if err := replicaVol.StartReplicaReceiver("127.0.0.1:0", "127.0.0.1:0"); err != nil {
t.Fatalf("StartReplicaReceiver: %v", err)
}
staleState := NewReader(replicaVol).ReadState()
staleRecv := replicaVol.ReceivedLSN()
t.Logf("replica before: head=%d receivedLSN=%d", staleState.WALHeadLSN, staleRecv)
executor := NewExecutor(replicaVol, rebuildServer.Addr())
if err := executor.TransferSnapshot(checkpointLSN); err != nil {
t.Fatalf("TransferSnapshot: %v", err)
}
// All runtime state must converge to checkpointLSN (exact, not conservative).
postState := NewReader(replicaVol).ReadState()
postRecv := replicaVol.ReceivedLSN()
if postState.WALHeadLSN != checkpointLSN {
t.Fatalf("WALHeadLSN=%d != checkpointLSN=%d", postState.WALHeadLSN, checkpointLSN)
}
if postState.CheckpointLSN != checkpointLSN {
t.Fatalf("CheckpointLSN=%d != snapshotLSN=%d", postState.CheckpointLSN, checkpointLSN)
}
if postRecv != checkpointLSN {
t.Fatalf("receivedLSN=%d != snapshotLSN=%d", postRecv, checkpointLSN)
}
t.Logf("convergence: staleHead=%d staleRecv=%d → all converged to %d",
staleState.WALHeadLSN, staleRecv, checkpointLSN)
}
// --- Temp snapshot cleanup verification ---
func TestP2_TransferSnapshot_TempSnapshotCleaned(t *testing.T) {
dir := t.TempDir()
primaryVol, checkpointLSN := setupSnapshotPrimary(t, dir, 10)
defer primaryVol.Close()
snapsBefore := primaryVol.ListSnapshots()
rebuildServer, err := blockvol.NewRebuildServer(primaryVol, "127.0.0.1:0")
if err != nil {
t.Fatalf("NewRebuildServer: %v", err)
}
rebuildServer.Serve()
defer rebuildServer.Stop()
replicaVol := createTestVolNamed(t, dir, "replica.blockvol")
defer replicaVol.Close()
executor := NewExecutor(replicaVol, rebuildServer.Addr())
if err := executor.TransferSnapshot(checkpointLSN); err != nil {
t.Fatalf("TransferSnapshot: %v", err)
}
// Verify: no leaked temp snapshots on primary after transfer.
snapsAfter := primaryVol.ListSnapshots()
if len(snapsAfter) != len(snapsBefore) {
// Find the leaked snapshot.
leaked := []uint32{}
beforeSet := map[uint32]bool{}
for _, s := range snapsBefore {
beforeSet[s.ID] = true
}
for _, s := range snapsAfter {
if !beforeSet[s.ID] {
leaked = append(leaked, s.ID)
}
}
t.Fatalf("temp snapshot leaked: before=%d after=%d leaked=%v",
len(snapsBefore), len(snapsAfter), leaked)
}
// Also check on failure path: request with wrong boundary.
for i := 10; i < 15; i++ {
primaryVol.WriteLBA(uint64(i), makeBlock(byte('X')))
}
primaryVol.ForceFlush()
subDir := filepath.Join(dir, "sub")
if err := os.MkdirAll(subDir, 0755); err != nil {
t.Fatalf("mkdir: %v", err)
}
r2Vol := createTestVolNamed(t, subDir, "r2.blockvol")
defer r2Vol.Close()
executor2 := NewExecutor(r2Vol, rebuildServer.Addr())
// This should fail (boundary drift).
_ = executor2.TransferSnapshot(checkpointLSN)
// Verify: still no leaked snapshots after failure.
snapsAfterFail := primaryVol.ListSnapshots()
if len(snapsAfterFail) != len(snapsBefore) {
t.Fatalf("temp snapshot leaked after failure: before=%d after=%d",
len(snapsBefore), len(snapsAfterFail))
}
t.Log("P2 cleanup: temp snapshots cleaned on success and failure paths")
}
@@ -0,0 +1,243 @@
package v2bridge
import (
"bytes"
"net"
"testing"
"github.com/seaweedfs/seaweedfs/weed/storage/blockvol"
)
// ============================================================
// Phase 09 P1: Adversarial tests for full-base rebuild transfer
//
// These tests verify safety under failure and concurrent mutation:
// 1. Server dies mid-transfer: replica extent must not be half-installed
// 2. Concurrent writes during rebuild: achievedLSN correct, no corruption
// 3. Double rebuild (cancel + restart): second rebuild sees clean state
// ============================================================
// --- Adversarial 1: Server dies mid-transfer, replica state must be safe ---
func TestAdversarial_ServerDiesMidTransfer_ReplicaStateClean(t *testing.T) {
dir := t.TempDir()
replicaVol := createTestVolNamed(t, dir, "replica.blockvol")
defer replicaVol.Close()
// Write pre-existing data on replica (simulates stale state).
for i := 0; i < 5; i++ {
replicaVol.WriteLBA(uint64(i), makeBlock(byte('R')))
}
replicaStateBefore := NewReader(replicaVol).ReadState()
// Fake server: sends a few extent chunks, then drops connection
// before sending MsgRebuildDone. The extent should NOT be installed.
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
go func() {
conn, err := ln.Accept()
if err != nil {
return
}
// Read request frame (discard).
blockvol.ReadFrame(conn)
// Send 3 extent chunks (partial extent).
for i := 0; i < 3; i++ {
chunk := make([]byte, 4096)
for j := range chunk {
chunk[j] = byte('X') // different from replica's 'R'
}
blockvol.WriteFrame(conn, blockvol.MsgRebuildExtent, chunk)
}
// Drop connection — no MsgRebuildDone sent.
conn.Close()
}()
executor := NewExecutor(replicaVol, ln.Addr().String())
_, err = executor.TransferFullBase(100)
if err == nil {
t.Fatal("should fail when server dies mid-transfer")
}
// KEY ASSERTION: replica's pre-existing data must still be readable.
// The partial extent must NOT have been committed.
replicaStateAfter := NewReader(replicaVol).ReadState()
// WALHeadLSN should not have been reset by a partial install.
if replicaStateAfter.WALHeadLSN != replicaStateBefore.WALHeadLSN {
t.Fatalf("WALHeadLSN changed after failed transfer: before=%d after=%d",
replicaStateBefore.WALHeadLSN, replicaStateAfter.WALHeadLSN)
}
// Pre-existing data should still be readable (stale 'R' blocks).
blockSize := replicaVol.Info().BlockSize
for i := 0; i < 5; i++ {
data, err := replicaVol.ReadLBA(uint64(i), blockSize)
if err != nil {
t.Fatalf("ReadLBA(%d) after failed transfer: %v", i, err)
}
expected := makeBlock(byte('R'))
if !bytes.Equal(data, expected) {
t.Fatalf("LBA %d corrupted after failed transfer: got[0]=%d want='R'(%d)",
i, data[0], byte('R'))
}
}
t.Log("adversarial 1: server died mid-transfer — replica state is clean, pre-existing data intact")
}
// --- Adversarial 2: Concurrent writes during rebuild ---
func TestAdversarial_ConcurrentWritesDuringRebuild(t *testing.T) {
dir := t.TempDir()
primaryVol := createTestVolNamed(t, dir, "primary.blockvol")
defer primaryVol.Close()
// Write initial data + flush.
for i := 0; i < 10; i++ {
primaryVol.WriteLBA(uint64(i), makeBlock(byte('A'+i%26)))
}
primaryVol.ForceFlush()
primaryStateBefore := NewReader(primaryVol).ReadState()
t.Logf("primary before extra writes: head=%d checkpoint=%d",
primaryStateBefore.WALHeadLSN, primaryStateBefore.CheckpointLSN)
// Start rebuild server.
rebuildServer, err := blockvol.NewRebuildServer(primaryVol, "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
rebuildServer.Serve()
defer rebuildServer.Stop()
// Write MORE data on primary AFTER rebuild server started.
// These writes happen while the rebuild transfer is in progress.
for i := 10; i < 20; i++ {
primaryVol.WriteLBA(uint64(i), makeBlock(byte('Z')))
}
primaryVol.ForceFlush()
primaryStateAfter := NewReader(primaryVol).ReadState()
t.Logf("primary after extra writes: head=%d checkpoint=%d",
primaryStateAfter.WALHeadLSN, primaryStateAfter.CheckpointLSN)
// Replica: empty vol.
replicaVol := createTestVolNamed(t, dir, "replica.blockvol")
defer replicaVol.Close()
executor := NewExecutor(replicaVol, rebuildServer.Addr())
// Transfer with the ORIGINAL target (before extra writes).
achievedLSN, err := executor.TransferFullBase(primaryStateBefore.CommittedLSN)
if err != nil {
t.Fatalf("TransferFullBase: %v", err)
}
t.Logf("achievedLSN=%d (target was %d)", achievedLSN, primaryStateBefore.CommittedLSN)
// achievedLSN must be >= original target.
if achievedLSN < primaryStateBefore.CommittedLSN {
t.Fatalf("achievedLSN=%d < target=%d", achievedLSN, primaryStateBefore.CommittedLSN)
}
// The rebuild server's snapshot should include ALL flushed data
// (including the extra writes), so achievedLSN should be >= the
// extra writes' head.
if achievedLSN < primaryStateAfter.CommittedLSN {
t.Logf("note: achievedLSN=%d < post-write committed=%d (snapshot was taken between flushes)",
achievedLSN, primaryStateAfter.CommittedLSN)
}
// Verify data integrity: at minimum, the original 10 LBAs must match.
verifyLBAMatch(t, primaryVol, replicaVol, 10)
t.Logf("adversarial 2: concurrent writes during rebuild — achievedLSN=%d, data integrity verified", achievedLSN)
}
// --- Adversarial 3: Double rebuild (first cancelled, second must see clean state) ---
func TestAdversarial_DoubleRebuild_SecondSeesCleanState(t *testing.T) {
dir := t.TempDir()
primaryVol := createTestVolNamed(t, dir, "primary.blockvol")
defer primaryVol.Close()
for i := 0; i < 10; i++ {
primaryVol.WriteLBA(uint64(i), makeBlock(byte('A'+i%26)))
}
primaryVol.ForceFlush()
rebuildServer, err := blockvol.NewRebuildServer(primaryVol, "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
rebuildServer.Serve()
defer rebuildServer.Stop()
replicaVol := createTestVolNamed(t, dir, "replica.blockvol")
defer replicaVol.Close()
// First rebuild: succeeds.
executor1 := NewExecutor(replicaVol, rebuildServer.Addr())
achievedLSN1, err := executor1.TransferFullBase(0)
if err != nil {
t.Fatalf("first rebuild: %v", err)
}
t.Logf("first rebuild: achievedLSN=%d", achievedLSN1)
// Verify first rebuild installed data correctly.
verifyLBAMatch(t, primaryVol, replicaVol, 10)
// Now: primary writes NEW data (different pattern).
for i := 0; i < 10; i++ {
primaryVol.WriteLBA(uint64(i), makeBlock(byte('Z')))
}
primaryVol.ForceFlush()
// Restart rebuild server (simulates new rebuild after epoch bump).
rebuildServer.Stop()
rebuildServer2, err := blockvol.NewRebuildServer(primaryVol, "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
rebuildServer2.Serve()
defer rebuildServer2.Stop()
// Second rebuild on SAME replica (simulates cancelled first + restart).
executor2 := NewExecutor(replicaVol, rebuildServer2.Addr())
achievedLSN2, err := executor2.TransferFullBase(0)
if err != nil {
t.Fatalf("second rebuild: %v", err)
}
t.Logf("second rebuild: achievedLSN=%d", achievedLSN2)
// Second rebuild's achievedLSN must be >= first rebuild's.
if achievedLSN2 < achievedLSN1 {
t.Fatalf("second rebuild achievedLSN=%d < first=%d — state regression",
achievedLSN2, achievedLSN1)
}
// Verify: replica now has the NEW data ('Z'), not the old ('A').
blockSize := replicaVol.Info().BlockSize
for i := 0; i < 10; i++ {
data, err := replicaVol.ReadLBA(uint64(i), blockSize)
if err != nil {
t.Fatalf("ReadLBA(%d) after second rebuild: %v", i, err)
}
expected := makeBlock(byte('Z'))
if !bytes.Equal(data, expected) {
t.Fatalf("LBA %d after second rebuild: got[0]=%d want='Z'(%d) — stale first-rebuild data leaked",
i, data[0], byte('Z'))
}
}
t.Logf("adversarial 3: double rebuild — second rebuild installed new data correctly, no stale state from first")
}
@@ -0,0 +1,763 @@
package v2bridge
import (
"bytes"
"net"
"path/filepath"
"strings"
"testing"
bridge "github.com/seaweedfs/seaweedfs/sw-block/bridge/blockvol"
engine "github.com/seaweedfs/seaweedfs/sw-block/engine/replication"
"github.com/seaweedfs/seaweedfs/weed/storage/blockvol"
)
// ============================================================
// Phase 09 P1: Full-base execution closure
//
// Proofs:
// 1. Component: TCP transfer + local install (bridge level)
// 2. One-chain: engine plan → RebuildExecutor → v2bridge → blockvol install → completion
// 3. Fail-closed: connection refused, epoch mismatch, partial transfer, no address
// ============================================================
// createTestVolNamed creates a real file-backed BlockVol in the given dir.
func createTestVolNamed(t *testing.T, dir, name string) *blockvol.BlockVol {
t.Helper()
path := filepath.Join(dir, name)
v, err := blockvol.CreateBlockVol(path, blockvol.CreateOptions{
VolumeSize: 1 * 1024 * 1024,
BlockSize: 4096,
WALSize: 256 * 1024,
})
if err != nil {
t.Fatalf("CreateBlockVol %s: %v", name, err)
}
return v
}
// verifyLBAMatch reads LBAs from both vols and verifies they match.
func verifyLBAMatch(t *testing.T, primaryVol, replicaVol *blockvol.BlockVol, lbaCount int) {
t.Helper()
blockSize := primaryVol.Info().BlockSize
for i := 0; i < lbaCount; i++ {
pdata, perr := primaryVol.ReadLBA(uint64(i), blockSize)
rdata, rerr := replicaVol.ReadLBA(uint64(i), blockSize)
if perr != nil {
t.Fatalf("primary ReadLBA(%d): %v", i, perr)
}
if rerr != nil {
t.Fatalf("replica ReadLBA(%d): %v", i, rerr)
}
if !bytes.Equal(pdata, rdata) {
t.Fatalf("LBA %d mismatch: primary[0]=%d replica[0]=%d", i, pdata[0], rdata[0])
}
}
}
// --- Component Proof: TCP transfer + local install ---
func TestP1_TransferFullBase_RealTCP(t *testing.T) {
dir := t.TempDir()
// Primary: write data + flush to populate extent region.
primaryVol := createTestVolNamed(t, dir, "primary.blockvol")
defer primaryVol.Close()
for i := 0; i < 20; i++ {
primaryVol.WriteLBA(uint64(i), makeBlock(byte('A'+i%26)))
}
primaryVol.ForceFlush()
primaryState := NewReader(primaryVol).ReadState()
t.Logf("primary: head=%d tail=%d committed=%d checkpoint=%d",
primaryState.WALHeadLSN, primaryState.WALTailLSN,
primaryState.CommittedLSN, primaryState.CheckpointLSN)
// Start rebuild server on primary (existing V1 code).
rebuildServer, err := blockvol.NewRebuildServer(primaryVol, "127.0.0.1:0")
if err != nil {
t.Fatalf("NewRebuildServer: %v", err)
}
rebuildServer.Serve()
defer rebuildServer.Stop()
t.Logf("rebuild server on %s", rebuildServer.Addr())
// Replica: empty vol, same geometry.
replicaVol := createTestVolNamed(t, dir, "replica.blockvol")
defer replicaVol.Close()
// Create executor for replica, pointing to primary's rebuild server.
executor := NewExecutor(replicaVol, rebuildServer.Addr())
// Transfer full base.
if _, err := executor.TransferFullBase(primaryState.CommittedLSN); err != nil {
t.Fatalf("TransferFullBase: %v", err)
}
// Verify: replica LBA data matches primary (reads from extent since
// replica has no WAL entries or dirty map entries).
verifyLBAMatch(t, primaryVol, replicaVol, 20)
t.Log("P1 component proof: TCP transfer + local install verified — LBA data matches")
}
// --- One-Chain Proof: engine → executor → bridge → blockvol → completion ---
// untrustedReaderShim wraps a Reader but reports CheckpointTrusted=false.
// This forces the engine's RebuildSourceDecision to select RebuildFullBase
// instead of RebuildSnapshotTail, so the one-chain test exercises
// TransferFullBase specifically.
type untrustedReaderShim struct{ r *Reader }
func (s *untrustedReaderShim) ReadState() bridge.BlockVolState {
rs := s.r.ReadState()
return bridge.BlockVolState{
WALHeadLSN: rs.WALHeadLSN,
WALTailLSN: rs.WALTailLSN,
CommittedLSN: rs.CommittedLSN,
CheckpointLSN: rs.CheckpointLSN,
CheckpointTrusted: false, // force full-base path
}
}
func TestP1_FullBaseRebuild_OneChain(t *testing.T) {
dir := t.TempDir()
// Primary: write data + flush → force rebuild condition.
primaryVol := createTestVolNamed(t, dir, "primary.blockvol")
defer primaryVol.Close()
for i := 0; i < 20; i++ {
primaryVol.WriteLBA(uint64(i), makeBlock(byte('A'+i%26)))
}
primaryVol.ForceFlush()
primaryState := NewReader(primaryVol).ReadState()
if primaryState.WALTailLSN == 0 {
t.Fatal("ForceFlush must advance tail for rebuild condition")
}
// Start rebuild server on primary.
rebuildServer, err := blockvol.NewRebuildServer(primaryVol, "127.0.0.1:0")
if err != nil {
t.Fatalf("NewRebuildServer: %v", err)
}
rebuildServer.Serve()
defer rebuildServer.Stop()
// Replica: empty vol.
replicaVol := createTestVolNamed(t, dir, "replica.blockvol")
defer replicaVol.Close()
// Engine setup: StorageAdapter reads from PRIMARY (for planning).
// Use untrustedReaderShim to force full-base rebuild path so this
// test exercises TransferFullBase specifically (not TransferSnapshot).
primaryReader := NewReader(primaryVol)
primaryPinner := NewPinner(primaryVol)
sa := bridge.NewStorageAdapter(
&untrustedReaderShim{primaryReader},
&pinnerShim{primaryPinner},
)
ca := bridge.NewControlAdapter()
driver := engine.NewRecoveryDriver(sa)
// Step 1: assignment — register the replica sender.
intent := ca.ToAssignmentIntent(
bridge.MasterAssignment{VolumeName: "vol1", Epoch: 1, Role: "primary"},
[]bridge.MasterAssignment{
{VolumeName: "vol1", ReplicaServerID: "vs2", Role: "replica",
DataAddr: "10.0.0.2:9333", CtrlAddr: "10.0.0.2:9334"},
},
)
driver.Orchestrator.ProcessAssignment(intent)
// Step 2: plan recovery — replicaLSN=0 with tail>0 forces NeedsRebuild.
plan, err := driver.PlanRecovery("vol1/vs2", 0)
if err != nil {
t.Fatalf("PlanRecovery: %v", err)
}
if plan.Outcome != engine.OutcomeNeedsRebuild {
t.Fatalf("outcome=%s, want NeedsRebuild", plan.Outcome)
}
// Step 3: rebuild assignment — switch sender to rebuild session.
rebuildIntent := ca.ToAssignmentIntent(
bridge.MasterAssignment{VolumeName: "vol1", Epoch: 1, Role: "primary"},
[]bridge.MasterAssignment{
{VolumeName: "vol1", ReplicaServerID: "vs2", Role: "rebuilding",
DataAddr: "10.0.0.2:9333", CtrlAddr: "10.0.0.2:9334"},
},
)
driver.Orchestrator.ProcessAssignment(rebuildIntent)
// Step 4: plan rebuild from real storage.
rebuildPlan, err := driver.PlanRebuild("vol1/vs2")
if err != nil {
t.Fatalf("PlanRebuild: %v", err)
}
if rebuildPlan.RebuildSource != engine.RebuildFullBase {
t.Fatalf("source=%s, want full_base (untrusted checkpoint should force this)",
rebuildPlan.RebuildSource)
}
t.Logf("rebuild plan: source=%s target=%d", rebuildPlan.RebuildSource, rebuildPlan.RebuildTargetLSN)
// Step 5: RebuildExecutor with real IO wired to v2bridge executor (on replica vol).
replicaExecutor := NewExecutor(replicaVol, rebuildServer.Addr())
exec := engine.NewRebuildExecutor(driver, rebuildPlan)
exec.IO = replicaExecutor
if err := exec.Execute(); err != nil {
t.Fatalf("RebuildExecutor.Execute: %v", err)
}
// Step 6: verify sender state → InSync.
s := driver.Orchestrator.Registry.Sender("vol1/vs2")
if s.State() != engine.StateInSync {
t.Fatalf("state=%s, want InSync", s.State())
}
// Step 7: verify pins released.
if primaryPinner.ActiveHoldCount() != 0 {
t.Fatalf("%d pins leaked", primaryPinner.ActiveHoldCount())
}
// Step 8: verify LBA data matches.
verifyLBAMatch(t, primaryVol, replicaVol, 20)
// Step 9: verify observability — execution log shows rebuild events.
events := driver.Orchestrator.Log.EventsFor("vol1/vs2")
hasStarted := false
hasCompleted := false
for _, ev := range events {
if ev.Event == "exec_rebuild_started" {
hasStarted = true
}
if ev.Event == "exec_rebuild_completed" {
hasCompleted = true
}
}
if !hasStarted || !hasCompleted {
t.Fatalf("observability: started=%v completed=%v", hasStarted, hasCompleted)
}
t.Log("P1 one-chain: plan(full_base) → RebuildExecutor(IO=v2bridge) → TCP → local install → InSync → pins released → data verified")
}
// --- Non-empty replica: stale state must be cleared ---
func TestP1_TransferFullBase_NonEmptyReplica(t *testing.T) {
dir := t.TempDir()
// Primary: write data + flush.
primaryVol := createTestVolNamed(t, dir, "primary.blockvol")
defer primaryVol.Close()
for i := 0; i < 10; i++ {
primaryVol.WriteLBA(uint64(i), makeBlock(byte('P')))
}
primaryVol.ForceFlush()
rebuildServer, err := blockvol.NewRebuildServer(primaryVol, "127.0.0.1:0")
if err != nil {
t.Fatalf("NewRebuildServer: %v", err)
}
rebuildServer.Serve()
defer rebuildServer.Stop()
// Replica: has STALE data + WAL entries (simulates a previously-used replica).
replicaVol := createTestVolNamed(t, dir, "replica.blockvol")
defer replicaVol.Close()
for i := 0; i < 5; i++ {
replicaVol.WriteLBA(uint64(i), makeBlock(byte('R')))
}
// Confirm replica has WAL entries and dirty map entries.
replicaStateBefore := NewReader(replicaVol).ReadState()
if replicaStateBefore.WALHeadLSN == 0 {
t.Fatal("replica must have WAL entries before rebuild")
}
t.Logf("replica before: head=%d", replicaStateBefore.WALHeadLSN)
// Transfer full base — must clear stale state.
executor := NewExecutor(replicaVol, rebuildServer.Addr())
if _, err := executor.TransferFullBase(0); err != nil {
t.Fatalf("TransferFullBase: %v", err)
}
// Verify: replica reads primary's data (not stale 'R' blocks).
for i := 0; i < 10; i++ {
data, err := replicaVol.ReadLBA(uint64(i), replicaVol.Info().BlockSize)
if err != nil {
t.Fatalf("ReadLBA(%d): %v", i, err)
}
if data[0] != 'P' {
t.Fatalf("LBA %d: got %c, want P (stale data not cleared)", i, data[0])
}
}
// Verify: WAL state was reset (no stale entries overlaying the new extent).
replicaStateAfter := NewReader(replicaVol).ReadState()
t.Logf("replica after: head=%d tail=%d checkpoint=%d",
replicaStateAfter.WALHeadLSN, replicaStateAfter.WALTailLSN, replicaStateAfter.CheckpointLSN)
t.Log("P1 non-empty replica: stale WAL/dirty state cleared, primary data installed correctly")
}
// --- Pre-flush correctness: unflushed WAL entries are in the extent ---
func TestP1_TransferFullBase_UnflushedEntries(t *testing.T) {
dir := t.TempDir()
// Primary: write data, flush SOME, then write MORE that stay in WAL.
primaryVol := createTestVolNamed(t, dir, "primary.blockvol")
defer primaryVol.Close()
for i := 0; i < 10; i++ {
primaryVol.WriteLBA(uint64(i), makeBlock(byte('A'+i%26)))
}
primaryVol.ForceFlush()
// These 5 writes are in the WAL, NOT yet flushed to extent.
for i := 10; i < 15; i++ {
primaryVol.WriteLBA(uint64(i), makeBlock(byte('A'+i%26)))
}
primaryState := NewReader(primaryVol).ReadState()
t.Logf("primary: head=%d tail=%d committed=%d checkpoint=%d",
primaryState.WALHeadLSN, primaryState.WALTailLSN,
primaryState.CommittedLSN, primaryState.CheckpointLSN)
// Confirm: checkpoint < head (unflushed entries exist).
if primaryState.CheckpointLSN >= primaryState.WALHeadLSN {
t.Fatal("need unflushed entries: checkpoint must be < head")
}
rebuildServer, err := blockvol.NewRebuildServer(primaryVol, "127.0.0.1:0")
if err != nil {
t.Fatalf("NewRebuildServer: %v", err)
}
rebuildServer.Serve()
defer rebuildServer.Stop()
// Replica: empty vol.
replicaVol := createTestVolNamed(t, dir, "replica.blockvol")
defer replicaVol.Close()
executor := NewExecutor(replicaVol, rebuildServer.Addr())
if _, err := executor.TransferFullBase(primaryState.CommittedLSN); err != nil {
t.Fatalf("TransferFullBase: %v", err)
}
// Verify: ALL 15 LBAs match — including the 5 that were unflushed.
// The rebuild server's pre-flush ensures they are in the extent.
verifyLBAMatch(t, primaryVol, replicaVol, 15)
t.Log("P1 pre-flush: unflushed WAL entries flushed by rebuild server before extent copy — all data correct")
}
// --- Convergence proof: achievedLSN > targetLSN, no split truth ---
func TestP1_FullBaseRebuild_AchievedConvergence(t *testing.T) {
dir := t.TempDir()
// Primary: write initial data + flush.
primaryVol := createTestVolNamed(t, dir, "primary.blockvol")
defer primaryVol.Close()
for i := 0; i < 20; i++ {
primaryVol.WriteLBA(uint64(i), makeBlock(byte('A'+i%26)))
}
primaryVol.ForceFlush()
primaryState := NewReader(primaryVol).ReadState()
if primaryState.WALTailLSN == 0 {
t.Fatal("ForceFlush must advance tail for rebuild condition")
}
rebuildServer, err := blockvol.NewRebuildServer(primaryVol, "127.0.0.1:0")
if err != nil {
t.Fatalf("NewRebuildServer: %v", err)
}
rebuildServer.Serve()
defer rebuildServer.Stop()
// Replica: empty vol.
replicaVol := createTestVolNamed(t, dir, "replica.blockvol")
defer replicaVol.Close()
// Engine setup with untrusted reader to force full-base path.
primaryReader := NewReader(primaryVol)
primaryPinner := NewPinner(primaryVol)
sa := bridge.NewStorageAdapter(
&untrustedReaderShim{primaryReader},
&pinnerShim{primaryPinner},
)
ca := bridge.NewControlAdapter()
driver := engine.NewRecoveryDriver(sa)
// Assignment + plan.
intent := ca.ToAssignmentIntent(
bridge.MasterAssignment{VolumeName: "vol1", Epoch: 1, Role: "primary"},
[]bridge.MasterAssignment{
{VolumeName: "vol1", ReplicaServerID: "vs2", Role: "replica",
DataAddr: "10.0.0.2:9333", CtrlAddr: "10.0.0.2:9334"},
},
)
driver.Orchestrator.ProcessAssignment(intent)
plan, _ := driver.PlanRecovery("vol1/vs2", 0)
if plan.Outcome != engine.OutcomeNeedsRebuild {
t.Fatalf("outcome=%s", plan.Outcome)
}
rebuildIntent := ca.ToAssignmentIntent(
bridge.MasterAssignment{VolumeName: "vol1", Epoch: 1, Role: "primary"},
[]bridge.MasterAssignment{
{VolumeName: "vol1", ReplicaServerID: "vs2", Role: "rebuilding",
DataAddr: "10.0.0.2:9333", CtrlAddr: "10.0.0.2:9334"},
},
)
driver.Orchestrator.ProcessAssignment(rebuildIntent)
rebuildPlan, err := driver.PlanRebuild("vol1/vs2")
if err != nil {
t.Fatalf("PlanRebuild: %v", err)
}
targetLSN := rebuildPlan.RebuildTargetLSN
t.Logf("plan: target=%d source=%s", targetLSN, rebuildPlan.RebuildSource)
// --- Force achievedLSN > targetLSN ---
// Write additional data to primary AFTER planning. The rebuild server
// will see these via ForceFlush and serve an extent newer than the plan.
for i := 20; i < 25; i++ {
primaryVol.WriteLBA(uint64(i), makeBlock(byte('X')))
}
postPlanState := NewReader(primaryVol).ReadState()
t.Logf("primary after extra writes: head=%d (plan target was %d)",
postPlanState.WALHeadLSN, targetLSN)
// Execute rebuild with real IO.
replicaExecutor := NewExecutor(replicaVol, rebuildServer.Addr())
exec := engine.NewRebuildExecutor(driver, rebuildPlan)
exec.IO = replicaExecutor
if err := exec.Execute(); err != nil {
t.Fatalf("Execute: %v", err)
}
// --- Full convergence verification ---
replicaState := NewReader(replicaVol).ReadState()
localAchieved := replicaState.WALHeadLSN
localCheckpoint := replicaState.CheckpointLSN
// 1. achievedLSN > targetLSN — primary advanced between plan and transfer.
if localAchieved <= targetLSN {
t.Fatalf("achievedLSN %d must be > targetLSN %d (primary wrote 5 more entries)",
localAchieved, targetLSN)
}
t.Logf("achievedLSN=%d > targetLSN=%d — confirmed", localAchieved, targetLSN)
// 2. Sender reached InSync.
s := driver.Orchestrator.Registry.Sender("vol1/vs2")
if s.State() != engine.StateInSync {
t.Fatalf("state=%s, want InSync", s.State())
}
// 3. No split truth: local checkpoint = local head = achievedLSN.
if localCheckpoint != localAchieved {
t.Fatalf("split truth: checkpoint=%d != achieved=%d", localCheckpoint, localAchieved)
}
// 4. Receiver progress aligned to achievedLSN.
// In this test the receiver is nil (standalone replica vol), so
// ReceivedLSN returns 0. The fix is verified structurally by
// Commit + SyncReceiverProgress; production tests with live
// receivers will exercise the full path.
receiverLSN := replicaVol.ReceivedLSN()
t.Logf("receiver progress: %d (0 = no active receiver in this test)", receiverLSN)
// 5. All 25 LBAs match (original 20 + 5 written after plan).
verifyLBAMatch(t, primaryVol, replicaVol, 25)
// 6. Pins released.
if primaryPinner.ActiveHoldCount() != 0 {
t.Fatalf("%d pins leaked", primaryPinner.ActiveHoldCount())
}
// 7. Engine log shows rebuild completion.
events := driver.Orchestrator.Log.EventsFor("vol1/vs2")
hasCompleted := false
for _, ev := range events {
if ev.Event == "exec_rebuild_completed" {
hasCompleted = true
}
}
if !hasCompleted {
t.Fatal("missing exec_rebuild_completed event")
}
t.Logf("convergence: target=%d achieved=%d checkpoint=%d — single truth verified",
targetLSN, localAchieved, localCheckpoint)
}
// --- Stale-higher convergence: replica had higher LSN than rebuilt boundary ---
func TestP1_TransferFullBase_StaleHigherThanAchieved(t *testing.T) {
dir := t.TempDir()
// Primary: small amount of data (10 entries → achievedLSN ~10).
primaryVol := createTestVolNamed(t, dir, "primary.blockvol")
defer primaryVol.Close()
for i := 0; i < 10; i++ {
primaryVol.WriteLBA(uint64(i), makeBlock(byte('P')))
}
primaryVol.ForceFlush()
rebuildServer, err := blockvol.NewRebuildServer(primaryVol, "127.0.0.1:0")
if err != nil {
t.Fatalf("NewRebuildServer: %v", err)
}
rebuildServer.Serve()
defer rebuildServer.Stop()
// Replica: has MORE data than primary (30 entries → higher nextLSN/receivedLSN).
// This simulates a stale replica that diverged (e.g., old primary that was demoted).
replicaVol := createTestVolNamed(t, dir, "replica.blockvol")
defer replicaVol.Close()
for i := 0; i < 30; i++ {
replicaVol.WriteLBA(uint64(i), makeBlock(byte('R')))
}
// Start receiver so replRecv has a high receivedLSN.
if err := replicaVol.StartReplicaReceiver("127.0.0.1:0", "127.0.0.1:0"); err != nil {
t.Fatalf("StartReplicaReceiver: %v", err)
}
staleLSN := replicaVol.ReceivedLSN()
staleState := NewReader(replicaVol).ReadState()
t.Logf("replica before: head=%d receivedLSN=%d (higher than primary)",
staleState.WALHeadLSN, staleLSN)
if staleLSN <= 10 {
t.Fatalf("replica receivedLSN %d must be > primary's 10 for this test", staleLSN)
}
// Transfer full base — must RESET (not just advance) to achieved boundary.
executor := NewExecutor(replicaVol, rebuildServer.Addr())
achieved, err := executor.TransferFullBase(0)
if err != nil {
t.Fatalf("TransferFullBase: %v", err)
}
// The achieved boundary should match the primary (~10), NOT the stale (~30).
postState := NewReader(replicaVol).ReadState()
postReceivedLSN := replicaVol.ReceivedLSN()
t.Logf("replica after: head=%d checkpoint=%d receivedLSN=%d achieved=%d",
postState.WALHeadLSN, postState.CheckpointLSN, postReceivedLSN, achieved)
// nextLSN (via WALHeadLSN) must be reset to achieved, not kept at stale higher value.
if postState.WALHeadLSN != achieved {
t.Fatalf("split truth: WALHeadLSN=%d != achieved=%d (stale higher value not reset)",
postState.WALHeadLSN, achieved)
}
// receivedLSN must be reset to achieved, not kept at stale higher value.
if postReceivedLSN != achieved {
t.Fatalf("split truth: receivedLSN=%d != achieved=%d (stale higher value not reset)",
postReceivedLSN, achieved)
}
// Data must be primary's, not stale replica's.
for i := 0; i < 10; i++ {
data, err := replicaVol.ReadLBA(uint64(i), replicaVol.Info().BlockSize)
if err != nil {
t.Fatalf("ReadLBA(%d): %v", i, err)
}
if data[0] != 'P' {
t.Fatalf("LBA %d: got %c, want P", i, data[0])
}
}
t.Logf("stale-higher: staleRecv=%d staleHead=%d → achieved=%d receivedLSN=%d — reset, not max",
staleLSN, staleState.WALHeadLSN, achieved, postReceivedLSN)
}
// --- Live receiver: receivedLSN convergence through active receiver ---
func TestP1_TransferFullBase_LiveReceiverConvergence(t *testing.T) {
dir := t.TempDir()
// Primary: write data + flush.
primaryVol := createTestVolNamed(t, dir, "primary.blockvol")
defer primaryVol.Close()
for i := 0; i < 20; i++ {
primaryVol.WriteLBA(uint64(i), makeBlock(byte('A'+i%26)))
}
primaryVol.ForceFlush()
rebuildServer, err := blockvol.NewRebuildServer(primaryVol, "127.0.0.1:0")
if err != nil {
t.Fatalf("NewRebuildServer: %v", err)
}
rebuildServer.Serve()
defer rebuildServer.Stop()
// Replica: has an ACTIVE receiver before rebuild (simulates a replica
// that was previously receiving WAL entries and now needs a rebuild).
replicaVol := createTestVolNamed(t, dir, "replica.blockvol")
defer replicaVol.Close()
// Write some stale data so the replica has a non-zero receivedLSN.
for i := 0; i < 3; i++ {
replicaVol.WriteLBA(uint64(i), makeBlock(byte('R')))
}
// Start a real receiver on the replica via StartReplicaReceiver
// (sets vol.replRecv so ReceivedLSN() returns a real value).
if err := replicaVol.StartReplicaReceiver("127.0.0.1:0", "127.0.0.1:0"); err != nil {
t.Fatalf("StartReplicaReceiver: %v", err)
}
staleReceivedLSN := replicaVol.ReceivedLSN()
t.Logf("replica before rebuild: receivedLSN=%d", staleReceivedLSN)
if staleReceivedLSN == 0 {
t.Fatal("receiver must have non-zero receivedLSN before rebuild")
}
// Transfer full base — must align receiver progress.
executor := NewExecutor(replicaVol, rebuildServer.Addr())
achieved, err := executor.TransferFullBase(0)
if err != nil {
t.Fatalf("TransferFullBase: %v", err)
}
// Verify: receivedLSN advanced to achievedLSN.
postReceivedLSN := replicaVol.ReceivedLSN()
if postReceivedLSN != achieved {
t.Fatalf("receiver split truth: receivedLSN=%d != achieved=%d",
postReceivedLSN, achieved)
}
// Verify: LBA data matches primary (not stale 'R' blocks).
verifyLBAMatch(t, primaryVol, replicaVol, 20)
t.Logf("live receiver convergence: stale=%d → achieved=%d, receivedLSN=%d — no split truth",
staleReceivedLSN, achieved, postReceivedLSN)
}
// --- Fail-Closed: connection refused ---
func TestP1_TransferFullBase_ConnectionRefused(t *testing.T) {
dir := t.TempDir()
replicaVol := createTestVolNamed(t, dir, "replica.blockvol")
defer replicaVol.Close()
// Point to an address where nothing is listening.
executor := NewExecutor(replicaVol, "127.0.0.1:1")
_, err := executor.TransferFullBase(100)
if err == nil {
t.Fatal("should fail on connection refused")
}
t.Logf("connection refused: %v", err)
}
// --- Fail-Closed: epoch mismatch ---
func TestP1_TransferFullBase_EpochMismatch(t *testing.T) {
dir := t.TempDir()
// Primary with epoch 5.
primaryVol := createTestVolNamed(t, dir, "primary.blockvol")
defer primaryVol.Close()
primaryVol.SetEpoch(5)
primaryVol.WriteLBA(0, makeBlock('A'))
primaryVol.ForceFlush()
rebuildServer, err := blockvol.NewRebuildServer(primaryVol, "127.0.0.1:0")
if err != nil {
t.Fatalf("NewRebuildServer: %v", err)
}
rebuildServer.Serve()
defer rebuildServer.Stop()
// Replica with epoch 3 (stale).
replicaVol := createTestVolNamed(t, dir, "replica.blockvol")
defer replicaVol.Close()
replicaVol.SetEpoch(3)
executor := NewExecutor(replicaVol, rebuildServer.Addr())
_, err = executor.TransferFullBase(100)
if err == nil {
t.Fatal("should fail on epoch mismatch")
}
if !strings.Contains(err.Error(), "EPOCH_MISMATCH") {
t.Fatalf("expected EPOCH_MISMATCH, got: %v", err)
}
t.Logf("epoch mismatch: %v", err)
}
// --- Fail-Closed: no rebuild address ---
func TestP1_TransferFullBase_NoAddress(t *testing.T) {
vol := createTestVol(t)
defer vol.Close()
executor := NewExecutor(vol, "")
_, err := executor.TransferFullBase(100)
if err == nil {
t.Fatal("should fail without rebuild address")
}
t.Logf("no address: %v", err)
}
// --- Fail-Closed: partial transfer (server closes mid-stream) ---
func TestP1_TransferFullBase_PartialTransfer(t *testing.T) {
dir := t.TempDir()
replicaVol := createTestVolNamed(t, dir, "replica.blockvol")
defer replicaVol.Close()
// Start a fake server that sends one extent chunk then closes.
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
defer ln.Close()
go func() {
conn, err := ln.Accept()
if err != nil {
return
}
// Read the request frame (discard).
blockvol.ReadFrame(conn)
// Send one extent chunk.
chunk := make([]byte, 4096)
for i := range chunk {
chunk[i] = 0xFF
}
blockvol.WriteFrame(conn, blockvol.MsgRebuildExtent, chunk)
// Close abruptly — no MsgRebuildDone.
conn.Close()
}()
executor := NewExecutor(replicaVol, ln.Addr().String())
_, err = executor.TransferFullBase(100)
if err == nil {
t.Fatal("should fail on partial transfer (connection closed before Done)")
}
t.Logf("partial transfer: %v", err)
}
@@ -0,0 +1,200 @@
package v2bridge
import (
"bytes"
"sync"
"testing"
"github.com/seaweedfs/seaweedfs/weed/storage/blockvol"
)
// ============================================================
// Phase 09 P3: Adversarial tests for truncation
// ============================================================
// --- Adversarial 1: Concurrent write during truncation ---
func TestAdversarial_Truncate_ConcurrentWrite(t *testing.T) {
vol := createTestVol(t)
defer vol.Close()
// Write 10 base entries, flush.
for i := 0; i < 10; i++ {
vol.WriteLBA(uint64(i), makeBlock(byte('B')))
}
vol.ForceFlush()
// Write 10 ahead entries (unflushed).
for i := 0; i < 10; i++ {
vol.WriteLBA(uint64(i), makeBlock(byte('A')))
}
stateBefore := NewReader(vol).ReadState()
t.Logf("before: head=%d checkpoint=%d", stateBefore.WALHeadLSN, stateBefore.CheckpointLSN)
// Race: truncation + concurrent write.
var wg sync.WaitGroup
var truncErr, writeErr error
wg.Add(2)
go func() {
defer wg.Done()
truncErr = vol.TruncateToLSN(10)
}()
go func() {
defer wg.Done()
writeErr = vol.WriteLBA(0, makeBlock(byte('W')))
}()
wg.Wait()
// Truncation must succeed (unflushed-ahead).
if truncErr != nil {
t.Fatalf("truncation should succeed: %v", truncErr)
}
// Write either succeeded (before truncation) or after truncation.
// Either way is fine — no crash, no corruption.
t.Logf("concurrent write err: %v", writeErr)
stateAfter := NewReader(vol).ReadState()
t.Logf("after: head=%d checkpoint=%d", stateAfter.WALHeadLSN, stateAfter.CheckpointLSN)
// Key assertion: data must be self-consistent.
// If head == 10: truncation won, write was either before (discarded) or failed.
// If head == 11: write happened after truncation reset nextLSN to 11.
// Both are valid.
blockSize := vol.Info().BlockSize
data, err := vol.ReadLBA(0, blockSize)
if err != nil {
t.Fatalf("ReadLBA after race: %v", err)
}
// Data should be 'B' (base), 'W' (concurrent write landed), or 'A' (ahead survived if write raced first).
// It must NOT be a mix of different blocks.
if data[0] != byte('B') && data[0] != byte('W') && data[0] != byte('A') {
t.Fatalf("LBA 0 unexpected data: %d", data[0])
}
t.Logf("adversarial 1: concurrent write during truncation — no crash, data[0]=%c, head=%d",
data[0], stateAfter.WALHeadLSN)
}
// --- Adversarial 2: Truncate to exact head (no-op boundary) ---
func TestAdversarial_Truncate_ExactHead_NoOp(t *testing.T) {
vol := createTestVol(t)
defer vol.Close()
// Write 10 entries, flush.
for i := 0; i < 10; i++ {
vol.WriteLBA(uint64(i), makeBlock(byte('D')))
}
vol.ForceFlush()
stateBefore := NewReader(vol).ReadState()
t.Logf("before: head=%d checkpoint=%d", stateBefore.WALHeadLSN, stateBefore.CheckpointLSN)
// Truncate to exactly head LSN — zero ahead entries.
executor := NewExecutor(vol, "")
if err := executor.TruncateWAL(stateBefore.WALHeadLSN); err != nil {
t.Fatalf("truncate to exact head: %v", err)
}
stateAfter := NewReader(vol).ReadState()
t.Logf("after: head=%d checkpoint=%d", stateAfter.WALHeadLSN, stateAfter.CheckpointLSN)
// Head should be exactly the truncation point.
if stateAfter.WALHeadLSN != stateBefore.WALHeadLSN {
t.Fatalf("head changed: %d → %d", stateBefore.WALHeadLSN, stateAfter.WALHeadLSN)
}
// Data must be unchanged.
blockSize := vol.Info().BlockSize
for i := 0; i < 10; i++ {
data, err := vol.ReadLBA(uint64(i), blockSize)
if err != nil {
t.Fatalf("ReadLBA(%d): %v", i, err)
}
expected := makeBlock(byte('D'))
if !bytes.Equal(data, expected) {
t.Fatalf("LBA %d changed after no-op truncation", i)
}
}
// Next write should be at head+1.
vol.WriteLBA(0, makeBlock(byte('N')))
statePost := NewReader(vol).ReadState()
if statePost.WALHeadLSN != stateBefore.WALHeadLSN+1 {
t.Fatalf("next write at wrong LSN: %d (expected %d)",
statePost.WALHeadLSN, stateBefore.WALHeadLSN+1)
}
t.Logf("adversarial 2: truncate to exact head — data unchanged, next write at %d", statePost.WALHeadLSN)
}
// --- Adversarial 3: Truncation after full-base rebuild ---
func TestAdversarial_Truncate_AfterRebuild(t *testing.T) {
dir := t.TempDir()
// Primary: 10 entries, flush.
primaryVol := createTestVolNamed(t, dir, "primary.blockvol")
defer primaryVol.Close()
for i := 0; i < 10; i++ {
primaryVol.WriteLBA(uint64(i), makeBlock(byte('P')))
}
primaryVol.ForceFlush()
// Replica: empty, rebuild from primary.
replicaVol := createTestVolNamed(t, dir, "replica.blockvol")
defer replicaVol.Close()
rebuildServer, err := blockvol.NewRebuildServer(primaryVol, "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
rebuildServer.Serve()
defer rebuildServer.Stop()
rebuildExec := NewExecutor(replicaVol, rebuildServer.Addr())
achievedLSN, err := rebuildExec.TransferFullBase(0)
if err != nil {
t.Fatalf("rebuild: %v", err)
}
t.Logf("rebuild achieved: %d", achievedLSN)
// Verify rebuild installed 'P' data.
verifyLBAMatch(t, primaryVol, replicaVol, 10)
// Now: write ahead entries on replica (simulates split-brain divergence).
for i := 0; i < 5; i++ {
replicaVol.WriteLBA(uint64(i), makeBlock(byte('X')))
}
replicaState := NewReader(replicaVol).ReadState()
t.Logf("replica after ahead writes: head=%d checkpoint=%d", replicaState.WALHeadLSN, replicaState.CheckpointLSN)
// Truncate back to the rebuild's achieved LSN.
truncExec := NewExecutor(replicaVol, "")
if err := truncExec.TruncateWAL(achievedLSN); err != nil {
t.Fatalf("truncate after rebuild: %v", err)
}
// Verify: 'P' data restored (ahead 'X' discarded).
blockSize := replicaVol.Info().BlockSize
for i := 0; i < 10; i++ {
data, err := replicaVol.ReadLBA(uint64(i), blockSize)
if err != nil {
t.Fatalf("ReadLBA(%d): %v", i, err)
}
expected := makeBlock(byte('P'))
if !bytes.Equal(data, expected) {
t.Fatalf("LBA %d after truncate: got %c, want 'P' — rebuild base corrupted", i, data[0])
}
}
t.Logf("adversarial 3: truncation after rebuild — ahead 'X' discarded, rebuild base 'P' preserved")
}
@@ -0,0 +1,103 @@
package v2bridge
import (
"testing"
)
// ============================================================
// Phase 09 P3: Safety tests for the mixed-case truncation bug
//
// Bug: checkpointLSN < truncateLSN is allowed but entries
// (checkpointLSN, truncateLSN] may live only in WAL/dirty map.
// Truncation clears both, losing committed data.
//
// Correct safety predicate:
// checkpointLSN == truncateLSN → safe
// checkpointLSN > truncateLSN → unsafe (flushed-ahead)
// checkpointLSN < truncateLSN → unsafe (kept data in WAL only)
// ============================================================
func TestSafety_MixedCase_CheckpointBelowTruncateLSN(t *testing.T) {
vol := createTestVol(t)
defer vol.Close()
// Phase 1: Write 10 entries + flush → checkpoint = 10.
for i := 0; i < 10; i++ {
vol.WriteLBA(uint64(i), makeBlock(byte('B')))
}
vol.ForceFlush()
state1 := NewReader(vol).ReadState()
checkpointLSN := state1.CheckpointLSN
t.Logf("after flush: checkpoint=%d", checkpointLSN)
// Phase 2: Write 5 MORE entries WITHOUT flushing (in WAL only).
for i := 10; i < 15; i++ {
vol.WriteLBA(uint64(i), makeBlock(byte('K')))
}
// Phase 3: Write 5 AHEAD entries (divergent).
for i := 15; i < 20; i++ {
vol.WriteLBA(uint64(i), makeBlock(byte('A')))
}
state2 := NewReader(vol).ReadState()
t.Logf("before truncation: head=%d checkpoint=%d committed=%d",
state2.WALHeadLSN, state2.CheckpointLSN, state2.CommittedLSN)
// Truncate to 15: checkpoint(10) < truncateLSN(15).
// Entries 11..15 are in WAL only — truncation would lose them.
executor := NewExecutor(vol, "")
err := executor.TruncateWAL(15)
if err == nil {
// BUG CONFIRMED: Show the data loss.
blockSize := vol.Info().BlockSize
for i := 10; i < 15; i++ {
data, err := vol.ReadLBA(uint64(i), blockSize)
if err != nil {
t.Logf(" LBA %d: read error: %v", i, err)
continue
}
t.Logf(" LBA %d: %c (want 'K')", i, data[0])
}
t.Fatal("BUG: truncation succeeded with checkpoint < truncateLSN — entries 11..15 lost")
}
// Correctly rejected.
t.Logf("correctly rejected: %v", err)
if !containsSubstring(err.Error(), "unsafe") {
t.Logf("warning: error should contain 'unsafe' for engine escalation, got: %v", err)
}
t.Logf("PASS: checkpoint=%d < truncateLSN=15 correctly rejected", checkpointLSN)
}
func TestSafety_MixedCase_EngineEscalates(t *testing.T) {
vol := createTestVol(t)
defer vol.Close()
// Same mixed state as above.
for i := 0; i < 10; i++ {
vol.WriteLBA(uint64(i), makeBlock(byte('B')))
}
vol.ForceFlush()
for i := 10; i < 15; i++ {
vol.WriteLBA(uint64(i), makeBlock(byte('K')))
}
for i := 15; i < 20; i++ {
vol.WriteLBA(uint64(i), makeBlock(byte('A')))
}
state := NewReader(vol).ReadState()
t.Logf("mixed: head=%d checkpoint=%d", state.WALHeadLSN, state.CheckpointLSN)
executor := NewExecutor(vol, "")
err := executor.TruncateWAL(15)
if err == nil {
t.Fatal("BUG: engine chain would complete InSync after data loss")
}
t.Logf("PASS: mixed case rejected — engine would escalate: %v", err)
}
@@ -0,0 +1,553 @@
package v2bridge
import (
"testing"
bridge "github.com/seaweedfs/seaweedfs/sw-block/bridge/blockvol"
engine "github.com/seaweedfs/seaweedfs/sw-block/engine/replication"
)
// ============================================================
// Phase 09 P3: Truncation execution closure
//
// Proofs:
// 1. Component: TruncateWAL performs real local correction
// 2. One-chain: engine plan(replica ahead) → CatchUpExecutor → TruncateWAL → InSync
// 3. Exact-boundary: runtime converges to exactly truncateLSN
// 4. Stale-higher: active receiver ahead of truncation point is corrected
// 5. Fail-closed: truncation failure prevents completion
// 6. Adversarial: truncation then resumed catch-up from truncated boundary
// ============================================================
// --- Component Proof: TruncateWAL performs real local correction ---
func TestP3_TruncateWAL_RealCorrection(t *testing.T) {
vol := createTestVol(t)
defer vol.Close()
// Phase 1: Write 10 "base" entries, flush to extent.
// These represent data the primary also has (shared truth).
for i := 0; i < 10; i++ {
vol.WriteLBA(uint64(i), makeBlock(byte('B'))) // B = base
}
vol.ForceFlush()
baseCheckpoint := NewReader(vol).ReadState().CheckpointLSN
t.Logf("base flushed: checkpoint=%d", baseCheckpoint)
// Phase 2: Write 10 MORE "ahead" entries WITHOUT flushing.
// These represent entries the replica received but the primary didn't commit.
for i := 0; i < 10; i++ {
vol.WriteLBA(uint64(i), makeBlock(byte('A'))) // A = ahead (overwrites B)
}
stateBefore := NewReader(vol).ReadState()
t.Logf("before truncation: head=%d checkpoint=%d", stateBefore.WALHeadLSN, stateBefore.CheckpointLSN)
// Verify: reads return 'A' (ahead data from dirty map).
data, _ := vol.ReadLBA(0, vol.Info().BlockSize)
if data[0] != 'A' {
t.Fatalf("pre-truncate: LBA 0 = %c, want A", data[0])
}
// Truncate to base checkpoint — discard ahead entries.
executor := NewExecutor(vol, "")
if err := executor.TruncateWAL(baseCheckpoint); err != nil {
t.Fatalf("TruncateWAL: %v", err)
}
stateAfter := NewReader(vol).ReadState()
t.Logf("after truncation: head=%d checkpoint=%d", stateAfter.WALHeadLSN, stateAfter.CheckpointLSN)
// Exact boundary: WALHeadLSN must be at the truncation point.
if stateAfter.WALHeadLSN != baseCheckpoint {
t.Fatalf("WALHeadLSN=%d, want %d", stateAfter.WALHeadLSN, baseCheckpoint)
}
// DATA PROOF: reads must return 'B' (base data from extent), not 'A'.
// The ahead entries were discarded from WAL without flushing, so
// reads fall through dirty map → extent → base data.
for i := 0; i < 10; i++ {
data, err := vol.ReadLBA(uint64(i), vol.Info().BlockSize)
if err != nil {
t.Fatalf("ReadLBA(%d): %v", i, err)
}
if data[0] != 'B' {
t.Fatalf("LBA %d = %c, want B (base data after truncation)", i, data[0])
}
}
// Verify: can write new entry after truncation (no gap).
vol.WriteLBA(0, makeBlock('Z'))
statePost := NewReader(vol).ReadState()
expectedNext := baseCheckpoint + 1
if statePost.WALHeadLSN != expectedNext {
t.Fatalf("post-truncate write: head=%d, want %d", statePost.WALHeadLSN, expectedNext)
}
t.Logf("P3 component: truncated to %d — ahead data discarded, base data restored, next write at %d",
baseCheckpoint, expectedNext)
}
// --- One-Chain Proof: engine → CatchUpExecutor → TruncateWAL → InSync ---
func TestP3_TruncateWAL_OneChain(t *testing.T) {
dir := t.TempDir()
// Primary: 10 entries with 'P' data, flush (committed=10).
primaryVol := createTestVolNamed(t, dir, "primary.blockvol")
defer primaryVol.Close()
for i := 0; i < 10; i++ {
primaryVol.WriteLBA(uint64(i), makeBlock(byte('P')))
}
primaryVol.ForceFlush()
primaryState := NewReader(primaryVol).ReadState()
t.Logf("primary: committed=%d", primaryState.CommittedLSN)
// Replica: first 10 entries with 'P' (same as primary, flushed),
// then 10 MORE with 'R' (ahead, NOT flushed).
replicaVol := createTestVolNamed(t, dir, "replica.blockvol")
defer replicaVol.Close()
for i := 0; i < 10; i++ {
replicaVol.WriteLBA(uint64(i), makeBlock(byte('P'))) // shared base
}
replicaVol.ForceFlush()
for i := 0; i < 10; i++ {
replicaVol.WriteLBA(uint64(i), makeBlock(byte('R'))) // ahead (overwrites P)
}
replicaState := NewReader(replicaVol).ReadState()
t.Logf("replica: head=%d (ahead of primary's %d)", replicaState.WALHeadLSN, primaryState.CommittedLSN)
// Engine setup: StorageAdapter reads PRIMARY state.
primaryReader := NewReader(primaryVol)
primaryPinner := NewPinner(primaryVol)
sa := bridge.NewStorageAdapter(
&readerShim{primaryReader},
&pinnerShim{primaryPinner},
)
ca := bridge.NewControlAdapter()
driver := engine.NewRecoveryDriver(sa)
// Assignment.
intent := ca.ToAssignmentIntent(
bridge.MasterAssignment{VolumeName: "vol1", Epoch: 1, Role: "primary"},
[]bridge.MasterAssignment{
{VolumeName: "vol1", ReplicaServerID: "vs2", Role: "replica",
DataAddr: "10.0.0.2:9333", CtrlAddr: "10.0.0.2:9334"},
},
)
driver.Orchestrator.ProcessAssignment(intent)
// Plan recovery with replica flushed at 20, primary committed at 10.
// → replica_ahead_needs_truncation, TruncateLSN = 10.
plan, err := driver.PlanRecovery("vol1/vs2", replicaState.WALHeadLSN)
if err != nil {
t.Fatalf("PlanRecovery: %v", err)
}
if plan.TruncateLSN == 0 {
t.Fatalf("expected truncation plan, got TruncateLSN=0 (outcome=%s)", plan.Outcome)
}
t.Logf("plan: outcome=%s truncateLSN=%d", plan.Outcome, plan.TruncateLSN)
// Execute: CatchUpExecutor with IO wired to replica vol.
// The executor calls TruncateWAL (real correction on replica).
replicaExecutor := NewExecutor(replicaVol, "")
exec := engine.NewCatchUpExecutor(driver, plan)
exec.IO = replicaExecutor
if err := exec.Execute(nil, 0); err != nil {
t.Fatalf("Execute: %v", err)
}
// Verify sender → InSync.
s := driver.Orchestrator.Registry.Sender("vol1/vs2")
if s.State() != engine.StateInSync {
t.Fatalf("state=%s, want InSync", s.State())
}
// Verify: replica runtime converged to truncateLSN.
replicaAfter := NewReader(replicaVol).ReadState()
if replicaAfter.WALHeadLSN != plan.TruncateLSN {
t.Fatalf("WALHeadLSN=%d != truncateLSN=%d", replicaAfter.WALHeadLSN, plan.TruncateLSN)
}
// DATA PROOF: replica data must match primary (base data 'P'),
// not ahead data ('R'). The unflushed ahead entries were discarded.
for i := 0; i < 10; i++ {
data, err := replicaVol.ReadLBA(uint64(i), replicaVol.Info().BlockSize)
if err != nil {
t.Fatalf("ReadLBA(%d): %v", i, err)
}
if data[0] != 'P' {
t.Fatalf("LBA %d = %c, want P (primary's base data after truncation)", i, data[0])
}
}
// Verify pins released.
if primaryPinner.ActiveHoldCount() != 0 {
t.Fatalf("%d pins leaked", primaryPinner.ActiveHoldCount())
}
// Verify observability.
events := driver.Orchestrator.Log.EventsFor("vol1/vs2")
hasTruncation := false
for _, ev := range events {
if ev.Event == "exec_truncation" {
hasTruncation = true
}
}
if !hasTruncation {
t.Fatal("missing exec_truncation event")
}
t.Logf("P3 one-chain: plan(replica_ahead) → CatchUpExecutor → TruncateWAL(%d) → InSync → data matches primary",
plan.TruncateLSN)
}
// --- Stale-higher: active receiver corrected down ---
func TestP3_TruncateWAL_ActiveReceiverCorrected(t *testing.T) {
vol := createTestVol(t)
defer vol.Close()
// Write 10 base entries, flush → checkpoint=10.
for i := 0; i < 10; i++ {
vol.WriteLBA(uint64(i), makeBlock(byte('B')))
}
vol.ForceFlush()
// Write 10 ahead entries (NOT flushed).
for i := 0; i < 10; i++ {
vol.WriteLBA(uint64(i), makeBlock(byte('A')))
}
// Start receiver so receivedLSN reflects head (20).
if err := vol.StartReplicaReceiver("127.0.0.1:0", "127.0.0.1:0"); err != nil {
t.Fatalf("StartReplicaReceiver: %v", err)
}
staleRecv := vol.ReceivedLSN()
t.Logf("before: receivedLSN=%d", staleRecv)
// Truncate to 10 (checkpoint == 10 == truncateLSN → safe).
executor := NewExecutor(vol, "")
if err := executor.TruncateWAL(10); err != nil {
t.Fatalf("TruncateWAL: %v", err)
}
postRecv := vol.ReceivedLSN()
if postRecv != 10 {
t.Fatalf("receivedLSN=%d, want 10 (corrected down)", postRecv)
}
t.Logf("P3 active receiver: receivedLSN %d→%d — corrected to truncation boundary", staleRecv, postRecv)
}
// --- Adversarial: truncation then resumed catch-up from truncated boundary ---
func TestP3_TruncateWAL_ThenCatchUp(t *testing.T) {
dir := t.TempDir()
// Primary: 15 entries with 'P', flush.
primaryVol := createTestVolNamed(t, dir, "primary.blockvol")
defer primaryVol.Close()
for i := 0; i < 15; i++ {
primaryVol.WriteLBA(uint64(i), makeBlock(byte('P')))
}
primaryVol.ForceFlush()
primaryState := NewReader(primaryVol).ReadState()
// Replica: first 15 entries with 'P' (shared base, flushed),
// then 5 ahead entries with 'R' (NOT flushed).
replicaVol := createTestVolNamed(t, dir, "replica.blockvol")
defer replicaVol.Close()
for i := 0; i < 15; i++ {
replicaVol.WriteLBA(uint64(i), makeBlock(byte('P')))
}
replicaVol.ForceFlush()
for i := 0; i < 5; i++ {
replicaVol.WriteLBA(uint64(i), makeBlock(byte('R'))) // ahead, overwrites P
}
executor := NewExecutor(replicaVol, "")
if err := executor.TruncateWAL(primaryState.CommittedLSN); err != nil {
t.Fatalf("TruncateWAL: %v", err)
}
// Verify truncation boundary.
postState := NewReader(replicaVol).ReadState()
if postState.WALHeadLSN != primaryState.CommittedLSN {
t.Fatalf("post-truncate: head=%d, want %d", postState.WALHeadLSN, primaryState.CommittedLSN)
}
// DATA PROOF: replica data at truncated LBAs must be 'P' (base), not 'R' (ahead).
for i := 0; i < 5; i++ {
data, err := replicaVol.ReadLBA(uint64(i), replicaVol.Info().BlockSize)
if err != nil {
t.Fatalf("ReadLBA(%d): %v", i, err)
}
if data[0] != 'P' {
t.Fatalf("LBA %d = %c, want P (base data after truncation)", i, data[0])
}
}
// Replica writes from next LSN should work (no gap).
replicaVol.WriteLBA(0, makeBlock('T'))
postWrite := NewReader(replicaVol).ReadState()
expectedLSN := primaryState.CommittedLSN + 1
if postWrite.WALHeadLSN != expectedLSN {
t.Fatalf("post-truncate write: head=%d, want %d", postWrite.WALHeadLSN, expectedLSN)
}
t.Logf("P3 adversarial: truncate to %d → base data restored → next write at %d — no gap",
primaryState.CommittedLSN, expectedLSN)
}
// --- Fail-closed: nil vol ---
func TestP3_TruncateWAL_NilVol(t *testing.T) {
executor := &Executor{vol: nil}
err := executor.TruncateWAL(10)
if err == nil {
t.Fatal("should fail with nil vol")
}
}
// --- Flushed-ahead escalation: checkpoint > truncateLSN → error ---
func TestP3_TruncateWAL_FlushedAheadEscalates(t *testing.T) {
vol := createTestVol(t)
defer vol.Close()
// Write 10 base entries, flush.
for i := 0; i < 10; i++ {
vol.WriteLBA(uint64(i), makeBlock(byte('B')))
}
vol.ForceFlush()
// Write 10 ahead entries AND FLUSH THEM — this contaminates the extent.
for i := 0; i < 10; i++ {
vol.WriteLBA(uint64(i), makeBlock(byte('A')))
}
vol.ForceFlush()
state := NewReader(vol).ReadState()
t.Logf("flushed-ahead: head=%d checkpoint=%d", state.WALHeadLSN, state.CheckpointLSN)
// Checkpoint is now 20 (all entries flushed). Truncation to 10 should
// FAIL because checkpoint (20) > truncateLSN (10) — ahead data is in extent.
executor := NewExecutor(vol, "")
err := executor.TruncateWAL(10)
if err == nil {
t.Fatal("truncation should fail when ahead entries are already flushed to extent")
}
// Verify the error wraps ErrTruncationUnsafe.
if !containsSubstring(err.Error(), "truncation unsafe") {
t.Fatalf("error should indicate truncation unsafe, got: %v", err)
}
t.Logf("P3 escalation: checkpoint=%d > truncateLSN=10 → %v", state.CheckpointLSN, err)
}
// --- One-chain escalation: engine plan → truncation fails → NOT InSync ---
func TestP3_TruncateWAL_OneChain_FlushedAheadNotInSync(t *testing.T) {
dir := t.TempDir()
// Primary: 10 entries, flush (committed=10).
primaryVol := createTestVolNamed(t, dir, "primary.blockvol")
defer primaryVol.Close()
for i := 0; i < 10; i++ {
primaryVol.WriteLBA(uint64(i), makeBlock(byte('P')))
}
primaryVol.ForceFlush()
// Replica: 10 base + 10 ahead, ALL FLUSHED (checkpoint > committed).
replicaVol := createTestVolNamed(t, dir, "replica.blockvol")
defer replicaVol.Close()
for i := 0; i < 10; i++ {
replicaVol.WriteLBA(uint64(i), makeBlock(byte('P'))) // base
}
replicaVol.ForceFlush()
for i := 0; i < 10; i++ {
replicaVol.WriteLBA(uint64(i), makeBlock(byte('R'))) // ahead
}
replicaVol.ForceFlush() // contaminates extent
replicaState := NewReader(replicaVol).ReadState()
t.Logf("replica: head=%d checkpoint=%d (flushed ahead)", replicaState.WALHeadLSN, replicaState.CheckpointLSN)
// Engine setup.
primaryReader := NewReader(primaryVol)
primaryPinner := NewPinner(primaryVol)
sa := bridge.NewStorageAdapter(
&readerShim{primaryReader},
&pinnerShim{primaryPinner},
)
ca := bridge.NewControlAdapter()
driver := engine.NewRecoveryDriver(sa)
intent := ca.ToAssignmentIntent(
bridge.MasterAssignment{VolumeName: "vol1", Epoch: 1, Role: "primary"},
[]bridge.MasterAssignment{
{VolumeName: "vol1", ReplicaServerID: "vs2", Role: "replica",
DataAddr: "10.0.0.2:9333", CtrlAddr: "10.0.0.2:9334"},
},
)
driver.Orchestrator.ProcessAssignment(intent)
plan, err := driver.PlanRecovery("vol1/vs2", replicaState.WALHeadLSN)
if err != nil {
t.Fatalf("PlanRecovery: %v", err)
}
if plan.TruncateLSN == 0 {
t.Fatalf("expected truncation plan, got TruncateLSN=0")
}
t.Logf("plan: truncateLSN=%d", plan.TruncateLSN)
// Execute: CatchUpExecutor with IO on the flushed-ahead replica.
replicaExecutor := NewExecutor(replicaVol, "")
exec := engine.NewCatchUpExecutor(driver, plan)
exec.IO = replicaExecutor
// Execute should FAIL — truncation detects flushed-ahead and escalates.
execErr := exec.Execute(nil, 0)
if execErr == nil {
t.Fatal("execute should fail: flushed-ahead truncation must not succeed")
}
t.Logf("execute failed as expected: %v", execErr)
// ESCALATION: sender must be in NeedsRebuild (not just "not InSync").
s := driver.Orchestrator.Registry.Sender("vol1/vs2")
if s.State() != engine.StateNeedsRebuild {
t.Fatalf("sender state=%s, want NeedsRebuild (must escalate, not just fail)", s.State())
}
t.Logf("sender state: %s — correctly escalated to rebuild", s.State())
// Verify: the log shows explicit escalation event.
events := driver.Orchestrator.Log.EventsFor("vol1/vs2")
hasEscalation := false
for _, ev := range events {
if ev.Event == "truncation_escalated" {
hasEscalation = true
}
}
if !hasEscalation {
t.Fatal("missing truncation_escalated event in log")
}
t.Logf("P3 escalation: flushed-ahead → truncation fails → sender NeedsRebuild → ready for rebuild assignment")
}
// --- Mixed case: checkpoint < truncateLSN → escalation (kept data in WAL) ---
func TestP3_TruncateWAL_MixedCase_CheckpointBelowTarget(t *testing.T) {
vol := createTestVol(t)
defer vol.Close()
// Write 10 entries, flush → checkpoint=10.
for i := 0; i < 10; i++ {
vol.WriteLBA(uint64(i), makeBlock(byte('B')))
}
vol.ForceFlush()
// Write 10 more (entries 11-20), NOT flushed → in WAL only.
for i := 0; i < 10; i++ {
vol.WriteLBA(uint64(i), makeBlock(byte('K'))) // K = kept range
}
state := NewReader(vol).ReadState()
t.Logf("mixed case: head=%d checkpoint=%d", state.WALHeadLSN, state.CheckpointLSN)
// Truncate to 15: checkpoint=10 < truncateLSN=15.
// Entries 11-15 are "kept" but live only in WAL.
// Truncation would discard them → data loss. Must escalate.
executor := NewExecutor(vol, "")
err := executor.TruncateWAL(15)
if err == nil {
t.Fatal("truncation should fail: checkpoint 10 < truncateLSN 15 (kept entries in WAL)")
}
if !containsSubstring(err.Error(), "truncation unsafe") {
t.Fatalf("error should indicate truncation unsafe, got: %v", err)
}
t.Logf("P3 mixed case: checkpoint=%d < truncateLSN=15 → %v", state.CheckpointLSN, err)
}
// --- Repeated truncation across different boundaries ---
func TestP3_TruncateWAL_RepeatedEras(t *testing.T) {
vol := createTestVol(t)
defer vol.Close()
// Era 1: write 20 base, flush → checkpoint=20.
for i := 0; i < 20; i++ {
vol.WriteLBA(uint64(i), makeBlock(byte('A'+i%26)))
}
vol.ForceFlush()
// Write 10 ahead (NOT flushed).
for i := 0; i < 10; i++ {
vol.WriteLBA(uint64(i), makeBlock(byte('X')))
}
// Truncate to 20 (checkpoint=20 == truncateLSN → safe).
executor := NewExecutor(vol, "")
if err := executor.TruncateWAL(20); err != nil {
t.Fatalf("first truncate: %v", err)
}
s1 := NewReader(vol).ReadState()
if s1.WALHeadLSN != 20 {
t.Fatalf("first truncate: head=%d, want 20", s1.WALHeadLSN)
}
// Era 2: write 5 more (LSN 21-25, NOT flushed).
// After first truncation, checkpoint=20. Write 2 then flush to get checkpoint=22.
for i := 0; i < 2; i++ {
vol.WriteLBA(uint64(i), makeBlock(byte('Y')))
}
vol.ForceFlush() // checkpoint=22
// Write 3 more ahead (NOT flushed).
for i := 2; i < 5; i++ {
vol.WriteLBA(uint64(i), makeBlock(byte('Z')))
}
// Truncate to 22 (checkpoint=22 == truncateLSN → safe).
if err := executor.TruncateWAL(22); err != nil {
t.Fatalf("second truncate: %v", err)
}
s2 := NewReader(vol).ReadState()
if s2.WALHeadLSN != 22 {
t.Fatalf("second truncate: head=%d, want 22", s2.WALHeadLSN)
}
// Next write should be at LSN 23.
vol.WriteLBA(0, makeBlock('W'))
s3 := NewReader(vol).ReadState()
if s3.WALHeadLSN != 23 {
t.Fatalf("post-second-truncate write: head=%d, want 23", s3.WALHeadLSN)
}
t.Log("P3 repeated: era1(30→20) era2(25→22) — both exact, writes resume correctly")
}
// containsSubstring is shared with transfer_test.go.
func containsSubstring(s, sub string) bool {
for i := 0; i <= len(s)-len(sub); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}