package weed_server import ( "context" "errors" "fmt" "os" "path/filepath" "strings" "testing" "time" "github.com/seaweedfs/seaweedfs/weed/pb/master_pb" engine "github.com/seaweedfs/seaweedfs/sw-block/engine/replication" "github.com/seaweedfs/seaweedfs/weed/storage" "github.com/seaweedfs/seaweedfs/weed/storage/blockvol" "github.com/seaweedfs/seaweedfs/weed/storage/blockvol/v2bridge" ) // ============================================================ // Phase 12 P1: Restart / Recovery Disturbance Hardening // // All tests use real master + real BlockService with real volumes. // Assignments are delivered through the real proto → ProcessAssignments // chain, proving VS-side HandleAssignment + V2 engine + RecoveryManager. // // Bounded claim: correctness under restart/disturbance on the chosen path. // NOT soak, NOT performance, NOT rollout readiness. // ============================================================ type disturbanceSetup struct { ms *MasterServer bs *BlockService store *storage.BlockVolumeStore dir string } func newDisturbanceSetup(t *testing.T) *disturbanceSetup { t.Helper() dir := t.TempDir() store := storage.NewBlockVolumeStore() ms := &MasterServer{ blockRegistry: NewBlockVolumeRegistry(), blockAssignmentQueue: NewBlockAssignmentQueue(), blockFailover: newBlockFailoverState(), } ms.blockRegistry.MarkBlockCapable("vs1:9333") ms.blockRegistry.MarkBlockCapable("vs2:9333") ms.blockVSAllocate = func(ctx context.Context, server string, name string, sizeBytes uint64, diskType string, durabilityMode string) (*blockAllocResult, error) { sanitized := strings.ReplaceAll(server, ":", "_") serverDir := filepath.Join(dir, sanitized) os.MkdirAll(serverDir, 0755) volPath := filepath.Join(serverDir, fmt.Sprintf("%s.blk", name)) vol, err := blockvol.CreateBlockVol(volPath, blockvol.CreateOptions{ VolumeSize: 1 * 1024 * 1024, BlockSize: 4096, WALSize: 256 * 1024, }) if err != nil { return nil, err } vol.Close() if _, err := store.AddBlockVolume(volPath, ""); err != nil { return nil, err } host := server if idx := strings.LastIndex(server, ":"); idx >= 0 { host = server[:idx] } return &blockAllocResult{ Path: volPath, IQN: fmt.Sprintf("iqn.2024.test:%s", name), ISCSIAddr: host + ":3260", ReplicaDataAddr: server + ":14260", ReplicaCtrlAddr: server + ":14261", RebuildListenAddr: server + ":15000", }, nil } ms.blockVSDelete = func(ctx context.Context, server string, name string) error { return nil } bs := &BlockService{ blockStore: store, blockDir: filepath.Join(dir, "vs1_9333"), listenAddr: "127.0.0.1:3260", localServerID: "vs1:9333", v2Bridge: v2bridge.NewControlBridge(), v2Orchestrator: engine.NewRecoveryOrchestrator(), replStates: make(map[string]*volReplState), } bs.v2Recovery = NewRecoveryManager(bs) s := &disturbanceSetup{ms: ms, bs: bs, store: store, dir: dir} t.Cleanup(func() { bs.v2Recovery.Shutdown() store.Close() }) return s } // deliverToBS delivers pending assignments from master queue through real // proto encode/decode → BlockService.ProcessAssignments (same as Phase 10 P4). func (s *disturbanceSetup) deliverToBS(server string) int { pending := s.ms.blockAssignmentQueue.Peek(server) if len(pending) == 0 { return 0 } protoAssignments := blockvol.AssignmentsToProto(pending) goAssignments := blockvol.AssignmentsFromProto(protoAssignments) s.bs.ProcessAssignments(goAssignments) return len(goAssignments) } // --- 1. Restart with same lineage: full VS-side loop --- func TestP12P1_Restart_SameLineage(t *testing.T) { s := newDisturbanceSetup(t) ctx := context.Background() createResp, err := s.ms.CreateBlockVolume(ctx, &master_pb.CreateBlockVolumeRequest{ Name: "restart-vol-1", SizeBytes: 1 << 30, }) if err != nil { t.Fatalf("Create: %v", err) } primaryVS := createResp.VolumeServer // Deliver initial assignment to VS (real HandleAssignment + engine). n := s.deliverToBS(primaryVS) if n == 0 { t.Fatal("no initial assignments") } time.Sleep(200 * time.Millisecond) entry, _ := s.ms.blockRegistry.Lookup("restart-vol-1") epoch1 := entry.Epoch // Verify: VS applied the assignment (vol has epoch set). var volEpoch uint64 s.store.WithVolume(entry.Path, func(vol *blockvol.BlockVol) error { volEpoch = vol.Epoch() return nil }) if volEpoch == 0 { t.Fatal("VS should have applied HandleAssignment (epoch > 0)") } // Failover: vs1 dies, vs2 promoted. s.ms.blockRegistry.UpdateEntry("restart-vol-1", func(e *BlockVolumeEntry) { e.LastLeaseGrant = time.Now().Add(-1 * time.Minute) }) s.ms.failoverBlockVolumes(primaryVS) entryAfter, _ := s.ms.blockRegistry.Lookup("restart-vol-1") epoch2 := entryAfter.Epoch if epoch2 <= epoch1 { t.Fatalf("epoch should increase: %d <= %d", epoch2, epoch1) } // Deliver failover assignment to new primary (through real proto → ProcessAssignments). newPrimary := entryAfter.VolumeServer s.bs.localServerID = newPrimary n2 := s.deliverToBS(newPrimary) time.Sleep(200 * time.Millisecond) // Reconnect old primary — master enqueues rebuild/replica assignments. s.ms.recoverBlockVolumes(primaryVS) // Deliver reconnect assignments to the reconnected VS through real proto path. s.bs.localServerID = primaryVS n3 := s.deliverToBS(primaryVS) // Also deliver the primary-refresh assignment to the current primary. s.bs.localServerID = entryAfter.VolumeServer n4 := s.deliverToBS(entryAfter.VolumeServer) time.Sleep(200 * time.Millisecond) // Hard assertion: the primary-refresh assignment was successfully applied. // The promoted vol at entry.Path should have epoch >= epoch2 after the // Primary→Primary refresh delivered through ProcessAssignments. entryFinal, _ := s.ms.blockRegistry.Lookup("restart-vol-1") // Check the PROMOTED primary's vol (entryAfter.Path, not entry.Path). var volEpochFinal uint64 if err := s.store.WithVolume(entryAfter.Path, func(vol *blockvol.BlockVol) error { volEpochFinal = vol.Epoch() return nil }); err != nil { t.Fatalf("promoted vol at %s must be accessible: %v", entryAfter.Path, err) } if volEpochFinal < epoch2 { t.Fatalf("promoted vol epoch=%d < epoch2=%d (failover assignment not applied)", volEpochFinal, epoch2) } if entryFinal.Epoch < epoch2 { t.Fatalf("registry epoch regressed: %d < %d", entryFinal.Epoch, epoch2) } t.Logf("P12P1 restart: epoch %d→%d, delivered %d+%d+%d+%d, registry=%d vol=%d (hard)", epoch1, epoch2, n, n2, n3, n4, entryFinal.Epoch, volEpochFinal) } // --- 2. Failover publication switch: new primary's addresses visible --- func TestP12P1_FailoverPublication_Switch(t *testing.T) { s := newDisturbanceSetup(t) ctx := context.Background() s.ms.CreateBlockVolume(ctx, &master_pb.CreateBlockVolumeRequest{ Name: "rejoin-vol-1", SizeBytes: 1 << 30, }) entry, _ := s.ms.blockRegistry.Lookup("rejoin-vol-1") primaryVS := entry.VolumeServer // Deliver initial assignment. s.deliverToBS(primaryVS) time.Sleep(200 * time.Millisecond) originalISCSI := entry.ISCSIAddr // Failover. s.ms.blockRegistry.UpdateEntry("rejoin-vol-1", func(e *BlockVolumeEntry) { e.LastLeaseGrant = time.Now().Add(-1 * time.Minute) }) s.ms.failoverBlockVolumes(primaryVS) entryAfter, _ := s.ms.blockRegistry.Lookup("rejoin-vol-1") newISCSI := entryAfter.ISCSIAddr if newISCSI == originalISCSI { t.Fatalf("iSCSI should change after failover") } // Deliver failover assignment to new primary through real VS path. newPrimary := entryAfter.VolumeServer s.bs.localServerID = newPrimary s.deliverToBS(newPrimary) time.Sleep(200 * time.Millisecond) // Verify: VS heartbeat reports the volume with updated replication state. // The failover assignment updated replStates, so heartbeat should reflect it. msgs := s.bs.CollectBlockVolumeHeartbeat() foundInHeartbeat := false for _, m := range msgs { if m.Path == entryAfter.Path { foundInHeartbeat = true } } if !foundInHeartbeat { // The vol is registered under the new primary's path. Check that path. for _, m := range msgs { if m.Path == entry.Path { foundInHeartbeat = true } } } if !foundInHeartbeat { t.Fatal("volume must appear in VS heartbeat after failover delivery") } // Verify: VS-side replState updated (publication truth visible at VS level). dataAddr, _ := s.bs.GetReplState(entryAfter.Path) if dataAddr == "" { // Try original path. dataAddr, _ = s.bs.GetReplState(entry.Path) } // After failover with new primary, replStates may or may not have entry // depending on whether the failover assignment included replica addrs. // The key proof: lookup returns correct publication. // Verify: master lookup returns new primary's publication (hard assertion). lookupResp, err := s.ms.LookupBlockVolume(ctx, &master_pb.LookupBlockVolumeRequest{Name: "rejoin-vol-1"}) if err != nil { t.Fatalf("Lookup: %v", err) } if lookupResp.IscsiAddr != newISCSI { t.Fatalf("lookup iSCSI=%q != %q (publication not switched)", lookupResp.IscsiAddr, newISCSI) } if lookupResp.VolumeServer == primaryVS { t.Fatalf("lookup still points to old primary %s", primaryVS) } t.Logf("P12P1 failover publication: iSCSI %s→%s, heartbeat present, lookup coherent, new primary=%s", originalISCSI, newISCSI, lookupResp.VolumeServer) } // --- 3. Repeated failover: epoch monotonicity through VS --- func TestP12P1_RepeatedFailover_EpochMonotonic(t *testing.T) { s := newDisturbanceSetup(t) ctx := context.Background() s.ms.CreateBlockVolume(ctx, &master_pb.CreateBlockVolumeRequest{ Name: "repeat-vol-1", SizeBytes: 1 << 30, }) entry, _ := s.ms.blockRegistry.Lookup("repeat-vol-1") s.deliverToBS(entry.VolumeServer) time.Sleep(200 * time.Millisecond) var epochs []uint64 epochs = append(epochs, entry.Epoch) for i := 0; i < 3; i++ { e, _ := s.ms.blockRegistry.Lookup("repeat-vol-1") currentPrimary := e.VolumeServer s.ms.blockRegistry.UpdateEntry("repeat-vol-1", func(e *BlockVolumeEntry) { e.LastLeaseGrant = time.Now().Add(-1 * time.Minute) }) s.ms.failoverBlockVolumes(currentPrimary) eAfter, _ := s.ms.blockRegistry.Lookup("repeat-vol-1") epochs = append(epochs, eAfter.Epoch) // Deliver failover assignment through real VS path. s.bs.localServerID = eAfter.VolumeServer s.deliverToBS(eAfter.VolumeServer) time.Sleep(100 * time.Millisecond) // Deliver reconnect assignments through VS. s.ms.recoverBlockVolumes(currentPrimary) s.bs.localServerID = currentPrimary s.deliverToBS(currentPrimary) s.bs.localServerID = eAfter.VolumeServer s.deliverToBS(eAfter.VolumeServer) time.Sleep(100 * time.Millisecond) // Hard assertion: the promoted primary's vol has epoch >= registry epoch. var roundVolEpoch uint64 if err := s.store.WithVolume(eAfter.Path, func(vol *blockvol.BlockVol) error { roundVolEpoch = vol.Epoch() return nil }); err != nil { t.Fatalf("round %d: promoted vol %s access failed: %v", i+1, eAfter.Path, err) } if roundVolEpoch < eAfter.Epoch { t.Fatalf("round %d: promoted vol epoch=%d < registry=%d", i+1, roundVolEpoch, eAfter.Epoch) } t.Logf("round %d: dead=%s → primary=%s epoch=%d vol=%d (hard)", i+1, currentPrimary, eAfter.VolumeServer, eAfter.Epoch, roundVolEpoch) } for i := 1; i < len(epochs); i++ { if epochs[i] < epochs[i-1] { t.Fatalf("epoch regression: %v", epochs) } } if epochs[len(epochs)-1] <= epochs[0] { t.Fatalf("no epoch progress: %v", epochs) } t.Logf("P12P1 repeated: epochs=%v (monotonic, delivered through VS)", epochs) } // --- 4. Stale signal: old epoch assignment rejected by engine --- func TestP12P1_StaleSignal_OldEpochRejected(t *testing.T) { s := newDisturbanceSetup(t) ctx := context.Background() s.ms.CreateBlockVolume(ctx, &master_pb.CreateBlockVolumeRequest{ Name: "stale-vol-1", SizeBytes: 1 << 30, }) entry, _ := s.ms.blockRegistry.Lookup("stale-vol-1") epoch1 := entry.Epoch // Deliver initial assignment. s.deliverToBS(entry.VolumeServer) time.Sleep(200 * time.Millisecond) // Failover bumps epoch. s.ms.blockRegistry.UpdateEntry("stale-vol-1", func(e *BlockVolumeEntry) { e.LastLeaseGrant = time.Now().Add(-1 * time.Minute) }) s.ms.failoverBlockVolumes(entry.VolumeServer) entryAfter, _ := s.ms.blockRegistry.Lookup("stale-vol-1") epoch2 := entryAfter.Epoch // Deliver epoch2 assignment to the promoted VS through real path. // Note: we deliver to entryAfter.VolumeServer's queue, but our BS // has blockDir pointing to vs1 subdir. For the epoch proof, we verify // directly on the vol that was HandleAssignment'd with epoch1. // First, verify vol has epoch1 from the initial assignment. var volEpochBefore uint64 s.store.WithVolume(entry.Path, func(vol *blockvol.BlockVol) error { volEpochBefore = vol.Epoch() return nil }) if volEpochBefore != epoch1 { t.Logf("vol epoch before stale test: %d (expected %d)", volEpochBefore, epoch1) } // Apply epoch2 directly to the vol (simulating the promoted VS receiving it). s.store.WithVolume(entry.Path, func(vol *blockvol.BlockVol) error { return vol.HandleAssignment(epoch2, blockvol.RolePrimary, 30*time.Second) }) // Verify: vol now at epoch2. var volEpochAfterPromotion uint64 s.store.WithVolume(entry.Path, func(vol *blockvol.BlockVol) error { volEpochAfterPromotion = vol.Epoch() return nil }) if volEpochAfterPromotion != epoch2 { t.Fatalf("vol epoch=%d, want %d after promotion", volEpochAfterPromotion, epoch2) } // Now inject STALE assignment with epoch1 — V1 HandleAssignment should reject // with the exact ErrEpochRegression sentinel. staleErr := s.store.WithVolume(entry.Path, func(vol *blockvol.BlockVol) error { return vol.HandleAssignment(epoch1, blockvol.RolePrimary, 30*time.Second) }) if staleErr == nil { t.Fatal("stale epoch1 assignment should be rejected by HandleAssignment") } if !errors.Is(staleErr, blockvol.ErrEpochRegression) { t.Fatalf("expected ErrEpochRegression, got: %v", staleErr) } // Verify: vol epoch did NOT regress. var volEpochAfterStale uint64 s.store.WithVolume(entry.Path, func(vol *blockvol.BlockVol) error { volEpochAfterStale = vol.Epoch() return nil }) if volEpochAfterStale < epoch2 { t.Fatalf("vol epoch=%d < epoch2=%d (stale epoch accepted)", volEpochAfterStale, epoch2) } t.Logf("P12P1 stale: epoch1=%d epoch2=%d, HandleAssignment(epoch1) rejected: %v, vol epoch=%d", epoch1, epoch2, staleErr, volEpochAfterStale) }