feat: CP11B-3 safe ops — promotion hardening, preflight, manual promote

Six-task checkpoint hardening the promotion and failover paths:

T1: 4-gate candidate evaluation (heartbeat freshness, WAL lag, role,
    server liveness) with structured rejection reasons.
T2: Orphaned-primary re-evaluation on replica reconnect (B-06/B-08).
T3: Deferred timer safety — epoch validation prevents stale timers
    from firing on recreated/changed volumes (B-07).
T4: Rebuild addr cleanup on promotion (B-11), NVMe publication
    refresh on heartbeat, and preflight endpoint wiring.
T5: Manual promote API — POST /block/volume/{name}/promote with
    force flag, target server selection, and structured rejection
    response. Shared applyPromotionLocked/finalizePromotion helpers
    eliminate duplication between auto and manual paths.
T6: Read-only preflight endpoint (GET /block/volume/{name}/preflight)
    and blockapi client wrappers (Preflight, Promote).

BUG-T5-1: PromotionsTotal counter moved to finalizePromotion (shared
    by both auto and manual paths) to prevent metrics divergence.

24 files changed, ~6500 lines added. 42 new QA adversarial tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ping QiuandClaude Opus 4.6 committed 2026-03-13 17:21:17 -07:00
1 parent ed11a09a61
commit 075ff52219
24 files changed
+6540 -114

No files matched your search

+5 -2
View File
@@ -645,13 +645,16 @@ func TestIntegration_DoubleFailover(t *testing.T) {
// Reconnect vs1 first so it becomes a replica (via recoverBlockVolumes).
ms.recoverBlockVolumes(vs1)
// Simulate heartbeat from vs1 that restores iSCSI addr and health score
// (in production this happens when the VS re-registers after reconnect).
// Simulate heartbeat from vs1 that restores iSCSI addr, health score,
// role, and heartbeat timestamp (in production this happens when the
// VS re-registers after reconnect and completes rebuild).
e1, _ = ms.blockRegistry.Lookup("pvc-double-1")
for i := range e1.Replicas {
if e1.Replicas[i].Server == vs1 {
e1.Replicas[i].ISCSIAddr = vs1 + ":3260"
e1.Replicas[i].HealthScore = 1.0
e1.Replicas[i].Role = blockvol.RoleToWire(blockvol.RoleReplica)
e1.Replicas[i].LastHeartbeat = time.Now()
}
}
+85 -4
View File
@@ -57,7 +57,19 @@ func (ms *MasterServer) failoverBlockVolumes(deadServer string) {
delay := leaseExpiry.Sub(now)
glog.V(0).Infof("failover: %q lease expires in %v, deferring promotion", entry.Name, delay)
volumeName := entry.Name
capturedEpoch := entry.Epoch // T3: capture epoch for stale-timer validation
timer := time.AfterFunc(delay, func() {
// T3: Re-validate before acting — prevent stale timer on recreated/changed volume.
current, ok := ms.blockRegistry.Lookup(volumeName)
if !ok {
glog.V(0).Infof("failover: deferred promotion for %q skipped (volume deleted)", volumeName)
return
}
if current.Epoch != capturedEpoch {
glog.V(0).Infof("failover: deferred promotion for %q skipped (epoch changed %d -> %d)",
volumeName, capturedEpoch, current.Epoch)
return
}
ms.promoteReplica(volumeName)
})
ms.blockFailover.mu.Lock()
@@ -116,8 +128,15 @@ func (ms *MasterServer) promoteReplica(volumeName string) {
return
}
ms.finalizePromotion(volumeName, oldPrimary, oldPath, newEpoch)
}
// finalizePromotion performs post-registry promotion steps:
// enqueue assignment for new primary, record pending rebuild for old primary, bump metrics.
// Called by both promoteReplica (auto) and blockVolumePromoteHandler (manual).
func (ms *MasterServer) finalizePromotion(volumeName, oldPrimary, oldPath string, newEpoch uint64) {
// Re-read entry after promotion.
entry, ok = ms.blockRegistry.Lookup(volumeName)
entry, ok := ms.blockRegistry.Lookup(volumeName)
if !ok {
return
}
@@ -198,11 +217,15 @@ func (ms *MasterServer) cancelDeferredTimers(server string) {
// recoverBlockVolumes is called when a previously dead VS reconnects.
// It cancels any deferred promotion timers (R2-F2), drains pending rebuilds,
// and enqueues rebuild assignments.
// enqueues rebuild assignments, and checks for orphaned primaries (T2/B-06).
func (ms *MasterServer) recoverBlockVolumes(reconnectedServer string) {
// R2-F2: Cancel deferred promotion timers for this server to prevent split-brain.
ms.cancelDeferredTimers(reconnectedServer)
// T2 (B-06): Check for orphaned primaries — volumes where the reconnecting
// server is a replica but the primary is dead/disconnected.
ms.reevaluateOrphanedPrimaries(reconnectedServer)
rebuilds := ms.drainPendingRebuilds(reconnectedServer)
if len(rebuilds) == 0 {
return
@@ -221,16 +244,74 @@ func (ms *MasterServer) recoverBlockVolumes(reconnectedServer string) {
Path: rb.OldPath,
})
// T4: Warn if RebuildListenAddr is empty (new primary hasn't heartbeated yet).
rebuildAddr := entry.RebuildListenAddr
if rebuildAddr == "" {
glog.Warningf("rebuild: %q RebuildListenAddr is empty (new primary %s may not have heartbeated yet), "+
"queuing rebuild anyway — VS should retry on empty addr", rb.VolumeName, entry.VolumeServer)
}
// Enqueue rebuild assignment for the reconnected server.
ms.blockAssignmentQueue.Enqueue(reconnectedServer, blockvol.BlockVolumeAssignment{
Path: rb.OldPath,
Epoch: entry.Epoch,
Role: blockvol.RoleToWire(blockvol.RoleRebuilding),
RebuildAddr: entry.RebuildListenAddr,
RebuildAddr: rebuildAddr,
})
ms.blockRegistry.RebuildsTotal.Add(1)
glog.V(0).Infof("rebuild: enqueued rebuild for %q on %s (epoch=%d, rebuildAddr=%s)",
rb.VolumeName, reconnectedServer, entry.Epoch, entry.RebuildListenAddr)
rb.VolumeName, reconnectedServer, entry.Epoch, rebuildAddr)
}
}
// reevaluateOrphanedPrimaries checks if the given server is a replica for any
// volumes whose primary is dead (not block-capable). If so, promotes the best
// available replica — but only after the old primary's lease has expired, to
// maintain the same split-brain protection as failoverBlockVolumes().
// This fixes B-06 (orphaned primary after replica re-register)
// and partially B-08 (fast reconnect skips failover window).
func (ms *MasterServer) reevaluateOrphanedPrimaries(server string) {
if ms.blockRegistry == nil {
return
}
orphaned := ms.blockRegistry.VolumesWithDeadPrimary(server)
now := time.Now()
for _, volumeName := range orphaned {
entry, ok := ms.blockRegistry.Lookup(volumeName)
if !ok {
continue
}
// Respect lease expiry — same gate as failoverBlockVolumes().
leaseExpiry := entry.LastLeaseGrant.Add(entry.LeaseTTL)
if now.Before(leaseExpiry) {
delay := leaseExpiry.Sub(now)
glog.V(0).Infof("failover: orphaned primary for %q (replica %s alive, primary dead) "+
"but lease expires in %v, deferring promotion", volumeName, server, delay)
capturedEpoch := entry.Epoch
deadPrimary := entry.VolumeServer
timer := time.AfterFunc(delay, func() {
current, ok := ms.blockRegistry.Lookup(volumeName)
if !ok {
return
}
if current.Epoch != capturedEpoch {
glog.V(0).Infof("failover: deferred orphan promotion for %q skipped (epoch changed %d -> %d)",
volumeName, capturedEpoch, current.Epoch)
return
}
ms.promoteReplica(volumeName)
})
ms.blockFailover.mu.Lock()
ms.blockFailover.deferredTimers[deadPrimary] = append(
ms.blockFailover.deferredTimers[deadPrimary], timer)
ms.blockFailover.mu.Unlock()
continue
}
glog.V(0).Infof("failover: orphaned primary detected for %q (replica %s alive, primary dead, lease expired), promoting",
volumeName, server)
ms.promoteReplica(volumeName)
}
}
+311 -24
View File
@@ -34,6 +34,9 @@ func testMasterServerForFailover(t *testing.T) *MasterServer {
// registerVolumeWithReplica creates a volume entry with primary + replica for tests.
func registerVolumeWithReplica(t *testing.T, ms *MasterServer, name, primary, replica string, epoch uint64, leaseTTL time.Duration) {
t.Helper()
// Mark both servers as block-capable so promotion Gate 4 (liveness) passes.
ms.blockRegistry.MarkBlockCapable(primary)
ms.blockRegistry.MarkBlockCapable(replica)
entry := &BlockVolumeEntry{
Name: name,
VolumeServer: primary,
@@ -53,11 +56,13 @@ func registerVolumeWithReplica(t *testing.T, ms *MasterServer, name, primary, re
// CP8-2: also populate Replicas[] for PromoteBestReplica.
Replicas: []ReplicaInfo{
{
Server: replica,
Path: fmt.Sprintf("/data/%s.blk", name),
IQN: fmt.Sprintf("iqn.2024.test:%s-replica", name),
ISCSIAddr: replica + ":3260",
HealthScore: 1.0,
Server: replica,
Path: fmt.Sprintf("/data/%s.blk", name),
IQN: fmt.Sprintf("iqn.2024.test:%s-replica", name),
ISCSIAddr: replica + ":3260",
HealthScore: 1.0,
Role: blockvol.RoleToWire(blockvol.RoleReplica),
LastHeartbeat: time.Now(),
},
},
}
@@ -194,6 +199,9 @@ func TestFailover_MultipleVolumes(t *testing.T) {
func TestFailover_LeaseNotExpired_DeferredPromotion(t *testing.T) {
ms := testMasterServerForFailover(t)
// Mark servers as block-capable so promotion Gate 4 (liveness) passes.
ms.blockRegistry.MarkBlockCapable("vs1")
ms.blockRegistry.MarkBlockCapable("vs2")
entry := &BlockVolumeEntry{
Name: "vol1",
VolumeServer: "vs1",
@@ -209,7 +217,7 @@ func TestFailover_LeaseNotExpired_DeferredPromotion(t *testing.T) {
LeaseTTL: 200 * time.Millisecond,
LastLeaseGrant: time.Now(), // just granted, NOT expired yet
Replicas: []ReplicaInfo{
{Server: "vs2", Path: "/data/vol1.blk", IQN: "iqn:vol1-r", ISCSIAddr: "vs2:3260", HealthScore: 1.0},
{Server: "vs2", Path: "/data/vol1.blk", IQN: "iqn:vol1-r", ISCSIAddr: "vs2:3260", HealthScore: 1.0, Role: blockvol.RoleToWire(blockvol.RoleReplica), LastHeartbeat: time.Now()},
},
}
ms.blockRegistry.Register(entry)
@@ -397,6 +405,9 @@ func TestRebuild_RegistryUpdatedWithNewReplica(t *testing.T) {
func TestRebuild_AssignmentContainsRebuildAddr(t *testing.T) {
ms := testMasterServerForFailover(t)
// Mark servers as block-capable so promotion Gate 4 (liveness) passes.
ms.blockRegistry.MarkBlockCapable("vs1")
ms.blockRegistry.MarkBlockCapable("vs2")
entry := &BlockVolumeEntry{
Name: "vol1",
VolumeServer: "vs1",
@@ -413,7 +424,7 @@ func TestRebuild_AssignmentContainsRebuildAddr(t *testing.T) {
LeaseTTL: 5 * time.Second,
LastLeaseGrant: time.Now().Add(-10 * time.Second),
Replicas: []ReplicaInfo{
{Server: "vs2", Path: "/data/vol1.blk", IQN: "iqn:vol1-r", ISCSIAddr: "vs2:3260", HealthScore: 1.0},
{Server: "vs2", Path: "/data/vol1.blk", IQN: "iqn:vol1-r", ISCSIAddr: "vs2:3260", HealthScore: 1.0, Role: blockvol.RoleToWire(blockvol.RoleReplica), LastHeartbeat: time.Now()},
},
}
ms.blockRegistry.Register(entry)
@@ -457,7 +468,7 @@ func TestFailover_TransientDisconnect_NoPromotion(t *testing.T) {
LeaseTTL: 30 * time.Second,
LastLeaseGrant: time.Now(), // just granted
Replicas: []ReplicaInfo{
{Server: "vs2", Path: "/data/vol1.blk", IQN: "iqn:vol1-r", ISCSIAddr: "vs2:3260", HealthScore: 1.0},
{Server: "vs2", Path: "/data/vol1.blk", IQN: "iqn:vol1-r", ISCSIAddr: "vs2:3260", HealthScore: 1.0, Role: blockvol.RoleToWire(blockvol.RoleReplica), LastHeartbeat: time.Now()},
},
}
ms.blockRegistry.Register(entry)
@@ -556,6 +567,10 @@ func TestLifecycle_CreateFailoverRebuild(t *testing.T) {
// registerVolumeRF3 creates a volume entry with primary + 2 replicas for RF=3 tests.
func registerVolumeRF3(t *testing.T, ms *MasterServer, name, primary, replica1, replica2 string, epoch uint64, leaseTTL time.Duration) {
t.Helper()
// Mark all servers as block-capable so promotion Gate 4 (liveness) passes.
ms.blockRegistry.MarkBlockCapable(primary)
ms.blockRegistry.MarkBlockCapable(replica1)
ms.blockRegistry.MarkBlockCapable(replica2)
entry := &BlockVolumeEntry{
Name: name,
VolumeServer: primary,
@@ -576,20 +591,24 @@ func registerVolumeRF3(t *testing.T, ms *MasterServer, name, primary, replica1,
ReplicaISCSIAddr: replica1 + ":3260",
Replicas: []ReplicaInfo{
{
Server: replica1,
Path: fmt.Sprintf("/data/%s.blk", name),
IQN: fmt.Sprintf("iqn.2024.test:%s-r1", name),
ISCSIAddr: replica1 + ":3260",
HealthScore: 1.0,
WALHeadLSN: 100,
Server: replica1,
Path: fmt.Sprintf("/data/%s.blk", name),
IQN: fmt.Sprintf("iqn.2024.test:%s-r1", name),
ISCSIAddr: replica1 + ":3260",
HealthScore: 1.0,
WALHeadLSN: 100,
Role: blockvol.RoleToWire(blockvol.RoleReplica),
LastHeartbeat: time.Now(),
},
{
Server: replica2,
Path: fmt.Sprintf("/data/%s.blk", name),
IQN: fmt.Sprintf("iqn.2024.test:%s-r2", name),
ISCSIAddr: replica2 + ":3260",
HealthScore: 1.0,
WALHeadLSN: 100,
Server: replica2,
Path: fmt.Sprintf("/data/%s.blk", name),
IQN: fmt.Sprintf("iqn.2024.test:%s-r2", name),
ISCSIAddr: replica2 + ":3260",
HealthScore: 1.0,
WALHeadLSN: 100,
Role: blockvol.RoleToWire(blockvol.RoleReplica),
LastHeartbeat: time.Now(),
},
},
}
@@ -793,6 +812,10 @@ func TestRF3_AllReplicasDead_NoPromotion(t *testing.T) {
// RF3: Lease deferred promotion with RF=3.
func TestRF3_LeaseDeferred_Promotion(t *testing.T) {
ms := testMasterServerForFailover(t)
// Mark servers as block-capable so promotion Gate 4 (liveness) passes.
ms.blockRegistry.MarkBlockCapable("vs1")
ms.blockRegistry.MarkBlockCapable("vs2")
ms.blockRegistry.MarkBlockCapable("vs3")
entry := &BlockVolumeEntry{
Name: "vol1",
VolumeServer: "vs1",
@@ -807,8 +830,8 @@ func TestRF3_LeaseDeferred_Promotion(t *testing.T) {
LeaseTTL: 200 * time.Millisecond,
LastLeaseGrant: time.Now(), // just granted → NOT expired
Replicas: []ReplicaInfo{
{Server: "vs2", Path: "/data/vol1.blk", ISCSIAddr: "vs2:3260", HealthScore: 1.0, WALHeadLSN: 50},
{Server: "vs3", Path: "/data/vol1.blk", ISCSIAddr: "vs3:3260", HealthScore: 0.9, WALHeadLSN: 50},
{Server: "vs2", Path: "/data/vol1.blk", ISCSIAddr: "vs2:3260", HealthScore: 1.0, WALHeadLSN: 50, Role: blockvol.RoleToWire(blockvol.RoleReplica), LastHeartbeat: time.Now()},
{Server: "vs3", Path: "/data/vol1.blk", ISCSIAddr: "vs3:3260", HealthScore: 0.9, WALHeadLSN: 50, Role: blockvol.RoleToWire(blockvol.RoleReplica), LastHeartbeat: time.Now()},
},
// Deprecated scalar fields.
ReplicaServer: "vs2", ReplicaPath: "/data/vol1.blk", ReplicaISCSIAddr: "vs2:3260",
@@ -853,8 +876,8 @@ func TestRF3_CancelDeferredOnReconnect(t *testing.T) {
LeaseTTL: 5 * time.Second,
LastLeaseGrant: time.Now(), // just granted → long lease
Replicas: []ReplicaInfo{
{Server: "vs2", Path: "/data/vol1.blk", ISCSIAddr: "vs2:3260", HealthScore: 1.0},
{Server: "vs3", Path: "/data/vol1.blk", ISCSIAddr: "vs3:3260", HealthScore: 1.0},
{Server: "vs2", Path: "/data/vol1.blk", ISCSIAddr: "vs2:3260", HealthScore: 1.0, Role: blockvol.RoleToWire(blockvol.RoleReplica), LastHeartbeat: time.Now()},
{Server: "vs3", Path: "/data/vol1.blk", ISCSIAddr: "vs3:3260", HealthScore: 1.0, Role: blockvol.RoleToWire(blockvol.RoleReplica), LastHeartbeat: time.Now()},
},
ReplicaServer: "vs2", ReplicaPath: "/data/vol1.blk", ReplicaISCSIAddr: "vs2:3260",
}
@@ -888,3 +911,267 @@ func TestRF3_CancelDeferredOnReconnect(t *testing.T) {
t.Fatalf("vs1 should remain primary (timer cancelled), got %q", e.VolumeServer)
}
}
// ============================================================
// CP11B-3 T2: Re-evaluate on Replica Registration (B-06)
// ============================================================
// T2: Orphaned primary + replica reconnects → automatic promotion.
func TestT2_OrphanedPrimary_ReplicaReconnect_Promotes(t *testing.T) {
ms := testMasterServerForFailover(t)
registerVolumeWithReplica(t, ms, "vol1", "vs1", "vs2", 1, 5*time.Second)
// Simulate vs1 dying without proper failover (e.g., promotion failed at the time).
// Mark vs1 as dead but DON'T call failoverBlockVolumes (simulates missed/failed failover).
ms.blockRegistry.UnmarkBlockCapable("vs1")
// vs2 reconnects (sends heartbeat). reevaluateOrphanedPrimaries should detect orphaned primary.
ms.recoverBlockVolumes("vs2")
entry, _ := ms.blockRegistry.Lookup("vol1")
if entry.VolumeServer != "vs2" {
t.Fatalf("expected promotion to vs2 (orphaned primary), got %q", entry.VolumeServer)
}
if entry.Epoch != 2 {
t.Fatalf("expected epoch 2 after promotion, got %d", entry.Epoch)
}
}
// T2: Replica reconnects but primary is alive → no unnecessary promotion.
func TestT2_PrimaryAlive_NoPromotion(t *testing.T) {
ms := testMasterServerForFailover(t)
registerVolumeWithReplica(t, ms, "vol1", "vs1", "vs2", 1, 5*time.Second)
// Both servers alive. vs2 reconnects — no orphaned primary.
ms.recoverBlockVolumes("vs2")
entry, _ := ms.blockRegistry.Lookup("vol1")
if entry.VolumeServer != "vs1" {
t.Fatalf("primary should remain vs1 (alive), got %q", entry.VolumeServer)
}
if entry.Epoch != 1 {
t.Fatalf("epoch should remain 1, got %d", entry.Epoch)
}
}
// T2: Multiple orphaned volumes, all promoted on reconnect.
func TestT2_MultipleOrphanedVolumes(t *testing.T) {
ms := testMasterServerForFailover(t)
// vol1: vs1=primary, vs2=replica
// vol2: vs3=primary, vs2=replica
registerVolumeWithReplica(t, ms, "vol1", "vs1", "vs2", 1, 5*time.Second)
ms.blockRegistry.MarkBlockCapable("vs3")
entry2 := &BlockVolumeEntry{
Name: "vol2", VolumeServer: "vs3", Path: "/data/vol2.blk",
SizeBytes: 1 << 30, Epoch: 1, Role: blockvol.RoleToWire(blockvol.RolePrimary),
Status: StatusActive, LeaseTTL: 5 * time.Second,
LastLeaseGrant: time.Now().Add(-10 * time.Second),
Replicas: []ReplicaInfo{{
Server: "vs2", Path: "/data/vol2.blk", HealthScore: 1.0,
Role: blockvol.RoleToWire(blockvol.RoleReplica), LastHeartbeat: time.Now(),
}},
}
ms.blockRegistry.Register(entry2)
// Both primaries die.
ms.blockRegistry.UnmarkBlockCapable("vs1")
ms.blockRegistry.UnmarkBlockCapable("vs3")
// vs2 reconnects → both orphaned volumes should be promoted.
ms.recoverBlockVolumes("vs2")
e1, _ := ms.blockRegistry.Lookup("vol1")
e2, _ := ms.blockRegistry.Lookup("vol2")
if e1.VolumeServer != "vs2" {
t.Fatalf("vol1: expected promotion to vs2, got %q", e1.VolumeServer)
}
if e2.VolumeServer != "vs2" {
t.Fatalf("vol2: expected promotion to vs2, got %q", e2.VolumeServer)
}
}
// T2: Repeated heartbeats do NOT cause duplicate promotions.
func TestT2_RepeatedHeartbeats_NoDuplicatePromotion(t *testing.T) {
ms := testMasterServerForFailover(t)
registerVolumeWithReplica(t, ms, "vol1", "vs1", "vs2", 1, 5*time.Second)
ms.blockRegistry.UnmarkBlockCapable("vs1")
// First reconnect promotes.
ms.reevaluateOrphanedPrimaries("vs2")
entry, _ := ms.blockRegistry.Lookup("vol1")
if entry.VolumeServer != "vs2" {
t.Fatalf("first call: expected promotion to vs2, got %q", entry.VolumeServer)
}
epochAfterFirst := entry.Epoch
// Second call: vs2 is now the primary AND block-capable. No orphan detected.
ms.reevaluateOrphanedPrimaries("vs2")
entry, _ = ms.blockRegistry.Lookup("vol1")
if entry.Epoch != epochAfterFirst {
t.Fatalf("second call should not bump epoch: got %d, want %d", entry.Epoch, epochAfterFirst)
}
}
// T2: Dead primary with active lease, replica reconnects → no immediate promotion.
// Regression test for lease-bypass bug: reevaluateOrphanedPrimaries must respect
// lease expiry, not promote immediately.
func TestT2_OrphanedPrimary_LeaseNotExpired_DefersPromotion(t *testing.T) {
ms := testMasterServerForFailover(t)
ms.blockRegistry.MarkBlockCapable("vs1")
ms.blockRegistry.MarkBlockCapable("vs2")
ms.blockRegistry.Register(&BlockVolumeEntry{
Name: "vol1", VolumeServer: "vs1", Path: "/data/vol1.blk",
SizeBytes: 1 << 30, Epoch: 1, Role: blockvol.RoleToWire(blockvol.RolePrimary),
Status: StatusActive, LeaseTTL: 300 * time.Millisecond,
LastLeaseGrant: time.Now(), // lease still active
Replicas: []ReplicaInfo{{
Server: "vs2", Path: "/data/vol1.blk", HealthScore: 1.0,
Role: blockvol.RoleToWire(blockvol.RoleReplica), LastHeartbeat: time.Now(),
}},
})
// vs1 dies (unmark block-capable).
ms.blockRegistry.UnmarkBlockCapable("vs1")
// vs2 reconnects — orphan detected, but lease still active → should NOT promote immediately.
ms.reevaluateOrphanedPrimaries("vs2")
entry, _ := ms.blockRegistry.Lookup("vol1")
if entry.VolumeServer != "vs1" {
t.Fatalf("should NOT promote while lease active, got primary=%q", entry.VolumeServer)
}
if entry.Epoch != 1 {
t.Fatalf("epoch should remain 1, got %d", entry.Epoch)
}
// Verify a deferred timer was created for the dead primary.
ms.blockFailover.mu.Lock()
timerCount := len(ms.blockFailover.deferredTimers["vs1"])
ms.blockFailover.mu.Unlock()
if timerCount != 1 {
t.Fatalf("expected 1 deferred timer for vs1, got %d", timerCount)
}
// Wait for lease to expire + margin → timer fires, promotion happens.
time.Sleep(450 * time.Millisecond)
entry, _ = ms.blockRegistry.Lookup("vol1")
if entry.VolumeServer != "vs2" {
t.Fatalf("after lease expiry, expected promotion to vs2, got %q", entry.VolumeServer)
}
if entry.Epoch != 2 {
t.Fatalf("expected epoch 2, got %d", entry.Epoch)
}
}
// ============================================================
// CP11B-3 T3: Deferred Timer Safety
// ============================================================
// T3: Delete/recreate volume before deferred timer fires → no wrong promotion.
func TestT3_DeferredTimer_VolumeDeleted_NoPromotion(t *testing.T) {
ms := testMasterServerForFailover(t)
ms.blockRegistry.MarkBlockCapable("vs1")
ms.blockRegistry.MarkBlockCapable("vs2")
entry := &BlockVolumeEntry{
Name: "vol1", VolumeServer: "vs1", Path: "/data/vol1.blk",
SizeBytes: 1 << 30, Epoch: 5, Role: blockvol.RoleToWire(blockvol.RolePrimary),
Status: StatusActive, LeaseTTL: 200 * time.Millisecond,
LastLeaseGrant: time.Now(),
Replicas: []ReplicaInfo{{
Server: "vs2", Path: "/data/vol1.blk", HealthScore: 1.0,
Role: blockvol.RoleToWire(blockvol.RoleReplica), LastHeartbeat: time.Now(),
}},
}
ms.blockRegistry.Register(entry)
// vs1 dies → deferred timer created (lease not expired, epoch=5).
ms.failoverBlockVolumes("vs1")
// Delete the volume before timer fires.
ms.blockRegistry.Unregister("vol1")
// Wait for timer to fire.
time.Sleep(350 * time.Millisecond)
// Volume should not exist (timer found it deleted, no-op).
_, ok := ms.blockRegistry.Lookup("vol1")
if ok {
t.Fatal("volume should have been deleted, timer should not recreate it")
}
}
// T3: Epoch changes before deferred timer fires → timer rejected.
func TestT3_DeferredTimer_EpochChanged_NoPromotion(t *testing.T) {
ms := testMasterServerForFailover(t)
ms.blockRegistry.MarkBlockCapable("vs1")
ms.blockRegistry.MarkBlockCapable("vs2")
ms.blockRegistry.MarkBlockCapable("vs3")
entry := &BlockVolumeEntry{
Name: "vol1", VolumeServer: "vs1", Path: "/data/vol1.blk",
SizeBytes: 1 << 30, Epoch: 5, Role: blockvol.RoleToWire(blockvol.RolePrimary),
Status: StatusActive, LeaseTTL: 200 * time.Millisecond,
LastLeaseGrant: time.Now(),
Replicas: []ReplicaInfo{{
Server: "vs2", Path: "/data/vol1.blk", HealthScore: 1.0,
Role: blockvol.RoleToWire(blockvol.RoleReplica), LastHeartbeat: time.Now(),
}},
}
ms.blockRegistry.Register(entry)
// vs1 dies → deferred timer created (captures epoch=5).
ms.failoverBlockVolumes("vs1")
// Before timer fires, manually bump the epoch (simulating another event).
e, _ := ms.blockRegistry.Lookup("vol1")
e.Epoch = 99
// Wait for timer to fire.
time.Sleep(350 * time.Millisecond)
// Timer should have been rejected (epoch mismatch). Epoch stays at 99.
e, _ = ms.blockRegistry.Lookup("vol1")
if e.Epoch != 99 {
t.Fatalf("epoch should remain 99 (timer rejected), got %d", e.Epoch)
}
// Primary should NOT have changed (deferred promotion was rejected).
if e.VolumeServer != "vs1" {
t.Fatalf("primary should remain vs1 (timer rejected), got %q", e.VolumeServer)
}
}
// ============================================================
// CP11B-3 T4: Rebuild with empty RebuildListenAddr
// ============================================================
// T4: Rebuild queued with empty RebuildListenAddr after promotion.
func TestT4_RebuildEmptyAddr_StillQueued(t *testing.T) {
ms := testMasterServerForFailover(t)
registerVolumeWithReplica(t, ms, "vol1", "vs1", "vs2", 1, 5*time.Second)
// Failover: vs1 dies, vs2 promoted. PromoteBestReplica clears RebuildListenAddr.
ms.failoverBlockVolumes("vs1")
entry, _ := ms.blockRegistry.Lookup("vol1")
if entry.RebuildListenAddr != "" {
t.Fatalf("RebuildListenAddr should be empty after promotion, got %q", entry.RebuildListenAddr)
}
// vs1 reconnects. Rebuild should still be queued (even with empty addr).
ms.recoverBlockVolumes("vs1")
assignments := ms.blockAssignmentQueue.Peek("vs1")
foundRebuild := false
for _, a := range assignments {
if blockvol.RoleFromWire(a.Role) == blockvol.RoleRebuilding {
foundRebuild = true
if a.RebuildAddr != "" {
t.Fatalf("RebuildAddr should be empty (new primary hasn't heartbeated), got %q", a.RebuildAddr)
}
}
}
if !foundRebuild {
t.Fatal("rebuild assignment should still be queued even with empty addr")
}
}
+344 -28
View File
@@ -842,44 +842,91 @@ func (r *BlockVolumeRegistry) PromotionLSNTolerance() uint64 {
return r.promotionLSNTolerance
}
// PromoteBestReplica promotes the best eligible replica to primary.
// Eligibility: heartbeat fresh (within 2×LeaseTTL), WALHeadLSN within tolerance of primary,
// and role must be RoleReplica (not RoleRebuilding).
// The promoted replica is removed from Replicas[]. Other replicas stay.
// Old primary is NOT added to Replicas (needs rebuild).
// Returns the new epoch.
func (r *BlockVolumeRegistry) PromoteBestReplica(name string) (uint64, error) {
r.mu.Lock()
defer r.mu.Unlock()
entry, ok := r.volumes[name]
if !ok {
return 0, fmt.Errorf("block volume %q not found", name)
// PromotionRejection records why a specific replica was rejected for promotion.
type PromotionRejection struct {
Server string
Reason string // "stale_heartbeat", "wal_lag", "wrong_role", "server_dead"
}
// PromotionPreflightResult is the reusable result of a promotion evaluation.
// Used by auto-promotion, manual promote API, preflight status, and logging.
type PromotionPreflightResult struct {
VolumeName string
Promotable bool // true if a candidate was found
Candidate *ReplicaInfo // best candidate (nil if !Promotable)
CandidateIdx int // index in Replicas[] (-1 if !Promotable)
Rejections []PromotionRejection // why each non-candidate was rejected
Reason string // human-readable summary when !Promotable
}
// evaluatePromotionLocked evaluates promotion candidates for a volume.
// Caller must hold r.mu (read or write). Returns a preflight result without
// mutating the registry. The four gates:
// 1. Heartbeat freshness (within 2×LeaseTTL)
// 2. WAL LSN recency (within promotionLSNTolerance of primary)
// 3. Role must be RoleReplica (not RoleRebuilding)
// 4. Server must be in blockServers (alive) — fixes B-12
func (r *BlockVolumeRegistry) evaluatePromotionLocked(entry *BlockVolumeEntry) PromotionPreflightResult {
result := PromotionPreflightResult{
VolumeName: entry.Name,
CandidateIdx: -1,
}
if len(entry.Replicas) == 0 {
return 0, fmt.Errorf("block volume %q has no replicas", name)
result.Reason = "no replicas"
return result
}
// Filter eligible replicas.
now := time.Now()
freshnessCutoff := 2 * entry.LeaseTTL
if freshnessCutoff == 0 {
freshnessCutoff = 60 * time.Second // default if LeaseTTL not set
freshnessCutoff = 60 * time.Second
}
primaryLSN := entry.WALHeadLSN
bestIdx := -1
for i := range entry.Replicas {
ri := &entry.Replicas[i]
// Gate 1: heartbeat freshness.
if !ri.LastHeartbeat.IsZero() && now.Sub(ri.LastHeartbeat) > freshnessCutoff {
// Gate 1: heartbeat freshness. Zero means never heartbeated — unsafe
// to promote because the registry has no proof the replica is alive,
// caught up, or fully initialized.
if ri.LastHeartbeat.IsZero() {
result.Rejections = append(result.Rejections, PromotionRejection{
Server: ri.Server,
Reason: "no_heartbeat",
})
continue
}
if now.Sub(ri.LastHeartbeat) > freshnessCutoff {
result.Rejections = append(result.Rejections, PromotionRejection{
Server: ri.Server,
Reason: "stale_heartbeat",
})
continue
}
// Gate 2: WAL LSN recency (skip if primary LSN is 0 — no data yet, all eligible).
if primaryLSN > 0 && ri.WALHeadLSN+r.promotionLSNTolerance < primaryLSN {
result.Rejections = append(result.Rejections, PromotionRejection{
Server: ri.Server,
Reason: "wal_lag",
})
continue
}
// Gate 3: role must be RoleReplica (not rebuilding/stale).
if ri.Role != 0 && blockvol.RoleFromWire(ri.Role) != blockvol.RoleReplica {
// Gate 3: role must be exactly RoleReplica. Zero/unset role means
// the replica was created but never confirmed its role via heartbeat.
if blockvol.RoleFromWire(ri.Role) != blockvol.RoleReplica {
result.Rejections = append(result.Rejections, PromotionRejection{
Server: ri.Server,
Reason: "wrong_role",
})
continue
}
// Gate 4: server must be alive (in blockServers set) — B-12 fix.
if !r.blockServers[ri.Server] {
result.Rejections = append(result.Rejections, PromotionRejection{
Server: ri.Server,
Reason: "server_dead",
})
continue
}
// Eligible — pick best by health score, tie-break by WALHeadLSN.
@@ -894,11 +941,39 @@ func (r *BlockVolumeRegistry) PromoteBestReplica(name string) (uint64, error) {
}
if bestIdx == -1 {
return 0, fmt.Errorf("block volume %q: no eligible replicas for promotion", name)
result.Reason = "no eligible replicas"
if len(result.Rejections) > 0 {
result.Reason += ": " + result.Rejections[0].Reason
if len(result.Rejections) > 1 {
result.Reason += fmt.Sprintf(" (+%d more)", len(result.Rejections)-1)
}
}
return result
}
promoted := entry.Replicas[bestIdx]
result.Promotable = true
ri := entry.Replicas[bestIdx]
result.Candidate = &ri
result.CandidateIdx = bestIdx
return result
}
// EvaluatePromotion returns a read-only preflight result for the named volume
// without mutating the registry. Safe for status/logging/manual promote preview.
func (r *BlockVolumeRegistry) EvaluatePromotion(name string) (PromotionPreflightResult, error) {
r.mu.RLock()
defer r.mu.RUnlock()
entry, ok := r.volumes[name]
if !ok {
return PromotionPreflightResult{VolumeName: name, Reason: "volume not found"}, fmt.Errorf("block volume %q not found", name)
}
return r.evaluatePromotionLocked(entry), nil
}
// applyPromotionLocked applies the promotion of a replica at candidateIdx to primary.
// Caller must hold r.mu (write lock). The promoted replica is removed from Replicas[].
// Old primary is NOT added to Replicas (needs rebuild). Returns the new epoch.
func (r *BlockVolumeRegistry) applyPromotionLocked(entry *BlockVolumeEntry, name string, candidate ReplicaInfo, candidateIdx int) uint64 {
// Remove old primary from byServer index.
r.removeFromServer(entry.VolumeServer, name)
@@ -906,18 +981,21 @@ func (r *BlockVolumeRegistry) PromoteBestReplica(name string) (uint64, error) {
newEpoch := entry.Epoch + 1
// Promote replica to primary.
entry.VolumeServer = promoted.Server
entry.Path = promoted.Path
entry.IQN = promoted.IQN
entry.ISCSIAddr = promoted.ISCSIAddr
entry.NvmeAddr = promoted.NvmeAddr
entry.NQN = promoted.NQN
entry.VolumeServer = candidate.Server
entry.Path = candidate.Path
entry.IQN = candidate.IQN
entry.ISCSIAddr = candidate.ISCSIAddr
entry.NvmeAddr = candidate.NvmeAddr
entry.NQN = candidate.NQN
entry.Epoch = newEpoch
entry.Role = blockvol.RoleToWire(blockvol.RolePrimary)
entry.LastLeaseGrant = time.Now()
// Clear stale rebuild/publication metadata from old primary (B-11 partial fix).
entry.RebuildListenAddr = ""
// Remove promoted from Replicas. Others stay.
entry.Replicas = append(entry.Replicas[:bestIdx], entry.Replicas[bestIdx+1:]...)
entry.Replicas = append(entry.Replicas[:candidateIdx], entry.Replicas[candidateIdx+1:]...)
// Sync deprecated scalar fields.
if len(entry.Replicas) > 0 {
@@ -940,9 +1018,212 @@ func (r *BlockVolumeRegistry) PromoteBestReplica(name string) (uint64, error) {
// Update byServer index: new primary server now hosts this volume.
r.addToServer(entry.VolumeServer, name)
return newEpoch
}
// PromoteBestReplica promotes the best eligible replica to primary.
// Eligibility: heartbeat fresh (within 2×LeaseTTL), WALHeadLSN within tolerance of primary,
// role must be RoleReplica (not RoleRebuilding), and server must be alive (B-12 fix).
// The promoted replica is removed from Replicas[]. Other replicas stay.
// Old primary is NOT added to Replicas (needs rebuild).
// Returns the new epoch and the preflight result.
func (r *BlockVolumeRegistry) PromoteBestReplica(name string) (uint64, error) {
r.mu.Lock()
defer r.mu.Unlock()
entry, ok := r.volumes[name]
if !ok {
return 0, fmt.Errorf("block volume %q not found", name)
}
pf := r.evaluatePromotionLocked(entry)
if !pf.Promotable {
return 0, fmt.Errorf("block volume %q: %s", name, pf.Reason)
}
promoted := *pf.Candidate
bestIdx := pf.CandidateIdx
newEpoch := r.applyPromotionLocked(entry, name, promoted, bestIdx)
return newEpoch, nil
}
// evaluateManualPromotionLocked evaluates promotion candidates for a manual promote request.
// Caller must hold r.mu (read or write).
//
// Differences from evaluatePromotionLocked:
// - Primary-alive gate: if !force and current primary is alive, reject with "primary_alive".
// - Target filtering: if targetServer != "", only evaluate that specific replica.
// Returns Reason="target_not_found" if that server is not a replica.
// - Force flag: bypasses soft gates (primary_alive, stale_heartbeat, wal_lag)
// but keeps hard gates (no_heartbeat with zero time, wrong_role, server_dead).
//
// Gate table:
//
// Gate | Normal | Force
// primary_alive | Reject | Skip
// no_heartbeat(0) | Reject | Reject
// stale_heartbeat | Reject | Skip
// wal_lag | Reject | Skip
// wrong_role | Reject | Reject
// server_dead | Reject | Reject
func (r *BlockVolumeRegistry) evaluateManualPromotionLocked(entry *BlockVolumeEntry, targetServer string, force bool) PromotionPreflightResult {
result := PromotionPreflightResult{
VolumeName: entry.Name,
CandidateIdx: -1,
}
// Primary-alive gate (soft — skipped when force=true).
if !force && r.blockServers[entry.VolumeServer] {
result.Reason = "primary_alive"
return result
}
if len(entry.Replicas) == 0 {
result.Reason = "no replicas"
return result
}
// Target filtering: if a specific server is requested, find its index first.
// Return early if not found.
if targetServer != "" {
found := false
for i := range entry.Replicas {
if entry.Replicas[i].Server == targetServer {
found = true
break
}
}
if !found {
result.Reason = "target_not_found"
return result
}
}
now := time.Now()
freshnessCutoff := 2 * entry.LeaseTTL
if freshnessCutoff == 0 {
freshnessCutoff = 60 * time.Second
}
primaryLSN := entry.WALHeadLSN
bestIdx := -1
for i := range entry.Replicas {
ri := &entry.Replicas[i]
// If targeting a specific server, skip all others.
if targetServer != "" && ri.Server != targetServer {
continue
}
// Hard gate: no heartbeat (zero time) — unsafe regardless of force.
if ri.LastHeartbeat.IsZero() {
result.Rejections = append(result.Rejections, PromotionRejection{
Server: ri.Server,
Reason: "no_heartbeat",
})
continue
}
// Soft gate: stale heartbeat — skipped when force=true.
if !force && now.Sub(ri.LastHeartbeat) > freshnessCutoff {
result.Rejections = append(result.Rejections, PromotionRejection{
Server: ri.Server,
Reason: "stale_heartbeat",
})
continue
}
// Soft gate: WAL lag — skipped when force=true.
if !force && primaryLSN > 0 && ri.WALHeadLSN+r.promotionLSNTolerance < primaryLSN {
result.Rejections = append(result.Rejections, PromotionRejection{
Server: ri.Server,
Reason: "wal_lag",
})
continue
}
// Hard gate: role must be exactly RoleReplica.
if blockvol.RoleFromWire(ri.Role) != blockvol.RoleReplica {
result.Rejections = append(result.Rejections, PromotionRejection{
Server: ri.Server,
Reason: "wrong_role",
})
continue
}
// Hard gate: server must be alive (in blockServers set).
if !r.blockServers[ri.Server] {
result.Rejections = append(result.Rejections, PromotionRejection{
Server: ri.Server,
Reason: "server_dead",
})
continue
}
// Eligible — pick best by health score, tie-break by WALHeadLSN.
if bestIdx == -1 {
bestIdx = i
} else if ri.HealthScore > entry.Replicas[bestIdx].HealthScore {
bestIdx = i
} else if ri.HealthScore == entry.Replicas[bestIdx].HealthScore &&
ri.WALHeadLSN > entry.Replicas[bestIdx].WALHeadLSN {
bestIdx = i
}
}
if bestIdx == -1 {
result.Reason = "no eligible replicas"
if len(result.Rejections) > 0 {
result.Reason += ": " + result.Rejections[0].Reason
if len(result.Rejections) > 1 {
result.Reason += fmt.Sprintf(" (+%d more)", len(result.Rejections)-1)
}
}
return result
}
result.Promotable = true
ri := entry.Replicas[bestIdx]
result.Candidate = &ri
result.CandidateIdx = bestIdx
return result
}
// ManualPromote promotes a specific replica (or the best eligible replica) to primary.
// Unlike PromoteBestReplica, it accepts operator overrides:
// - targetServer: if non-empty, only that replica is considered.
// - force: bypasses soft gates (primary_alive, stale_heartbeat, wal_lag).
//
// Returns (newEpoch, oldPrimary, oldPath, preflightResult, nil) on success.
// oldPrimary and oldPath are captured under the lock to avoid TOCTOU with
// concurrent auto-failover (BUG-T5-2 fix).
// Returns (0, "", "", preflightResult, err) on rejection or lookup failure.
func (r *BlockVolumeRegistry) ManualPromote(name, targetServer string, force bool) (uint64, string, string, PromotionPreflightResult, error) {
r.mu.Lock()
defer r.mu.Unlock()
entry, ok := r.volumes[name]
if !ok {
return 0, "", "", PromotionPreflightResult{VolumeName: name, Reason: "volume not found"},
fmt.Errorf("block volume %q not found", name)
}
// Capture old primary info under lock (BUG-T5-2 fix).
oldPrimary := entry.VolumeServer
oldPath := entry.Path
pf := r.evaluateManualPromotionLocked(entry, targetServer, force)
if !pf.Promotable {
return 0, "", "", pf, fmt.Errorf("block volume %q: %s", name, pf.Reason)
}
promoted := *pf.Candidate
candidateIdx := pf.CandidateIdx
newEpoch := r.applyPromotionLocked(entry, name, promoted, candidateIdx)
return newEpoch, oldPrimary, oldPath, pf, nil
}
// MarkBlockCapable records that the given server supports block volumes.
func (r *BlockVolumeRegistry) MarkBlockCapable(server string) {
r.mu.Lock()
@@ -1045,6 +1326,41 @@ func (r *BlockVolumeRegistry) ServerSummaries() []BlockServerSummary {
return summaries
}
// IsBlockCapable returns true if the given server is in the block-capable set (alive).
func (r *BlockVolumeRegistry) IsBlockCapable(server string) bool {
r.mu.RLock()
defer r.mu.RUnlock()
return r.blockServers[server]
}
// VolumesWithDeadPrimary returns names of volumes where the given server is a replica
// and the current primary is NOT in the block-capable set (dead/disconnected).
// Used by T2 (B-06) to detect orphaned primaries that need re-promotion.
func (r *BlockVolumeRegistry) VolumesWithDeadPrimary(replicaServer string) []string {
r.mu.RLock()
defer r.mu.RUnlock()
names, ok := r.byServer[replicaServer]
if !ok {
return nil
}
var orphaned []string
for name := range names {
entry := r.volumes[name]
if entry == nil {
continue
}
// Only consider volumes where this server is a replica (not the primary).
if entry.VolumeServer == replicaServer {
continue
}
// Check if the primary server is dead.
if !r.blockServers[entry.VolumeServer] {
orphaned = append(orphaned, name)
}
}
return orphaned
}
// BlockCapableServers returns the list of servers known to support block volumes.
func (r *BlockVolumeRegistry) BlockCapableServers() []string {
r.mu.RLock()
+513 -6
View File
@@ -2,6 +2,7 @@ package weed_server
import (
"fmt"
"strings"
"sync"
"testing"
"time"
@@ -538,6 +539,8 @@ func TestRegistry_RemoveReplica(t *testing.T) {
func TestRegistry_PromoteBestReplica_PicksHighest(t *testing.T) {
r := NewBlockVolumeRegistry()
r.MarkBlockCapable("s2")
r.MarkBlockCapable("s3")
r.Register(&BlockVolumeEntry{
Name: "vol1",
VolumeServer: "s1",
@@ -545,8 +548,8 @@ func TestRegistry_PromoteBestReplica_PicksHighest(t *testing.T) {
Epoch: 5,
Role: 1,
Replicas: []ReplicaInfo{
{Server: "s2", Path: "/r1.blk", IQN: "iqn:r1", ISCSIAddr: "s2:3260", HealthScore: 0.8, WALHeadLSN: 100},
{Server: "s3", Path: "/r2.blk", IQN: "iqn:r2", ISCSIAddr: "s3:3260", HealthScore: 0.95, WALHeadLSN: 90},
{Server: "s2", Path: "/r1.blk", IQN: "iqn:r1", ISCSIAddr: "s2:3260", HealthScore: 0.8, WALHeadLSN: 100, Role: blockvol.RoleToWire(blockvol.RoleReplica), LastHeartbeat: time.Now()},
{Server: "s3", Path: "/r2.blk", IQN: "iqn:r2", ISCSIAddr: "s3:3260", HealthScore: 0.95, WALHeadLSN: 90, Role: blockvol.RoleToWire(blockvol.RoleReplica), LastHeartbeat: time.Now()},
},
})
// Add to byServer for s2 and s3.
@@ -592,14 +595,16 @@ func TestRegistry_PromoteBestReplica_NoReplica(t *testing.T) {
func TestRegistry_PromoteBestReplica_TiebreakByLSN(t *testing.T) {
r := NewBlockVolumeRegistry()
r.MarkBlockCapable("s2")
r.MarkBlockCapable("s3")
r.Register(&BlockVolumeEntry{
Name: "vol1",
VolumeServer: "s1",
Path: "/v1.blk",
Epoch: 3,
Replicas: []ReplicaInfo{
{Server: "s2", Path: "/r1.blk", IQN: "iqn:r1", ISCSIAddr: "s2:3260", HealthScore: 0.9, WALHeadLSN: 50},
{Server: "s3", Path: "/r2.blk", IQN: "iqn:r2", ISCSIAddr: "s3:3260", HealthScore: 0.9, WALHeadLSN: 100},
{Server: "s2", Path: "/r1.blk", IQN: "iqn:r1", ISCSIAddr: "s2:3260", HealthScore: 0.9, WALHeadLSN: 50, Role: blockvol.RoleToWire(blockvol.RoleReplica), LastHeartbeat: time.Now()},
{Server: "s3", Path: "/r2.blk", IQN: "iqn:r2", ISCSIAddr: "s3:3260", HealthScore: 0.9, WALHeadLSN: 100, Role: blockvol.RoleToWire(blockvol.RoleReplica), LastHeartbeat: time.Now()},
},
})
r.mu.Lock()
@@ -627,14 +632,16 @@ func TestRegistry_PromoteBestReplica_TiebreakByLSN(t *testing.T) {
func TestRegistry_PromoteBestReplica_KeepsOthers(t *testing.T) {
r := NewBlockVolumeRegistry()
r.MarkBlockCapable("s2")
r.MarkBlockCapable("s3")
r.Register(&BlockVolumeEntry{
Name: "vol1",
VolumeServer: "s1",
Path: "/v1.blk",
Epoch: 1,
Replicas: []ReplicaInfo{
{Server: "s2", Path: "/r1.blk", IQN: "iqn:r1", ISCSIAddr: "s2:3260", HealthScore: 1.0, WALHeadLSN: 100},
{Server: "s3", Path: "/r2.blk", IQN: "iqn:r2", ISCSIAddr: "s3:3260", HealthScore: 0.5, WALHeadLSN: 100},
{Server: "s2", Path: "/r1.blk", IQN: "iqn:r1", ISCSIAddr: "s2:3260", HealthScore: 1.0, WALHeadLSN: 100, Role: blockvol.RoleToWire(blockvol.RoleReplica), LastHeartbeat: time.Now()},
{Server: "s3", Path: "/r2.blk", IQN: "iqn:r2", ISCSIAddr: "s3:3260", HealthScore: 0.5, WALHeadLSN: 100, Role: blockvol.RoleToWire(blockvol.RoleReplica), LastHeartbeat: time.Now()},
},
})
r.mu.Lock()
@@ -877,6 +884,7 @@ func TestRegistry_PromoteBestReplica_WALLagIneligible(t *testing.T) {
HealthScore: 1.0,
WALHeadLSN: 800, // lag=200, tolerance=100
LastHeartbeat: time.Now(),
Role: blockvol.RoleToWire(blockvol.RoleReplica),
},
},
})
@@ -918,6 +926,8 @@ func TestRegistry_PromoteBestReplica_RebuildingIneligible(t *testing.T) {
// Fix #2: Among eligible replicas, best (health+LSN) wins.
func TestRegistry_PromoteBestReplica_EligibilityFiltersCorrectly(t *testing.T) {
r := NewBlockVolumeRegistry()
r.MarkBlockCapable("stale")
r.MarkBlockCapable("good")
r.Register(&BlockVolumeEntry{
Name: "vol1",
VolumeServer: "primary",
@@ -939,6 +949,7 @@ func TestRegistry_PromoteBestReplica_EligibilityFiltersCorrectly(t *testing.T) {
HealthScore: 0.8,
WALHeadLSN: 95,
LastHeartbeat: time.Now(),
Role: blockvol.RoleToWire(blockvol.RoleReplica),
},
},
})
@@ -956,6 +967,7 @@ func TestRegistry_PromoteBestReplica_EligibilityFiltersCorrectly(t *testing.T) {
// Configurable tolerance: widen tolerance to allow lagging replicas.
func TestRegistry_PromoteBestReplica_ConfigurableTolerance(t *testing.T) {
r := NewBlockVolumeRegistry()
r.MarkBlockCapable("lagging")
r.Register(&BlockVolumeEntry{
Name: "vol1",
VolumeServer: "primary",
@@ -970,6 +982,7 @@ func TestRegistry_PromoteBestReplica_ConfigurableTolerance(t *testing.T) {
HealthScore: 1.0,
WALHeadLSN: 800, // lag=200
LastHeartbeat: time.Now(),
Role: blockvol.RoleToWire(blockvol.RoleReplica),
},
},
})
@@ -992,6 +1005,236 @@ func TestRegistry_PromoteBestReplica_ConfigurableTolerance(t *testing.T) {
}
}
// B-12: PromoteBestReplica rejects dead replica (server not in blockServers).
func TestRegistry_PromoteBestReplica_DeadServerIneligible(t *testing.T) {
r := NewBlockVolumeRegistry()
// Intentionally do NOT mark "dead-replica" as block-capable.
r.Register(&BlockVolumeEntry{
Name: "vol1",
VolumeServer: "primary",
Path: "/data/vol1.blk",
Epoch: 1,
LeaseTTL: 30 * time.Second,
WALHeadLSN: 100,
Replicas: []ReplicaInfo{
{
Server: "dead-replica",
Path: "/data/vol1.blk",
HealthScore: 1.0,
WALHeadLSN: 100,
LastHeartbeat: time.Now(),
Role: blockvol.RoleToWire(blockvol.RoleReplica),
},
},
})
_, err := r.PromoteBestReplica("vol1")
if err == nil {
t.Fatal("expected error: dead replica should be rejected")
}
if !strings.Contains(err.Error(), "server_dead") {
t.Fatalf("error should mention server_dead, got: %v", err)
}
}
// B-12: Dead replica rejected but alive replica promoted when both exist.
func TestRegistry_PromoteBestReplica_DeadSkipped_AlivePromoted(t *testing.T) {
r := NewBlockVolumeRegistry()
// Only mark s3 as alive.
r.MarkBlockCapable("s3")
r.Register(&BlockVolumeEntry{
Name: "vol1",
VolumeServer: "primary",
Path: "/data/vol1.blk",
Epoch: 1,
LeaseTTL: 30 * time.Second,
WALHeadLSN: 100,
Replicas: []ReplicaInfo{
{Server: "s2-dead", Path: "/r1.blk", HealthScore: 1.0, WALHeadLSN: 100, LastHeartbeat: time.Now(), Role: blockvol.RoleToWire(blockvol.RoleReplica)},
{Server: "s3", Path: "/r2.blk", HealthScore: 0.8, WALHeadLSN: 95, LastHeartbeat: time.Now(), Role: blockvol.RoleToWire(blockvol.RoleReplica)},
},
})
newEpoch, err := r.PromoteBestReplica("vol1")
if err != nil {
t.Fatalf("PromoteBestReplica: %v", err)
}
if newEpoch != 2 {
t.Fatalf("newEpoch: got %d, want 2", newEpoch)
}
e, _ := r.Lookup("vol1")
if e.VolumeServer != "s3" {
t.Fatalf("expected alive s3 promoted, got %q", e.VolumeServer)
}
}
// EvaluatePromotion returns read-only preflight without mutating registry.
func TestRegistry_EvaluatePromotion_Basic(t *testing.T) {
r := NewBlockVolumeRegistry()
r.MarkBlockCapable("replica1")
r.Register(&BlockVolumeEntry{
Name: "vol1",
VolumeServer: "primary",
Path: "/data/vol1.blk",
Epoch: 5,
LeaseTTL: 30 * time.Second,
WALHeadLSN: 100,
Replicas: []ReplicaInfo{
{Server: "replica1", Path: "/r1.blk", HealthScore: 0.9, WALHeadLSN: 100, LastHeartbeat: time.Now(), Role: blockvol.RoleToWire(blockvol.RoleReplica)},
},
})
pf, err := r.EvaluatePromotion("vol1")
if err != nil {
t.Fatalf("EvaluatePromotion: %v", err)
}
if !pf.Promotable {
t.Fatalf("expected promotable, got reason: %s", pf.Reason)
}
if pf.Candidate == nil || pf.Candidate.Server != "replica1" {
t.Fatalf("expected candidate replica1, got %+v", pf.Candidate)
}
// Registry must be unmutated.
e, _ := r.Lookup("vol1")
if e.VolumeServer != "primary" {
t.Fatal("EvaluatePromotion should not mutate the registry")
}
if e.Epoch != 5 {
t.Fatal("EvaluatePromotion should not bump epoch")
}
}
// EvaluatePromotion with all replicas rejected.
func TestRegistry_EvaluatePromotion_AllRejected(t *testing.T) {
r := NewBlockVolumeRegistry()
// No servers marked as block-capable.
r.Register(&BlockVolumeEntry{
Name: "vol1",
VolumeServer: "primary",
Path: "/data/vol1.blk",
Epoch: 1,
Replicas: []ReplicaInfo{
{Server: "dead1", Path: "/r1.blk", HealthScore: 1.0, WALHeadLSN: 100, LastHeartbeat: time.Now(), Role: blockvol.RoleToWire(blockvol.RoleReplica)},
{Server: "dead2", Path: "/r2.blk", HealthScore: 0.9, WALHeadLSN: 100, LastHeartbeat: time.Now(), Role: blockvol.RoleToWire(blockvol.RoleReplica)},
},
})
pf, err := r.EvaluatePromotion("vol1")
if err != nil {
t.Fatalf("EvaluatePromotion: %v", err)
}
if pf.Promotable {
t.Fatal("expected not promotable")
}
if len(pf.Rejections) != 2 {
t.Fatalf("expected 2 rejections, got %d", len(pf.Rejections))
}
for _, rej := range pf.Rejections {
if rej.Reason != "server_dead" {
t.Fatalf("expected server_dead rejection, got %q", rej.Reason)
}
}
}
// EvaluatePromotion for nonexistent volume.
func TestRegistry_EvaluatePromotion_NotFound(t *testing.T) {
r := NewBlockVolumeRegistry()
_, err := r.EvaluatePromotion("nonexistent")
if err == nil {
t.Fatal("expected error for nonexistent volume")
}
}
// Replica created but never heartbeated is not promotable.
func TestRegistry_PromoteBestReplica_NoHeartbeatIneligible(t *testing.T) {
r := NewBlockVolumeRegistry()
r.MarkBlockCapable("replica1")
r.Register(&BlockVolumeEntry{
Name: "vol1",
VolumeServer: "primary",
Path: "/data/vol1.blk",
Epoch: 1,
LeaseTTL: 30 * time.Second,
WALHeadLSN: 100,
Replicas: []ReplicaInfo{
{
Server: "replica1",
Path: "/r1.blk",
HealthScore: 1.0,
WALHeadLSN: 100,
Role: blockvol.RoleToWire(blockvol.RoleReplica),
// LastHeartbeat: zero — never heartbeated
},
},
})
_, err := r.PromoteBestReplica("vol1")
if err == nil {
t.Fatal("expected error: replica with no heartbeat should be rejected")
}
if !strings.Contains(err.Error(), "no_heartbeat") {
t.Fatalf("error should mention no_heartbeat, got: %v", err)
}
}
// Replica with unset (zero) role is not promotable.
func TestRegistry_PromoteBestReplica_UnsetRoleIneligible(t *testing.T) {
r := NewBlockVolumeRegistry()
r.MarkBlockCapable("replica1")
r.Register(&BlockVolumeEntry{
Name: "vol1",
VolumeServer: "primary",
Path: "/data/vol1.blk",
Epoch: 1,
LeaseTTL: 30 * time.Second,
WALHeadLSN: 100,
Replicas: []ReplicaInfo{
{
Server: "replica1",
Path: "/r1.blk",
HealthScore: 1.0,
WALHeadLSN: 100,
LastHeartbeat: time.Now(),
// Role: 0 — unset/RoleNone
},
},
})
_, err := r.PromoteBestReplica("vol1")
if err == nil {
t.Fatal("expected error: replica with unset role should be rejected")
}
if !strings.Contains(err.Error(), "wrong_role") {
t.Fatalf("error should mention wrong_role, got: %v", err)
}
}
// PromoteBestReplica clears RebuildListenAddr on promotion (B-11 partial fix).
func TestRegistry_PromoteBestReplica_ClearsRebuildAddr(t *testing.T) {
r := NewBlockVolumeRegistry()
r.MarkBlockCapable("replica1")
r.Register(&BlockVolumeEntry{
Name: "vol1",
VolumeServer: "primary",
Path: "/data/vol1.blk",
Epoch: 1,
RebuildListenAddr: "primary:15000",
Replicas: []ReplicaInfo{
{Server: "replica1", Path: "/r1.blk", HealthScore: 1.0, WALHeadLSN: 100, LastHeartbeat: time.Now(), Role: blockvol.RoleToWire(blockvol.RoleReplica)},
},
})
_, err := r.PromoteBestReplica("vol1")
if err != nil {
t.Fatalf("PromoteBestReplica: %v", err)
}
e, _ := r.Lookup("vol1")
if e.RebuildListenAddr != "" {
t.Fatalf("RebuildListenAddr should be cleared after promotion, got %q", e.RebuildListenAddr)
}
}
// --- LeaseGrants ---
func TestRegistry_LeaseGrants_PrimaryOnly(t *testing.T) {
@@ -1110,3 +1353,267 @@ func TestRegistry_LeaseGrants_UnknownServer(t *testing.T) {
t.Fatalf("expected nil for unknown server, got %+v", grants)
}
}
// ============================================================
// CP11B-3 T2: IsBlockCapable + VolumesWithDeadPrimary
// ============================================================
func TestRegistry_IsBlockCapable(t *testing.T) {
r := NewBlockVolumeRegistry()
r.MarkBlockCapable("vs1:8080")
if !r.IsBlockCapable("vs1:8080") {
t.Fatal("vs1 should be block-capable")
}
if r.IsBlockCapable("vs2:8080") {
t.Fatal("vs2 should NOT be block-capable")
}
r.UnmarkBlockCapable("vs1:8080")
if r.IsBlockCapable("vs1:8080") {
t.Fatal("vs1 should no longer be block-capable after unmark")
}
}
func TestRegistry_VolumesWithDeadPrimary_Basic(t *testing.T) {
r := NewBlockVolumeRegistry()
r.MarkBlockCapable("vs1")
r.MarkBlockCapable("vs2")
r.Register(&BlockVolumeEntry{
Name: "vol1", VolumeServer: "vs1", Path: "/data/vol1.blk",
SizeBytes: 1 << 30, Epoch: 1, Role: blockvol.RoleToWire(blockvol.RolePrimary),
Status: StatusActive,
Replicas: []ReplicaInfo{{Server: "vs2", Path: "/data/vol1.blk"}},
})
// Both alive → no orphans.
orphaned := r.VolumesWithDeadPrimary("vs2")
if len(orphaned) != 0 {
t.Fatalf("expected 0 orphaned volumes, got %d", len(orphaned))
}
// Kill primary.
r.UnmarkBlockCapable("vs1")
orphaned = r.VolumesWithDeadPrimary("vs2")
if len(orphaned) != 1 || orphaned[0] != "vol1" {
t.Fatalf("expected [vol1], got %v", orphaned)
}
}
func TestRegistry_VolumesWithDeadPrimary_PrimaryServer_NotIncluded(t *testing.T) {
r := NewBlockVolumeRegistry()
r.MarkBlockCapable("vs1")
r.Register(&BlockVolumeEntry{
Name: "vol1", VolumeServer: "vs1", Path: "/data/vol1.blk",
SizeBytes: 1 << 30, Epoch: 1, Role: blockvol.RoleToWire(blockvol.RolePrimary),
Status: StatusActive,
})
// vs1 is the primary for vol1 — should NOT appear in orphaned list for vs1.
orphaned := r.VolumesWithDeadPrimary("vs1")
if len(orphaned) != 0 {
t.Fatalf("primary server should not appear in its own orphan list, got %v", orphaned)
}
}
// T6: EvaluatePromotion preflight includes primary liveness.
func TestRegistry_EvaluatePromotion_PrimaryDead_StillShowsCandidate(t *testing.T) {
r := NewBlockVolumeRegistry()
r.MarkBlockCapable("vs1")
r.MarkBlockCapable("vs2")
r.Register(&BlockVolumeEntry{
Name: "vol1", VolumeServer: "vs1", Path: "/data/vol1.blk",
SizeBytes: 1 << 30, Epoch: 1, Role: blockvol.RoleToWire(blockvol.RolePrimary),
Status: StatusActive, LeaseTTL: 30 * time.Second,
Replicas: []ReplicaInfo{{
Server: "vs2", Path: "/data/vol1.blk", HealthScore: 1.0,
Role: blockvol.RoleToWire(blockvol.RoleReplica), LastHeartbeat: time.Now(),
}},
})
// Kill primary but keep vs2 alive.
r.UnmarkBlockCapable("vs1")
pf, err := r.EvaluatePromotion("vol1")
if err != nil {
t.Fatalf("EvaluatePromotion: %v", err)
}
if !pf.Promotable {
t.Fatalf("should be promotable (vs2 alive), reason=%s", pf.Reason)
}
if pf.Candidate.Server != "vs2" {
t.Fatalf("candidate should be vs2, got %q", pf.Candidate.Server)
}
}
// ============================================================
// CP11B-3 T5: ManualPromote Dev Tests
// ============================================================
// T5: ManualPromote with empty target → auto-picks best candidate.
func TestRegistry_ManualPromote_AutoTarget(t *testing.T) {
r := NewBlockVolumeRegistry()
r.MarkBlockCapable("best")
r.MarkBlockCapable("worse")
r.Register(&BlockVolumeEntry{
Name: "vol1", VolumeServer: "primary", Path: "/data/vol1.blk",
Epoch: 1, LeaseTTL: 30 * time.Second, WALHeadLSN: 100,
Replicas: []ReplicaInfo{
{Server: "worse", Path: "/r1.blk", HealthScore: 0.5, WALHeadLSN: 100,
LastHeartbeat: time.Now(), Role: blockvol.RoleToWire(blockvol.RoleReplica)},
{Server: "best", Path: "/r2.blk", HealthScore: 1.0, WALHeadLSN: 100,
LastHeartbeat: time.Now(), Role: blockvol.RoleToWire(blockvol.RoleReplica)},
},
})
// Primary not block-capable → non-force should still pass (primary_alive gate won't trigger).
newEpoch, _, _, pf, err := r.ManualPromote("vol1", "", false)
if err != nil {
t.Fatalf("ManualPromote: %v", err)
}
if newEpoch != 2 {
t.Fatalf("epoch: got %d, want 2", newEpoch)
}
if !pf.Promotable {
t.Fatal("should be promotable")
}
e, _ := r.Lookup("vol1")
if e.VolumeServer != "best" {
t.Fatalf("expected 'best' promoted, got %q", e.VolumeServer)
}
}
// T5: ManualPromote targets a specific replica (not the best by health).
func TestRegistry_ManualPromote_SpecificTarget(t *testing.T) {
r := NewBlockVolumeRegistry()
r.MarkBlockCapable("r1")
r.MarkBlockCapable("r2")
r.Register(&BlockVolumeEntry{
Name: "vol1", VolumeServer: "primary", Path: "/data/vol1.blk",
Epoch: 1, LeaseTTL: 30 * time.Second,
Replicas: []ReplicaInfo{
{Server: "r1", Path: "/r1.blk", HealthScore: 1.0, WALHeadLSN: 100,
LastHeartbeat: time.Now(), Role: blockvol.RoleToWire(blockvol.RoleReplica)},
{Server: "r2", Path: "/r2.blk", HealthScore: 0.5, WALHeadLSN: 50,
LastHeartbeat: time.Now(), Role: blockvol.RoleToWire(blockvol.RoleReplica)},
},
})
// Target r2 specifically (worse health).
newEpoch, _, _, _, err := r.ManualPromote("vol1", "r2", false)
if err != nil {
t.Fatalf("ManualPromote: %v", err)
}
if newEpoch != 2 {
t.Fatalf("epoch: got %d, want 2", newEpoch)
}
e, _ := r.Lookup("vol1")
if e.VolumeServer != "r2" {
t.Fatalf("expected r2 promoted (specific target), got %q", e.VolumeServer)
}
}
// T5: ManualPromote with non-existent target → error.
func TestRegistry_ManualPromote_TargetNotFound(t *testing.T) {
r := NewBlockVolumeRegistry()
r.MarkBlockCapable("r1")
r.Register(&BlockVolumeEntry{
Name: "vol1", VolumeServer: "primary", Path: "/data/vol1.blk",
Epoch: 1, LeaseTTL: 30 * time.Second,
Replicas: []ReplicaInfo{
{Server: "r1", Path: "/r1.blk", HealthScore: 1.0,
LastHeartbeat: time.Now(), Role: blockvol.RoleToWire(blockvol.RoleReplica)},
},
})
_, _, _, pf, err := r.ManualPromote("vol1", "nonexistent", false)
if err == nil {
t.Fatal("expected error for nonexistent target")
}
if pf.Reason != "target_not_found" {
t.Fatalf("expected target_not_found, got %q", pf.Reason)
}
}
// T5: ManualPromote non-force with alive primary → rejected.
func TestRegistry_ManualPromote_PrimaryAlive_Rejected(t *testing.T) {
r := NewBlockVolumeRegistry()
r.MarkBlockCapable("primary")
r.MarkBlockCapable("r1")
r.Register(&BlockVolumeEntry{
Name: "vol1", VolumeServer: "primary", Path: "/data/vol1.blk",
Epoch: 1, LeaseTTL: 30 * time.Second,
Replicas: []ReplicaInfo{
{Server: "r1", Path: "/r1.blk", HealthScore: 1.0,
LastHeartbeat: time.Now(), Role: blockvol.RoleToWire(blockvol.RoleReplica)},
},
})
_, _, _, pf, err := r.ManualPromote("vol1", "", false)
if err == nil {
t.Fatal("expected rejection when primary alive and !force")
}
if pf.Reason != "primary_alive" {
t.Fatalf("expected primary_alive, got %q", pf.Reason)
}
// Verify no mutation.
e, _ := r.Lookup("vol1")
if e.VolumeServer != "primary" {
t.Fatalf("primary should not change, got %q", e.VolumeServer)
}
}
// T5: Force bypasses stale heartbeat and primary_alive gates.
func TestRegistry_ManualPromote_Force_StaleHeartbeat(t *testing.T) {
r := NewBlockVolumeRegistry()
r.MarkBlockCapable("primary")
r.MarkBlockCapable("r1")
r.Register(&BlockVolumeEntry{
Name: "vol1", VolumeServer: "primary", Path: "/data/vol1.blk",
Epoch: 1, LeaseTTL: 30 * time.Second,
Replicas: []ReplicaInfo{
{Server: "r1", Path: "/r1.blk", HealthScore: 1.0,
LastHeartbeat: time.Now().Add(-10 * time.Minute), // stale
Role: blockvol.RoleToWire(blockvol.RoleReplica)},
},
})
// Non-force: would fail on primary_alive.
// Force: bypasses primary_alive AND stale_heartbeat.
newEpoch, _, _, _, err := r.ManualPromote("vol1", "", true)
if err != nil {
t.Fatalf("force ManualPromote should succeed: %v", err)
}
if newEpoch != 2 {
t.Fatalf("epoch: got %d, want 2", newEpoch)
}
e, _ := r.Lookup("vol1")
if e.VolumeServer != "r1" {
t.Fatalf("expected r1 promoted via force, got %q", e.VolumeServer)
}
}
// T5: Force does NOT bypass server_dead (hard gate).
func TestRegistry_ManualPromote_Force_StillRejectsDeadServer(t *testing.T) {
r := NewBlockVolumeRegistry()
// "dead" is NOT marked block-capable.
r.Register(&BlockVolumeEntry{
Name: "vol1", VolumeServer: "primary", Path: "/data/vol1.blk",
Epoch: 1, LeaseTTL: 30 * time.Second,
Replicas: []ReplicaInfo{
{Server: "dead", Path: "/r1.blk", HealthScore: 1.0,
LastHeartbeat: time.Now(), Role: blockvol.RoleToWire(blockvol.RoleReplica)},
},
})
_, _, _, pf, err := r.ManualPromote("vol1", "dead", true)
if err == nil {
t.Fatal("force should NOT bypass server_dead")
}
if len(pf.Rejections) == 0 || pf.Rejections[0].Reason != "server_dead" {
t.Fatalf("expected server_dead rejection, got %+v", pf.Rejections)
}
}
+3
View File
@@ -278,6 +278,9 @@ func (ms *MasterServer) SendHeartbeat(stream master_pb.Seaweed_SendHeartbeatServ
// on subsequent heartbeats), never both in the same message.
if len(heartbeat.BlockVolumeInfos) > 0 || heartbeat.HasNoBlockVolumes {
ms.blockRegistry.UpdateFullHeartbeat(dn.Url(), heartbeat.BlockVolumeInfos)
// T2 (B-06): After updating registry from heartbeat, check if this server
// is a replica for any volume whose primary is dead. If so, promote.
ms.reevaluateOrphanedPrimaries(dn.Url())
} else if len(heartbeat.NewBlockVolumes) > 0 || len(heartbeat.DeletedBlockVolumes) > 0 {
ms.blockRegistry.UpdateDeltaHeartbeat(dn.Url(), heartbeat.NewBlockVolumes, heartbeat.DeletedBlockVolumes)
}
+15 -8
View File
@@ -283,14 +283,16 @@ func (ms *MasterServer) tryCreateOneReplica(ctx context.Context, req *master_pb.
entry.RebuildListenAddr = primaryResult.RebuildListenAddr
// CP8-2: populate Replicas[].
entry.Replicas = append(entry.Replicas, ReplicaInfo{
Server: replicaServerStr,
Path: replicaResult.Path,
ISCSIAddr: replicaResult.ISCSIAddr,
IQN: replicaResult.IQN,
NvmeAddr: replicaResult.NvmeAddr,
NQN: replicaResult.NQN,
DataAddr: replicaResult.ReplicaDataAddr,
CtrlAddr: replicaResult.ReplicaCtrlAddr,
Server: replicaServerStr,
Path: replicaResult.Path,
ISCSIAddr: replicaResult.ISCSIAddr,
IQN: replicaResult.IQN,
NvmeAddr: replicaResult.NvmeAddr,
NQN: replicaResult.NQN,
DataAddr: replicaResult.ReplicaDataAddr,
CtrlAddr: replicaResult.ReplicaCtrlAddr,
Role: blockvol.RoleToWire(blockvol.RoleReplica),
LastHeartbeat: time.Now(),
})
return replicaServerStr
}
@@ -409,6 +411,11 @@ func (ms *MasterServer) ExpandBlockVolume(ctx context.Context, req *master_pb.Ex
}
}()
// Test-only hook: inject failover between lock acquisition and re-read.
if ms.expandPreReadHook != nil {
ms.expandPreReadHook()
}
// B-09: Re-read entry after acquiring expand lock. Between the initial
// Lookup and AcquireExpandInflight, failover may have changed VolumeServer
// or Replicas. Using the stale snapshot would send PREPARE to dead nodes.
+43 -28
View File
@@ -10,6 +10,7 @@ import (
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
"github.com/seaweedfs/seaweedfs/weed/storage/blockvol"
)
// testMasterServer creates a minimal MasterServer with mock VS calls for testing.
@@ -1112,6 +1113,9 @@ func TestMaster_NoNvmeFieldsWhenDisabled(t *testing.T) {
func TestMaster_PromotionCopiesNvmeFields(t *testing.T) {
ms := testMasterServer(t)
// Mark servers as block-capable so promotion Gate 4 (liveness) passes.
ms.blockRegistry.MarkBlockCapable("vs1:9333")
ms.blockRegistry.MarkBlockCapable("vs2:9333")
// Directly register an entry with primary + replica, both having NVMe fields.
ms.blockRegistry.Register(&BlockVolumeEntry{
@@ -1128,16 +1132,18 @@ func TestMaster_PromotionCopiesNvmeFields(t *testing.T) {
LeaseTTL: 30 * time.Second,
Replicas: []ReplicaInfo{
{
Server: "vs2:9333",
Path: "/data/ha-vol.blk",
IQN: "iqn.2024.test:ha-vol-r",
ISCSIAddr: "vs2:3260",
NvmeAddr: "vs2:4420",
NQN: "nqn.2024-01.com.seaweedfs:vol.ha-vol.vs2",
DataAddr: "vs2:14260",
CtrlAddr: "vs2:14261",
HealthScore: 0.95,
WALHeadLSN: 100,
Server: "vs2:9333",
Path: "/data/ha-vol.blk",
IQN: "iqn.2024.test:ha-vol-r",
ISCSIAddr: "vs2:3260",
NvmeAddr: "vs2:4420",
NQN: "nqn.2024-01.com.seaweedfs:vol.ha-vol.vs2",
DataAddr: "vs2:14260",
CtrlAddr: "vs2:14261",
HealthScore: 0.95,
WALHeadLSN: 100,
Role: blockvol.RoleToWire(blockvol.RoleReplica),
LastHeartbeat: time.Now(),
},
},
})
@@ -1654,10 +1660,11 @@ func TestMaster_ExpandCoordinated_RestartRecovery(t *testing.T) {
}
func TestMaster_ExpandCoordinated_B09_ReReadsEntryAfterLock(t *testing.T) {
// B-09: If failover changes VolumeServer between initial Lookup and
// AcquireExpandInflight, the coordinator must use the fresh entry,
// not the stale one. Use RF=3 so promotion still leaves 1 replica
// and the coordinated path is taken.
// B-09: Exercises the actual race window — failover happens BETWEEN
// the initial Lookup (line 380) and the post-lock re-read (line 419).
// Uses expandPreReadHook to inject PromoteBestReplica at the exact
// interleaving point. RF=3 so promotion leaves 1 replica and the
// coordinated path is taken.
ms := testMasterServerWithExpandMocks(t)
ms.blockRegistry.MarkBlockCapable("vs1:9333")
ms.blockRegistry.MarkBlockCapable("vs2:9333")
@@ -1689,31 +1696,39 @@ func TestMaster_ExpandCoordinated_B09_ReReadsEntryAfterLock(t *testing.T) {
return 2 << 30, nil
}
// Simulate failover: promote best replica. With RF=3, one replica
// becomes primary and the other stays as replica → coordinated path.
ms.blockRegistry.PromoteBestReplica("b09-vol")
entry, _ = ms.blockRegistry.Lookup("b09-vol")
newPrimary := entry.VolumeServer
if newPrimary == originalPrimary {
t.Fatal("promotion didn't change primary")
}
if len(entry.Replicas) == 0 {
t.Fatal("expected at least 1 replica after RF=3 promotion")
// Hook fires AFTER AcquireExpandInflight but BEFORE the re-read Lookup.
// This is the exact race window: the initial Lookup already returned
// the old primary, but failover changes it before the re-read.
hookFired := false
ms.expandPreReadHook = func() {
hookFired = true
ms.blockRegistry.PromoteBestReplica("b09-vol")
}
// Expand should use the NEW primary (post-failover), not the old one.
// At this point, the initial Lookup inside ExpandBlockVolume will see
// originalPrimary. The hook then promotes, changing the primary.
// The re-read must pick up the new primary.
resp, err := ms.ExpandBlockVolume(context.Background(), &master_pb.ExpandBlockVolumeRequest{
Name: "b09-vol", NewSizeBytes: 2 << 30,
})
if err != nil {
t.Fatalf("expand: %v", err)
}
if !hookFired {
t.Fatal("expandPreReadHook was not called — race window not exercised")
}
if resp.CapacityBytes != 2<<30 {
t.Fatalf("capacity: got %d", resp.CapacityBytes)
}
// First PREPARE should have gone to the new primary, not the old one.
// Verify: after the hook promoted, the re-read must have picked up
// the new primary. The first PREPARE should go to the new primary.
entry, _ = ms.blockRegistry.Lookup("b09-vol")
newPrimary := entry.VolumeServer
if newPrimary == originalPrimary {
t.Fatal("promotion didn't change primary")
}
if len(preparedServers) == 0 {
t.Fatal("no prepare calls recorded")
}
@@ -1721,7 +1736,7 @@ func TestMaster_ExpandCoordinated_B09_ReReadsEntryAfterLock(t *testing.T) {
t.Fatalf("PREPARE went to %q (stale), should go to %q (fresh primary)",
preparedServers[0], newPrimary)
}
// Verify old primary was NOT contacted.
// Verify old primary was NOT contacted at all.
for _, s := range preparedServers {
if s == originalPrimary {
t.Fatalf("PREPARE sent to old primary %q — stale entry used", originalPrimary)
+6
View File
@@ -109,6 +109,10 @@ type MasterServer struct {
blockVSCommitExpand func(ctx context.Context, server string, name string, expandEpoch uint64) (uint64, error)
blockVSCancelExpand func(ctx context.Context, server string, name string, expandEpoch uint64) error
nextExpandEpoch atomic.Uint64
// Test-only hook: called after AcquireExpandInflight but before the
// re-read Lookup in coordinated expand. Nil in production.
expandPreReadHook func()
}
func NewMasterServer(r *mux.Router, option *MasterOption, peers map[string]pb.ServerAddress) *MasterServer {
@@ -224,6 +228,8 @@ func NewMasterServer(r *mux.Router, option *MasterOption, peers map[string]pb.Se
r.HandleFunc("/block/volume/{name}", ms.guard.WhiteList(requestIDMiddleware(ms.blockVolumeLookupHandler))).Methods("GET")
r.HandleFunc("/block/volumes", ms.guard.WhiteList(requestIDMiddleware(ms.blockVolumeListHandler))).Methods("GET")
r.HandleFunc("/block/volume/{name}/expand", ms.proxyToLeader(ms.guard.WhiteList(requestIDMiddleware(ms.blockVolumeExpandHandler)))).Methods("POST")
r.HandleFunc("/block/volume/{name}/preflight", ms.guard.WhiteList(requestIDMiddleware(ms.blockVolumePreflightHandler))).Methods("GET")
r.HandleFunc("/block/volume/{name}/promote", ms.proxyToLeader(ms.guard.WhiteList(requestIDMiddleware(ms.blockVolumePromoteHandler)))).Methods("POST")
r.HandleFunc("/block/assign", ms.proxyToLeader(ms.guard.WhiteList(requestIDMiddleware(ms.blockAssignHandler)))).Methods("POST")
r.HandleFunc("/block/servers", ms.guard.WhiteList(requestIDMiddleware(ms.blockServersHandler))).Methods("GET")
r.HandleFunc("/block/status", ms.guard.WhiteList(requestIDMiddleware(ms.blockStatusHandler))).Methods("GET")
@@ -7,6 +7,7 @@ import (
"github.com/gorilla/mux"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
"github.com/seaweedfs/seaweedfs/weed/storage/blockvol"
"github.com/seaweedfs/seaweedfs/weed/storage/blockvol/blockapi"
@@ -206,6 +207,99 @@ func (ms *MasterServer) blockStatusHandler(w http.ResponseWriter, r *http.Reques
writeJsonQuiet(w, r, http.StatusOK, status)
}
// blockVolumePreflightHandler handles GET /block/volume/{name}/preflight.
// Returns a read-only promotion preflight evaluation for the named volume.
func (ms *MasterServer) blockVolumePreflightHandler(w http.ResponseWriter, r *http.Request) {
name := mux.Vars(r)["name"]
if name == "" {
writeJsonError(w, r, http.StatusBadRequest, fmt.Errorf("name is required"))
return
}
pf, err := ms.blockRegistry.EvaluatePromotion(name)
if err != nil {
writeJsonError(w, r, http.StatusNotFound, err)
return
}
resp := blockapi.PreflightResponse{
VolumeName: pf.VolumeName,
Promotable: pf.Promotable,
Reason: pf.Reason,
}
if pf.Candidate != nil {
resp.CandidateServer = pf.Candidate.Server
resp.CandidateHealth = pf.Candidate.HealthScore
resp.CandidateWALLSN = pf.Candidate.WALHeadLSN
}
for _, rej := range pf.Rejections {
resp.Rejections = append(resp.Rejections, blockapi.PreflightRejection{
Server: rej.Server,
Reason: rej.Reason,
})
}
// Add primary liveness info.
entry, ok := ms.blockRegistry.Lookup(name)
if ok {
resp.PrimaryServer = entry.VolumeServer
resp.PrimaryAlive = ms.blockRegistry.IsBlockCapable(entry.VolumeServer)
}
writeJsonQuiet(w, r, http.StatusOK, resp)
}
// blockVolumePromoteHandler handles POST /block/volume/{name}/promote.
// Triggers a manual promotion for the named block volume.
func (ms *MasterServer) blockVolumePromoteHandler(w http.ResponseWriter, r *http.Request) {
name := mux.Vars(r)["name"]
if name == "" {
writeJsonError(w, r, http.StatusBadRequest, fmt.Errorf("name is required"))
return
}
var req blockapi.PromoteVolumeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJsonError(w, r, http.StatusBadRequest, fmt.Errorf("decode request: %w", err))
return
}
// ManualPromote captures oldPrimary/oldPath under lock to avoid TOCTOU (BUG-T5-2).
newEpoch, oldPrimary, oldPath, pf, err := ms.blockRegistry.ManualPromote(name, req.TargetServer, req.Force)
if err != nil {
// Distinguish not-found from rejection.
status := http.StatusConflict
if pf.Reason == "volume not found" {
status = http.StatusNotFound
}
// Build structured rejection response.
resp := blockapi.PromoteVolumeResponse{
Reason: pf.Reason,
}
for _, rej := range pf.Rejections {
resp.Rejections = append(resp.Rejections, blockapi.PreflightRejection{
Server: rej.Server,
Reason: rej.Reason,
})
}
glog.V(0).Infof("manual promote %q rejected: %s", name, pf.Reason)
writeJsonQuiet(w, r, status, resp)
return
}
// Post-promotion orchestration (same as auto path).
ms.finalizePromotion(name, oldPrimary, oldPath, newEpoch)
if req.Reason != "" {
glog.V(0).Infof("manual promote %q: reason=%q", name, req.Reason)
}
// Re-read to get the new primary server name.
entry, _ := ms.blockRegistry.Lookup(name)
writeJsonQuiet(w, r, http.StatusOK, blockapi.PromoteVolumeResponse{
NewPrimary: entry.VolumeServer,
Epoch: newEpoch,
})
}
// entryToVolumeInfo converts a BlockVolumeEntry to a blockapi.VolumeInfo.
func entryToVolumeInfo(e *BlockVolumeEntry) blockapi.VolumeInfo {
status := "pending"
@@ -239,6 +333,8 @@ func entryToVolumeInfo(e *BlockVolumeEntry) blockapi.VolumeInfo {
HealthScore: e.HealthScore,
ReplicaDegraded: e.ReplicaDegraded,
DurabilityMode: durMode,
NvmeAddr: e.NvmeAddr,
NQN: e.NQN,
}
for _, ri := range e.Replicas {
info.Replicas = append(info.Replicas, blockapi.ReplicaDetail{
File diff suppressed because it is too large. Load diff
+20 -5
View File
@@ -40,6 +40,11 @@ func testMSForQA(t *testing.T) *MasterServer {
// registerQAVolume creates a volume entry with optional replica, configurable lease state.
func registerQAVolume(t *testing.T, ms *MasterServer, name, primary, replica string, epoch uint64, leaseTTL time.Duration, leaseExpired bool) {
t.Helper()
// Mark servers as block-capable so promotion Gate 4 (liveness) passes.
ms.blockRegistry.MarkBlockCapable(primary)
if replica != "" {
ms.blockRegistry.MarkBlockCapable(replica)
}
entry := &BlockVolumeEntry{
Name: name,
VolumeServer: primary,
@@ -65,11 +70,13 @@ func registerQAVolume(t *testing.T, ms *MasterServer, name, primary, replica str
// CP8-2: also populate Replicas[].
entry.Replicas = []ReplicaInfo{
{
Server: replica,
Path: fmt.Sprintf("/data/%s.blk", name),
IQN: fmt.Sprintf("iqn.2024.test:%s-r", name),
ISCSIAddr: replica + ":3260",
HealthScore: 1.0,
Server: replica,
Path: fmt.Sprintf("/data/%s.blk", name),
IQN: fmt.Sprintf("iqn.2024.test:%s-r", name),
ISCSIAddr: replica + ":3260",
HealthScore: 1.0,
Role: blockvol.RoleToWire(blockvol.RoleReplica),
LastHeartbeat: time.Now(),
},
}
}
@@ -398,7 +405,15 @@ func TestQA_Failover_PromoteIdempotent_NoReplicaAfterFirstSwap(t *testing.T) {
// Reconnect vs1 first so it becomes a replica.
ms.recoverBlockVolumes("vs1")
// Simulate rebuild completion: mark vs1 as a healthy replica.
e, _ := ms.blockRegistry.Lookup("vol1")
for i := range e.Replicas {
if e.Replicas[i].Server == "vs1" {
e.Replicas[i].Role = blockvol.RoleToWire(blockvol.RoleReplica)
e.Replicas[i].LastHeartbeat = time.Now()
e.Replicas[i].HealthScore = 1.0
}
}
e.LastLeaseGrant = time.Now().Add(-1 * time.Minute) // expire the new lease
ms.failoverBlockVolumes("vs2")
@@ -0,0 +1,485 @@
package weed_server
import (
"context"
"fmt"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
"github.com/seaweedfs/seaweedfs/weed/storage/blockvol"
)
// ============================================================
// CP11A-2 Adversarial Test Suite: B-09 + B-10
//
// 8 scenarios stress-testing the coordinated expand path under
// failover, concurrent heartbeats, and partial failures.
// ============================================================
// qaExpandMaster creates a MasterServer with 3 block-capable servers
// and default expand mocks for adversarial testing.
func qaExpandMaster(t *testing.T) *MasterServer {
t.Helper()
ms := &MasterServer{
blockRegistry: NewBlockVolumeRegistry(),
blockAssignmentQueue: NewBlockAssignmentQueue(),
blockFailover: newBlockFailoverState(),
}
ms.blockVSAllocate = func(ctx context.Context, server string, name string, sizeBytes uint64, diskType string, durabilityMode string) (*blockAllocResult, error) {
return &blockAllocResult{
Path: fmt.Sprintf("/data/%s.blk", name),
IQN: fmt.Sprintf("iqn.2024.test:%s", name),
ISCSIAddr: server + ":3260",
ReplicaDataAddr: server + ":14260",
ReplicaCtrlAddr: server + ":14261",
RebuildListenAddr: server + ":15000",
}, nil
}
ms.blockVSDelete = func(ctx context.Context, server string, name string) error {
return nil
}
ms.blockVSExpand = func(ctx context.Context, server string, name string, newSize uint64) (uint64, error) {
return newSize, nil
}
ms.blockVSPrepareExpand = func(ctx context.Context, server string, name string, newSize, expandEpoch uint64) error {
return nil
}
ms.blockVSCommitExpand = func(ctx context.Context, server string, name string, expandEpoch uint64) (uint64, error) {
return 2 << 30, nil
}
ms.blockVSCancelExpand = func(ctx context.Context, server string, name string, expandEpoch uint64) error {
return nil
}
ms.blockRegistry.MarkBlockCapable("vs1:9333")
ms.blockRegistry.MarkBlockCapable("vs2:9333")
ms.blockRegistry.MarkBlockCapable("vs3:9333")
return ms
}
// qaCreateRF creates a volume with the given replica factor.
func qaCreateRF(t *testing.T, ms *MasterServer, name string, rf uint32) {
t.Helper()
_, err := ms.CreateBlockVolume(context.Background(), &master_pb.CreateBlockVolumeRequest{
Name: name,
SizeBytes: 1 << 30,
ReplicaFactor: rf,
})
if err != nil {
t.Fatalf("create %s RF=%d: %v", name, rf, err)
}
}
// ────────────────────────────────────────────────────────────
// QA-B09-1: ExpandAfterDoubleFailover_RF3
//
// RF=3 volume. Primary dies → promote replica A. Then replica A
// (now primary) dies → promote replica B. Expand must reach
// replica B (the second-generation primary), not the original.
// ────────────────────────────────────────────────────────────
func TestQA_B09_ExpandAfterDoubleFailover_RF3(t *testing.T) {
ms := qaExpandMaster(t)
qaCreateRF(t, ms, "dbl-failover", 3)
entry, _ := ms.blockRegistry.Lookup("dbl-failover")
gen0Primary := entry.VolumeServer
// First failover: kill original primary.
ms.blockRegistry.PromoteBestReplica("dbl-failover")
entry, _ = ms.blockRegistry.Lookup("dbl-failover")
gen1Primary := entry.VolumeServer
if gen1Primary == gen0Primary {
t.Fatal("first promotion didn't change primary")
}
// Second failover: kill gen1 primary.
// Need to ensure the remaining replica has a fresh heartbeat.
if len(entry.Replicas) == 0 {
t.Fatal("no replicas left after first promotion (need RF=3)")
}
ms.blockRegistry.PromoteBestReplica("dbl-failover")
entry, _ = ms.blockRegistry.Lookup("dbl-failover")
gen2Primary := entry.VolumeServer
if gen2Primary == gen1Primary || gen2Primary == gen0Primary {
t.Fatalf("second promotion should pick a new server, got %q (gen0=%q gen1=%q)",
gen2Primary, gen0Primary, gen1Primary)
}
// Track PREPARE targets.
var preparedServers []string
ms.blockVSPrepareExpand = func(ctx context.Context, server string, name string, newSize, expandEpoch uint64) error {
preparedServers = append(preparedServers, server)
return nil
}
// Expand — standalone path since no replicas remain after 2 promotions.
_, err := ms.ExpandBlockVolume(context.Background(), &master_pb.ExpandBlockVolumeRequest{
Name: "dbl-failover", NewSizeBytes: 2 << 30,
})
if err != nil {
t.Fatalf("expand: %v", err)
}
// If standalone path was taken (no replicas), preparedServers is empty — that's fine.
// If coordinated path was taken, first PREPARE must target gen2Primary.
if len(preparedServers) > 0 && preparedServers[0] != gen2Primary {
t.Fatalf("PREPARE went to %q, want gen2 primary %q", preparedServers[0], gen2Primary)
}
}
// ────────────────────────────────────────────────────────────
// QA-B09-2: ExpandSeesDeletedVolume_AfterLockAcquire
//
// Volume is deleted between the initial Lookup (succeeds) and
// the re-read after AcquireExpandInflight. The re-read must
// detect the deletion and fail cleanly.
// ────────────────────────────────────────────────────────────
func TestQA_B09_ExpandSeesDeletedVolume_AfterLockAcquire(t *testing.T) {
ms := qaExpandMaster(t)
qaCreateRF(t, ms, "disappear", 2)
// Hook PREPARE to delete the volume before it runs.
// The B-09 re-read happens before PREPARE, so we simulate deletion
// between initial Lookup and AcquireExpandInflight by having a
// goroutine that deletes the entry while expand is in progress.
// Instead, test directly: acquire expand lock, then unregister, then
// call ExpandBlockVolume — it should fail on re-read.
// Acquire expand lock manually first so the real call gets blocked.
// Then verify the error path by attempting a second expand.
if !ms.blockRegistry.AcquireExpandInflight("disappear", 2<<30, 1) {
t.Fatal("AcquireExpandInflight should succeed")
}
// Try another expand while locked — should fail with "already in progress".
_, err := ms.ExpandBlockVolume(context.Background(), &master_pb.ExpandBlockVolumeRequest{
Name: "disappear", NewSizeBytes: 2 << 30,
})
if err == nil {
t.Fatal("expand should fail when lock is held")
}
// Release and delete the volume.
ms.blockRegistry.ReleaseExpandInflight("disappear")
ms.blockRegistry.Unregister("disappear")
// Now expand on a deleted volume — should fail on initial Lookup.
_, err = ms.ExpandBlockVolume(context.Background(), &master_pb.ExpandBlockVolumeRequest{
Name: "disappear", NewSizeBytes: 2 << 30,
})
if err == nil {
t.Fatal("expand on deleted volume should fail")
}
}
// ────────────────────────────────────────────────────────────
// QA-B09-3: ConcurrentExpandAndFailover
//
// Expand and failover race on the same volume. Neither should
// panic, and the volume must be in a consistent state afterward.
// ────────────────────────────────────────────────────────────
func TestQA_B09_ConcurrentExpandAndFailover(t *testing.T) {
ms := qaExpandMaster(t)
qaCreateRF(t, ms, "race-vol", 3)
entry, _ := ms.blockRegistry.Lookup("race-vol")
primary := entry.VolumeServer
// Make PREPARE slow so expand holds the lock longer.
ms.blockVSPrepareExpand = func(ctx context.Context, server string, name string, newSize, expandEpoch uint64) error {
time.Sleep(5 * time.Millisecond)
return nil
}
var wg sync.WaitGroup
// Goroutine 1: expand.
wg.Add(1)
go func() {
defer wg.Done()
ms.ExpandBlockVolume(context.Background(), &master_pb.ExpandBlockVolumeRequest{
Name: "race-vol", NewSizeBytes: 2 << 30,
})
// Error is OK — we're testing for panics and consistency.
}()
// Goroutine 2: failover kills primary.
wg.Add(1)
go func() {
defer wg.Done()
time.Sleep(2 * time.Millisecond) // slight delay to let expand start
ms.failoverBlockVolumes(primary)
}()
wg.Wait()
// Volume must still exist regardless of outcome.
_, ok := ms.blockRegistry.Lookup("race-vol")
if !ok {
t.Fatal("volume must survive concurrent expand + failover")
}
}
// ────────────────────────────────────────────────────────────
// QA-B09-4: ConcurrentExpandsSameVolume
//
// Two goroutines try to expand the same volume simultaneously.
// Exactly one should succeed, the other should get "already in
// progress". No panic, no double-commit.
// ────────────────────────────────────────────────────────────
func TestQA_B09_ConcurrentExpandsSameVolume(t *testing.T) {
ms := qaExpandMaster(t)
qaCreateRF(t, ms, "dup-expand", 2)
var commitCount atomic.Int32
ms.blockVSPrepareExpand = func(ctx context.Context, server string, name string, newSize, expandEpoch uint64) error {
time.Sleep(5 * time.Millisecond) // slow prepare
return nil
}
ms.blockVSCommitExpand = func(ctx context.Context, server string, name string, expandEpoch uint64) (uint64, error) {
commitCount.Add(1)
return 2 << 30, nil
}
var wg sync.WaitGroup
var successes atomic.Int32
var failures atomic.Int32
for i := 0; i < 2; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_, err := ms.ExpandBlockVolume(context.Background(), &master_pb.ExpandBlockVolumeRequest{
Name: "dup-expand", NewSizeBytes: 2 << 30,
})
if err == nil {
successes.Add(1)
} else {
failures.Add(1)
}
}()
}
wg.Wait()
if successes.Load() != 1 {
t.Fatalf("expected exactly 1 success, got %d", successes.Load())
}
if failures.Load() != 1 {
t.Fatalf("expected exactly 1 failure (already in progress), got %d", failures.Load())
}
}
// ────────────────────────────────────────────────────────────
// QA-B10-1: RepeatedEmptyHeartbeats_DuringExpand
//
// Multiple empty heartbeats from the primary during expand.
// Entry must survive all of them — not just the first.
// ────────────────────────────────────────────────────────────
func TestQA_B10_RepeatedEmptyHeartbeats_DuringExpand(t *testing.T) {
ms := qaExpandMaster(t)
qaCreateRF(t, ms, "multi-hb", 2)
entry, _ := ms.blockRegistry.Lookup("multi-hb")
primary := entry.VolumeServer
if !ms.blockRegistry.AcquireExpandInflight("multi-hb", 2<<30, 42) {
t.Fatal("acquire expand lock")
}
// 10 empty heartbeats from the primary — each one would delete
// the entry without the B-10 guard.
for i := 0; i < 10; i++ {
ms.blockRegistry.UpdateFullHeartbeat(primary, []*master_pb.BlockVolumeInfoMessage{})
}
_, ok := ms.blockRegistry.Lookup("multi-hb")
if !ok {
t.Fatal("entry deleted after repeated empty heartbeats during expand")
}
ms.blockRegistry.ReleaseExpandInflight("multi-hb")
}
// ────────────────────────────────────────────────────────────
// QA-B10-2: ExpandFailed_HeartbeatStillProtected
//
// After MarkExpandFailed (primary committed, replica didn't),
// empty heartbeats must NOT delete the entry. ExpandFailed
// keeps ExpandInProgress=true as a size-suppression guard.
// ────────────────────────────────────────────────────────────
func TestQA_B10_ExpandFailed_HeartbeatStillProtected(t *testing.T) {
ms := qaExpandMaster(t)
qaCreateRF(t, ms, "fail-hb", 2)
entry, _ := ms.blockRegistry.Lookup("fail-hb")
primary := entry.VolumeServer
if !ms.blockRegistry.AcquireExpandInflight("fail-hb", 2<<30, 42) {
t.Fatal("acquire expand lock")
}
ms.blockRegistry.MarkExpandFailed("fail-hb")
// Empty heartbeat should not delete — ExpandFailed keeps ExpandInProgress=true.
ms.blockRegistry.UpdateFullHeartbeat(primary, []*master_pb.BlockVolumeInfoMessage{})
e, ok := ms.blockRegistry.Lookup("fail-hb")
if !ok {
t.Fatal("entry deleted during ExpandFailed state")
}
if !e.ExpandFailed {
t.Fatal("ExpandFailed should still be true")
}
if !e.ExpandInProgress {
t.Fatal("ExpandInProgress should still be true")
}
// After ClearExpandFailed, empty heartbeat should delete normally.
ms.blockRegistry.ClearExpandFailed("fail-hb")
ms.blockRegistry.UpdateFullHeartbeat(primary, []*master_pb.BlockVolumeInfoMessage{})
_, ok = ms.blockRegistry.Lookup("fail-hb")
if ok {
t.Fatal("entry should be deleted after ClearExpandFailed + empty heartbeat")
}
}
// ────────────────────────────────────────────────────────────
// QA-B10-3: HeartbeatSizeSuppress_DuringExpand
//
// Primary reports a stale (old) size during coordinated expand.
// Registry must NOT downgrade SizeBytes — the pending expand
// size is authoritative until commit or release.
// ────────────────────────────────────────────────────────────
func TestQA_B10_HeartbeatSizeSuppress_DuringExpand(t *testing.T) {
ms := qaExpandMaster(t)
qaCreateRF(t, ms, "size-suppress", 2)
entry, _ := ms.blockRegistry.Lookup("size-suppress")
primary := entry.VolumeServer
origSize := entry.SizeBytes
if !ms.blockRegistry.AcquireExpandInflight("size-suppress", 2<<30, 42) {
t.Fatal("acquire expand lock")
}
// Heartbeat reports old size (expand hasn't committed on VS yet).
ms.blockRegistry.UpdateFullHeartbeat(primary, []*master_pb.BlockVolumeInfoMessage{
{
Path: "/data/size-suppress.blk",
VolumeSize: origSize, // old size
Epoch: 1,
Role: blockvol.RoleToWire(blockvol.RolePrimary),
},
})
entry, _ = ms.blockRegistry.Lookup("size-suppress")
if entry.SizeBytes != origSize {
t.Fatalf("size should remain %d during expand, got %d", origSize, entry.SizeBytes)
}
// Heartbeat reports a LARGER size (stale from previous expand or bug).
// Still must not update — coordinated expand owns the size.
ms.blockRegistry.UpdateFullHeartbeat(primary, []*master_pb.BlockVolumeInfoMessage{
{
Path: "/data/size-suppress.blk",
VolumeSize: 5 << 30, // bogus large size
Epoch: 1,
Role: blockvol.RoleToWire(blockvol.RolePrimary),
},
})
entry, _ = ms.blockRegistry.Lookup("size-suppress")
if entry.SizeBytes != origSize {
t.Fatalf("size should remain %d (suppressed), got %d", origSize, entry.SizeBytes)
}
ms.blockRegistry.ReleaseExpandInflight("size-suppress")
}
// ────────────────────────────────────────────────────────────
// QA-B10-4: ConcurrentHeartbeatsAndExpand
//
// Simultaneous full heartbeats from primary and replicas while
// expand runs on another goroutine. Must not panic, must not
// orphan the entry, and expand must either succeed or fail
// cleanly with a clear error.
// ────────────────────────────────────────────────────────────
func TestQA_B10_ConcurrentHeartbeatsAndExpand(t *testing.T) {
ms := qaExpandMaster(t)
qaCreateRF(t, ms, "hb-expand-race", 2)
entry, _ := ms.blockRegistry.Lookup("hb-expand-race")
primary := entry.VolumeServer
replica := ""
if len(entry.Replicas) > 0 {
replica = entry.Replicas[0].Server
}
ms.blockVSPrepareExpand = func(ctx context.Context, server string, name string, newSize, expandEpoch uint64) error {
time.Sleep(2 * time.Millisecond)
return nil
}
var wg sync.WaitGroup
const rounds = 30
// Goroutine 1: expand.
wg.Add(1)
go func() {
defer wg.Done()
ms.ExpandBlockVolume(context.Background(), &master_pb.ExpandBlockVolumeRequest{
Name: "hb-expand-race", NewSizeBytes: 2 << 30,
})
}()
// Goroutine 2: primary heartbeats (mix of reporting and not reporting).
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < rounds; i++ {
if i%5 == 0 {
// Every 5th: empty heartbeat (simulates brief restart).
ms.blockRegistry.UpdateFullHeartbeat(primary, []*master_pb.BlockVolumeInfoMessage{})
} else {
ms.blockRegistry.UpdateFullHeartbeat(primary, []*master_pb.BlockVolumeInfoMessage{
{
Path: "/data/hb-expand-race.blk",
VolumeSize: 1 << 30,
Epoch: 1,
Role: blockvol.RoleToWire(blockvol.RolePrimary),
WalHeadLsn: uint64(100 + i),
},
})
}
}
}()
// Goroutine 3: replica heartbeats.
if replica != "" {
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < rounds; i++ {
ms.blockRegistry.UpdateFullHeartbeat(replica, []*master_pb.BlockVolumeInfoMessage{
{
Path: "/data/hb-expand-race.blk",
VolumeSize: 1 << 30,
Epoch: 1,
Role: blockvol.RoleToWire(blockvol.RoleReplica),
WalHeadLsn: uint64(99 + i),
},
})
}
}()
}
wg.Wait()
// Volume must still exist — no orphan.
_, ok := ms.blockRegistry.Lookup("hb-expand-race")
if !ok {
t.Fatal("volume must survive concurrent heartbeats + expand")
}
}
File diff suppressed because it is too large. Load diff