feat: protocol-aware execution wave — phase gate for live WAL shipping

Add host-side protocol state seam that derives per-replica execution
state from V2 sender/session snapshots and blocks live-tail WAL
shipping while an active recovery session is in progress.

New file: weed/server/block_protocol_state.go
  - replicaProtocolExecutionState derived from engine snapshots
  - LiveEligible=false during active catch-up/rebuild sessions
  - bindProtocolExecutionPolicy wires policy into BlockVol
  - syncProtocolExecutionState called after assignments + core events

Data plane changes:
  - WALShipper.Ship() checks liveShippingPolicy before dial/send
  - BlockVol.SetLiveShippingPolicy persists across shipper group rebuilds
  - ShipperGroup propagates policy to all shippers

Design contract: sw-block/design/v2-protocol-aware-execution.md

Scope: WAL-first rollout only. Prevents illegal live-tail delivery
during active recovery. Does not change snapshot/build behavior or
move backlog. Next wave: bounded WAL catch-up under same contract.

Tests: 4 unit/component tests for phase gate behavior, plus bootstrap
seam tests that confirmed the two pre-existing bugs locally.

13 files changed, 900 insertions, 69 deletions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
pingqiuandClaude Opus 4.6 committed 2026-04-05 23:47:07 -07:00
1 parent f8e8c2c4d1
commit d1a16fac03
17 files changed
+1631 -69

No files matched your search

@@ -187,6 +187,47 @@ func TestQueue_ConfirmFromHeartbeat_SameEpochRefreshWaitsForReplicaTransport(t *
}
}
func TestQueue_ConfirmFromHeartbeat_PrimaryRefreshWaitsForCoreProjectionTransition(t *testing.T) {
q := NewBlockAssignmentQueue()
refresh := mkAssign("/a.blk", 5, 1)
refresh.ReplicaAddrs = []blockvol.ReplicaAddr{{
DataAddr: "10.0.0.2:14260",
CtrlAddr: "10.0.0.2:14261",
ServerID: "vs2",
}}
refresh.ReplicaDataAddr = "10.0.0.2:14260"
refresh.ReplicaCtrlAddr = "10.0.0.2:14261"
refresh.ReplicaServerID = "vs2"
q.Enqueue("s1", refresh)
// Legacy transport fields alone are not enough for a primary refresh if the
// local core still projects allocated_only. Otherwise the refresh can be
// dropped before the VS actually re-applies replica membership to the core.
q.ConfirmFromHeartbeat("s1", []blockvol.BlockVolumeInfoMessage{{
Path: "/a.blk",
Epoch: 5,
ReplicaDataAddr: "10.0.0.2:14260",
ReplicaCtrlAddr: "10.0.0.2:14261",
EngineProjectionMode: "allocated_only",
}})
if q.Pending("s1") != 1 {
t.Fatalf("allocated_only primary should not confirm refresh early, pending=%d", q.Pending("s1"))
}
// Once the local core leaves allocated_only, the same heartbeat transport
// now proves the refresh reached the V2 projection layer.
q.ConfirmFromHeartbeat("s1", []blockvol.BlockVolumeInfoMessage{{
Path: "/a.blk",
Epoch: 5,
ReplicaDataAddr: "10.0.0.2:14260",
ReplicaCtrlAddr: "10.0.0.2:14261",
EngineProjectionMode: "bootstrap_pending",
}})
if q.Pending("s1") != 0 {
t.Fatalf("expected refresh assignment to confirm after projection transition, pending=%d", q.Pending("s1"))
}
}
func TestQueue_PeekPrunesStaleEpochs(t *testing.T) {
q := NewBlockAssignmentQueue()
q.Enqueue("s1", mkAssign("/a.blk", 1, 1)) // stale