Browse Source
feat: add V2 bridge adapters for blockvol (Phase 07 P0)
feat: add V2 bridge adapters for blockvol (Phase 07 P0)
Creates sw-block/bridge/blockvol/ — concrete adapters connecting the V2 engine to real blockvol storage and control-plane state. control_adapter.go: - MakeReplicaID: volume-name/server-id (NOT address-derived) - ToAssignmentIntent: maps master assignment → engine intent - Role → SessionKind translation (pure mapping, no policy) storage_adapter.go: - BlockVolState: maps to real blockvol fields (WAL head/tail, committed, checkpoint) — NOT reconstructed from metadata - GetRetainedHistory from real state - PinSnapshot rejects untrusted checkpoint - PinWALRetention rejects recycled range - PinFullBase / ReleaseFullBase 8 bridge tests: - StableIdentity: ReplicaID = vol/server (not address) - AddressChangePreservesIdentity: same ID, different address - RebuildRoleMapping: "rebuilding" → SessionRebuild - PrimaryNoRecovery: no recovery targets for primary - RetainedHistoryFromRealState: all fields from BlockVolState - WALPinRejectsRecycled: tail validation - SnapshotPinRejectsInvalid: trust validation - E2E_AssignmentToRecovery: master assignment → adapter → engine intent → plan → execute → InSync Adapter replacement order: P0: control_adapter + storage_adapter (this delivery) P1: executor_bridge + observe_adapter (deferred) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>feature/sw-block
5 changed files with 456 additions and 0 deletions
-
222sw-block/bridge/blockvol/bridge_test.go
-
83sw-block/bridge/blockvol/control_adapter.go
-
19sw-block/bridge/blockvol/doc.go
-
7sw-block/bridge/blockvol/go.mod
-
125sw-block/bridge/blockvol/storage_adapter.go
@ -0,0 +1,222 @@ |
|||
package blockvol |
|||
|
|||
import ( |
|||
"testing" |
|||
|
|||
engine "github.com/seaweedfs/seaweedfs/sw-block/engine/replication" |
|||
) |
|||
|
|||
// ============================================================
|
|||
// Phase 07 P0: Bridge adapter tests
|
|||
// Validates E1-E3 expectations against concrete adapter code.
|
|||
// ============================================================
|
|||
|
|||
// --- E1: Real assignment → engine intent ---
|
|||
|
|||
func TestControlAdapter_StableIdentity(t *testing.T) { |
|||
ca := NewControlAdapter() |
|||
|
|||
primary := MasterAssignment{ |
|||
VolumeName: "pvc-data-1", |
|||
Epoch: 3, |
|||
Role: "primary", |
|||
PrimaryServerID: "vs1", |
|||
} |
|||
replicas := []MasterAssignment{ |
|||
{ |
|||
VolumeName: "pvc-data-1", |
|||
Epoch: 3, |
|||
Role: "replica", |
|||
ReplicaServerID: "vs2", |
|||
DataAddr: "10.0.0.2:9333", |
|||
CtrlAddr: "10.0.0.2:9334", |
|||
AddrVersion: 1, |
|||
}, |
|||
} |
|||
|
|||
intent := ca.ToAssignmentIntent(primary, replicas) |
|||
|
|||
if intent.Epoch != 3 { |
|||
t.Fatalf("epoch=%d", intent.Epoch) |
|||
} |
|||
if len(intent.Replicas) != 1 { |
|||
t.Fatalf("replicas=%d", len(intent.Replicas)) |
|||
} |
|||
|
|||
// ReplicaID is stable: volume-name/server-id (NOT address).
|
|||
r := intent.Replicas[0] |
|||
if r.ReplicaID != "pvc-data-1/vs2" { |
|||
t.Fatalf("ReplicaID=%s (must be volume/server, not address)", r.ReplicaID) |
|||
} |
|||
|
|||
// Endpoint is the address (mutable).
|
|||
if r.Endpoint.DataAddr != "10.0.0.2:9333" { |
|||
t.Fatalf("DataAddr=%s", r.Endpoint.DataAddr) |
|||
} |
|||
|
|||
// Recovery target mapped.
|
|||
if intent.RecoveryTargets["pvc-data-1/vs2"] != engine.SessionCatchUp { |
|||
t.Fatalf("recovery=%s", intent.RecoveryTargets["pvc-data-1/vs2"]) |
|||
} |
|||
} |
|||
|
|||
func TestControlAdapter_AddressChangePreservesIdentity(t *testing.T) { |
|||
ca := NewControlAdapter() |
|||
|
|||
// First assignment.
|
|||
intent1 := ca.ToAssignmentIntent( |
|||
MasterAssignment{VolumeName: "vol1", Epoch: 1, Role: "primary", PrimaryServerID: "vs1"}, |
|||
[]MasterAssignment{ |
|||
{VolumeName: "vol1", ReplicaServerID: "vs2", Role: "replica", DataAddr: "10.0.0.2:9333", AddrVersion: 1}, |
|||
}, |
|||
) |
|||
|
|||
// Address changes — new assignment.
|
|||
intent2 := ca.ToAssignmentIntent( |
|||
MasterAssignment{VolumeName: "vol1", Epoch: 1, Role: "primary", PrimaryServerID: "vs1"}, |
|||
[]MasterAssignment{ |
|||
{VolumeName: "vol1", ReplicaServerID: "vs2", Role: "replica", DataAddr: "10.0.0.3:9333", AddrVersion: 2}, |
|||
}, |
|||
) |
|||
|
|||
// Same ReplicaID despite different address.
|
|||
if intent1.Replicas[0].ReplicaID != intent2.Replicas[0].ReplicaID { |
|||
t.Fatalf("identity changed: %s → %s", |
|||
intent1.Replicas[0].ReplicaID, intent2.Replicas[0].ReplicaID) |
|||
} |
|||
|
|||
// Endpoint updated.
|
|||
if intent2.Replicas[0].Endpoint.DataAddr != "10.0.0.3:9333" { |
|||
t.Fatalf("endpoint not updated: %s", intent2.Replicas[0].Endpoint.DataAddr) |
|||
} |
|||
} |
|||
|
|||
func TestControlAdapter_RebuildRoleMapping(t *testing.T) { |
|||
ca := NewControlAdapter() |
|||
|
|||
intent := ca.ToAssignmentIntent( |
|||
MasterAssignment{VolumeName: "vol1", Epoch: 1, Role: "primary"}, |
|||
[]MasterAssignment{ |
|||
{VolumeName: "vol1", ReplicaServerID: "vs2", Role: "rebuilding", DataAddr: "10.0.0.2:9333"}, |
|||
}, |
|||
) |
|||
|
|||
if intent.RecoveryTargets["vol1/vs2"] != engine.SessionRebuild { |
|||
t.Fatalf("rebuilding role should map to SessionRebuild, got %s", |
|||
intent.RecoveryTargets["vol1/vs2"]) |
|||
} |
|||
} |
|||
|
|||
func TestControlAdapter_PrimaryNoRecovery(t *testing.T) { |
|||
ca := NewControlAdapter() |
|||
|
|||
intent := ca.ToAssignmentIntent( |
|||
MasterAssignment{VolumeName: "vol1", Epoch: 1, Role: "primary"}, |
|||
[]MasterAssignment{}, // no replicas
|
|||
) |
|||
|
|||
if len(intent.RecoveryTargets) != 0 { |
|||
t.Fatal("primary should not have recovery targets") |
|||
} |
|||
} |
|||
|
|||
// --- E2: Real storage truth → RetainedHistory ---
|
|||
|
|||
func TestStorageAdapter_RetainedHistoryFromRealState(t *testing.T) { |
|||
sa := NewStorageAdapter() |
|||
sa.UpdateState(BlockVolState{ |
|||
WALHeadLSN: 100, |
|||
WALTailLSN: 30, |
|||
CommittedLSN: 90, |
|||
CheckpointLSN: 50, |
|||
CheckpointTrusted: true, |
|||
}) |
|||
|
|||
rh := sa.GetRetainedHistory() |
|||
|
|||
if rh.HeadLSN != 100 { |
|||
t.Fatalf("HeadLSN=%d", rh.HeadLSN) |
|||
} |
|||
if rh.TailLSN != 30 { |
|||
t.Fatalf("TailLSN=%d", rh.TailLSN) |
|||
} |
|||
if rh.CommittedLSN != 90 { |
|||
t.Fatalf("CommittedLSN=%d", rh.CommittedLSN) |
|||
} |
|||
if rh.CheckpointLSN != 50 { |
|||
t.Fatalf("CheckpointLSN=%d", rh.CheckpointLSN) |
|||
} |
|||
if !rh.CheckpointTrusted { |
|||
t.Fatal("CheckpointTrusted should be true") |
|||
} |
|||
} |
|||
|
|||
func TestStorageAdapter_WALPinRejectsRecycled(t *testing.T) { |
|||
sa := NewStorageAdapter() |
|||
sa.UpdateState(BlockVolState{WALTailLSN: 50}) |
|||
|
|||
_, err := sa.PinWALRetention(30) // 30 < tail 50
|
|||
if err == nil { |
|||
t.Fatal("WAL pin should be rejected when range is recycled") |
|||
} |
|||
} |
|||
|
|||
func TestStorageAdapter_SnapshotPinRejectsInvalid(t *testing.T) { |
|||
sa := NewStorageAdapter() |
|||
sa.UpdateState(BlockVolState{CheckpointLSN: 50, CheckpointTrusted: false}) |
|||
|
|||
_, err := sa.PinSnapshot(50) |
|||
if err == nil { |
|||
t.Fatal("snapshot pin should be rejected when checkpoint is untrusted") |
|||
} |
|||
} |
|||
|
|||
// --- E3: Engine integration through bridge ---
|
|||
|
|||
func TestBridge_E2E_AssignmentToRecovery(t *testing.T) { |
|||
// Full bridge flow: master assignment → adapter → engine.
|
|||
ca := NewControlAdapter() |
|||
sa := NewStorageAdapter() |
|||
sa.UpdateState(BlockVolState{ |
|||
WALHeadLSN: 100, |
|||
WALTailLSN: 30, |
|||
CommittedLSN: 100, |
|||
CheckpointLSN: 50, |
|||
CheckpointTrusted: true, |
|||
}) |
|||
|
|||
// Step 1: master assignment → engine intent.
|
|||
intent := ca.ToAssignmentIntent( |
|||
MasterAssignment{VolumeName: "vol1", Epoch: 1, Role: "primary", PrimaryServerID: "vs1"}, |
|||
[]MasterAssignment{ |
|||
{VolumeName: "vol1", ReplicaServerID: "vs2", Role: "replica", |
|||
DataAddr: "10.0.0.2:9333", CtrlAddr: "10.0.0.2:9334", AddrVersion: 1}, |
|||
}, |
|||
) |
|||
|
|||
// Step 2: engine processes intent.
|
|||
drv := engine.NewRecoveryDriver(sa) |
|||
drv.Orchestrator.ProcessAssignment(intent) |
|||
|
|||
// Step 3: plan recovery from real storage state.
|
|||
plan, err := drv.PlanRecovery("vol1/vs2", 70) |
|||
if err != nil { |
|||
t.Fatal(err) |
|||
} |
|||
if plan.Outcome != engine.OutcomeCatchUp { |
|||
t.Fatalf("outcome=%s", plan.Outcome) |
|||
} |
|||
if !plan.Proof.Recoverable { |
|||
t.Fatalf("proof: %s", plan.Proof.Reason) |
|||
} |
|||
|
|||
// Step 4: execute through engine executor.
|
|||
exec := engine.NewCatchUpExecutor(drv, plan) |
|||
if err := exec.Execute([]uint64{80, 90, 100}, 0); err != nil { |
|||
t.Fatal(err) |
|||
} |
|||
|
|||
if drv.Orchestrator.Registry.Sender("vol1/vs2").State() != engine.StateInSync { |
|||
t.Fatalf("state=%s", drv.Orchestrator.Registry.Sender("vol1/vs2").State()) |
|||
} |
|||
} |
|||
@ -0,0 +1,83 @@ |
|||
package blockvol |
|||
|
|||
import ( |
|||
"fmt" |
|||
|
|||
engine "github.com/seaweedfs/seaweedfs/sw-block/engine/replication" |
|||
) |
|||
|
|||
// MasterAssignment represents a block-volume assignment from the master,
|
|||
// as delivered via heartbeat response. This is the raw input from the
|
|||
// existing master_grpc_server / block_heartbeat_loop path.
|
|||
type MasterAssignment struct { |
|||
VolumeName string // e.g., "pvc-data-1"
|
|||
Epoch uint64 |
|||
Role string // "primary", "replica", "rebuilding"
|
|||
PrimaryServerID string // which server is the primary
|
|||
ReplicaServerID string // which server is this replica
|
|||
DataAddr string // replica's current data address
|
|||
CtrlAddr string // replica's current control address
|
|||
AddrVersion uint64 // bumped on address change
|
|||
} |
|||
|
|||
// ControlAdapter converts master assignments into engine AssignmentIntent.
|
|||
// Identity mapping: ReplicaID = <volume-name>/<replica-server-id>.
|
|||
// This adapter does NOT decide recovery policy — it only translates
|
|||
// master role/state into engine SessionKind.
|
|||
type ControlAdapter struct{} |
|||
|
|||
// NewControlAdapter creates a control adapter.
|
|||
func NewControlAdapter() *ControlAdapter { |
|||
return &ControlAdapter{} |
|||
} |
|||
|
|||
// MakeReplicaID derives a stable engine ReplicaID from volume + server identity.
|
|||
// NOT derived from any address field.
|
|||
func MakeReplicaID(volumeName, serverID string) string { |
|||
return fmt.Sprintf("%s/%s", volumeName, serverID) |
|||
} |
|||
|
|||
// ToAssignmentIntent converts a master assignment into an engine intent.
|
|||
// The adapter maps role transitions to SessionKind but does NOT decide
|
|||
// the actual recovery outcome (that's the engine's job).
|
|||
func (ca *ControlAdapter) ToAssignmentIntent(primary MasterAssignment, replicas []MasterAssignment) engine.AssignmentIntent { |
|||
intent := engine.AssignmentIntent{ |
|||
Epoch: primary.Epoch, |
|||
} |
|||
|
|||
for _, r := range replicas { |
|||
replicaID := MakeReplicaID(r.VolumeName, r.ReplicaServerID) |
|||
intent.Replicas = append(intent.Replicas, engine.ReplicaAssignment{ |
|||
ReplicaID: replicaID, |
|||
Endpoint: engine.Endpoint{ |
|||
DataAddr: r.DataAddr, |
|||
CtrlAddr: r.CtrlAddr, |
|||
Version: r.AddrVersion, |
|||
}, |
|||
}) |
|||
|
|||
// Map role to recovery intent (if needed).
|
|||
kind := mapRoleToSessionKind(r.Role) |
|||
if kind != "" { |
|||
if intent.RecoveryTargets == nil { |
|||
intent.RecoveryTargets = map[string]engine.SessionKind{} |
|||
} |
|||
intent.RecoveryTargets[replicaID] = kind |
|||
} |
|||
} |
|||
|
|||
return intent |
|||
} |
|||
|
|||
// mapRoleToSessionKind maps a master-assigned role to an engine SessionKind.
|
|||
// This is a pure translation — NO policy decision.
|
|||
func mapRoleToSessionKind(role string) engine.SessionKind { |
|||
switch role { |
|||
case "replica": |
|||
return engine.SessionCatchUp // default recovery for reconnecting replicas
|
|||
case "rebuilding": |
|||
return engine.SessionRebuild |
|||
default: |
|||
return "" // no recovery needed (primary, or unknown)
|
|||
} |
|||
} |
|||
@ -0,0 +1,19 @@ |
|||
// Package blockvol bridges the V2 engine to real blockvol storage and
|
|||
// control-plane state.
|
|||
//
|
|||
// This package implements the adapter interfaces defined in
|
|||
// sw-block/engine/replication/ using real blockvol internals as the
|
|||
// source of truth.
|
|||
//
|
|||
// Hard rules (Phase 07):
|
|||
// - ReplicaID = <volume-name>/<replica-server-id> (not address-derived)
|
|||
// - blockvol executes recovery I/O but does NOT own recovery policy
|
|||
// - Engine decides zero-gap vs catch-up vs rebuild
|
|||
// - Bridge translates engine decisions into blockvol actions
|
|||
//
|
|||
// Adapter replacement order:
|
|||
// P0: control_adapter (assignment → engine intent)
|
|||
// P0: storage_adapter (blockvol state → RetainedHistory)
|
|||
// P1: executor_bridge (engine executor → blockvol I/O)
|
|||
// P1: observe_adapter (engine status → service diagnostics)
|
|||
package blockvol |
|||
@ -0,0 +1,7 @@ |
|||
module github.com/seaweedfs/seaweedfs/sw-block/bridge/blockvol |
|||
|
|||
go 1.23.0 |
|||
|
|||
require github.com/seaweedfs/seaweedfs/sw-block/engine/replication v0.0.0 |
|||
|
|||
replace github.com/seaweedfs/seaweedfs/sw-block/engine/replication => ../../engine/replication |
|||
@ -0,0 +1,125 @@ |
|||
package blockvol |
|||
|
|||
import ( |
|||
"fmt" |
|||
"sync" |
|||
"sync/atomic" |
|||
|
|||
engine "github.com/seaweedfs/seaweedfs/sw-block/engine/replication" |
|||
) |
|||
|
|||
// BlockVolState represents the real storage state from a blockvol instance.
|
|||
// This is populated from actual blockvol fields, not reconstructed from
|
|||
// metadata. Each field maps to a specific blockvol source:
|
|||
//
|
|||
// WALHeadLSN ← vol.wal.HeadLSN()
|
|||
// WALTailLSN ← vol.flusher.RetentionFloor()
|
|||
// CommittedLSN ← vol.coordinator.CommittedLSN (from group commit)
|
|||
// CheckpointLSN ← vol.superblock.CheckpointLSN
|
|||
// CheckpointTrusted ← vol.superblock.Valid && checkpoint file exists
|
|||
type BlockVolState struct { |
|||
WALHeadLSN uint64 |
|||
WALTailLSN uint64 |
|||
CommittedLSN uint64 |
|||
CheckpointLSN uint64 |
|||
CheckpointTrusted bool |
|||
} |
|||
|
|||
// StorageAdapter implements engine.StorageAdapter using real blockvol state.
|
|||
// It does NOT decide recovery policy — it only exposes storage truth.
|
|||
type StorageAdapter struct { |
|||
mu sync.Mutex |
|||
state BlockVolState |
|||
nextPinID atomic.Uint64 |
|||
|
|||
// Pin tracking (real implementation would hold actual file/WAL references).
|
|||
snapshotPins map[uint64]bool |
|||
walPins map[uint64]bool |
|||
fullBasePins map[uint64]bool |
|||
} |
|||
|
|||
// NewStorageAdapter creates a storage adapter. The caller must update
|
|||
// state via UpdateState when blockvol state changes.
|
|||
func NewStorageAdapter() *StorageAdapter { |
|||
return &StorageAdapter{ |
|||
snapshotPins: map[uint64]bool{}, |
|||
walPins: map[uint64]bool{}, |
|||
fullBasePins: map[uint64]bool{}, |
|||
} |
|||
} |
|||
|
|||
// UpdateState refreshes the adapter's view of blockvol state.
|
|||
// Called when blockvol completes a checkpoint, advances WAL tail, etc.
|
|||
func (sa *StorageAdapter) UpdateState(state BlockVolState) { |
|||
sa.mu.Lock() |
|||
defer sa.mu.Unlock() |
|||
sa.state = state |
|||
} |
|||
|
|||
// GetRetainedHistory returns the current WAL retention state from real
|
|||
// blockvol fields. This is NOT reconstructed from test inputs.
|
|||
func (sa *StorageAdapter) GetRetainedHistory() engine.RetainedHistory { |
|||
sa.mu.Lock() |
|||
defer sa.mu.Unlock() |
|||
return engine.RetainedHistory{ |
|||
HeadLSN: sa.state.WALHeadLSN, |
|||
TailLSN: sa.state.WALTailLSN, |
|||
CommittedLSN: sa.state.CommittedLSN, |
|||
CheckpointLSN: sa.state.CheckpointLSN, |
|||
CheckpointTrusted: sa.state.CheckpointTrusted, |
|||
} |
|||
} |
|||
|
|||
// PinSnapshot pins a checkpoint for rebuild use.
|
|||
func (sa *StorageAdapter) PinSnapshot(checkpointLSN uint64) (engine.SnapshotPin, error) { |
|||
sa.mu.Lock() |
|||
defer sa.mu.Unlock() |
|||
if !sa.state.CheckpointTrusted || sa.state.CheckpointLSN != checkpointLSN { |
|||
return engine.SnapshotPin{}, fmt.Errorf("no valid checkpoint at LSN %d", checkpointLSN) |
|||
} |
|||
id := sa.nextPinID.Add(1) |
|||
sa.snapshotPins[id] = true |
|||
return engine.SnapshotPin{LSN: checkpointLSN, PinID: id, Valid: true}, nil |
|||
} |
|||
|
|||
// ReleaseSnapshot releases a pinned snapshot.
|
|||
func (sa *StorageAdapter) ReleaseSnapshot(pin engine.SnapshotPin) { |
|||
sa.mu.Lock() |
|||
defer sa.mu.Unlock() |
|||
delete(sa.snapshotPins, pin.PinID) |
|||
} |
|||
|
|||
// PinWALRetention holds WAL entries from startLSN.
|
|||
func (sa *StorageAdapter) PinWALRetention(startLSN uint64) (engine.RetentionPin, error) { |
|||
sa.mu.Lock() |
|||
defer sa.mu.Unlock() |
|||
if startLSN < sa.state.WALTailLSN { |
|||
return engine.RetentionPin{}, fmt.Errorf("WAL already recycled past LSN %d (tail=%d)", startLSN, sa.state.WALTailLSN) |
|||
} |
|||
id := sa.nextPinID.Add(1) |
|||
sa.walPins[id] = true |
|||
return engine.RetentionPin{StartLSN: startLSN, PinID: id, Valid: true}, nil |
|||
} |
|||
|
|||
// ReleaseWALRetention releases a WAL retention hold.
|
|||
func (sa *StorageAdapter) ReleaseWALRetention(pin engine.RetentionPin) { |
|||
sa.mu.Lock() |
|||
defer sa.mu.Unlock() |
|||
delete(sa.walPins, pin.PinID) |
|||
} |
|||
|
|||
// PinFullBase pins a full-extent base image for full-base rebuild.
|
|||
func (sa *StorageAdapter) PinFullBase(committedLSN uint64) (engine.FullBasePin, error) { |
|||
sa.mu.Lock() |
|||
defer sa.mu.Unlock() |
|||
id := sa.nextPinID.Add(1) |
|||
sa.fullBasePins[id] = true |
|||
return engine.FullBasePin{CommittedLSN: committedLSN, PinID: id, Valid: true}, nil |
|||
} |
|||
|
|||
// ReleaseFullBase releases a pinned full base image.
|
|||
func (sa *StorageAdapter) ReleaseFullBase(pin engine.FullBasePin) { |
|||
sa.mu.Lock() |
|||
defer sa.mu.Unlock() |
|||
delete(sa.fullBasePins, pin.PinID) |
|||
} |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue