Files
seaweedfs/weed/server/qa_block_nvme_publication_test.go
T
Ping QiuandClaude Opus 4.6 a9a5e455c6 fix: Lookup/ListAll return copies, add UpdateEntry for safe mutation
Lookup() and ListAll() now return value copies (not pointers to
internal registry state). Callers can no longer mutate registry
entries without holding a lock.

Added clone() on BlockVolumeEntry with deep-copied Replicas slice.
Added UpdateEntry(name, func(*BlockVolumeEntry)) for locked mutation.
ListByServer() also returns copies.

Migrated 1 production mutation (ReplicaPlacement + Preset in create
handler) and ~20 test mutations to use UpdateEntry.

5 new copy-correctness tests: Lookup returns copy, Replicas slice
isolated, ListAll returns copies, UpdateEntry mutates, UpdateEntry
not-found error.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 01:00:27 -07:00

1350 lines
46 KiB
Go

package weed_server
import (
"context"
"fmt"
"os"
"strings"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
"github.com/seaweedfs/seaweedfs/weed/storage/blockvol"
)
// =============================================================================
// QA Adversarial Tests for Master-Backed NVMe Publication (Item 1)
//
// These tests verify:
// - NVMe fields (NvmeAddr, NQN) propagated through registry lifecycle
// - Backward compatibility: missing NVMe fields degrade gracefully to iSCSI
// - Heartbeat reconstruction after master restart
// - Partial-field behavior (NvmeAddr without NQN, vice versa)
// - PromoteBestReplica preserves NVMe metadata of promoted replica
// =============================================================================
// TestQA_NVMe_CreateSetsFields verifies that NvmeAddr/NQN are preserved in
// registry entries created via Register (simulating the CreateBlockVolume path).
func TestQA_NVMe_CreateSetsFields(t *testing.T) {
r := NewBlockVolumeRegistry()
err := r.Register(&BlockVolumeEntry{
Name: "nvme-vol1",
VolumeServer: "s1:18080",
Path: "/data/nvme-vol1.blk",
IQN: "iqn.2024.com.seaweedfs:nvme-vol1",
ISCSIAddr: "10.0.0.1:3260",
NvmeAddr: "10.0.0.1:4420",
NQN: "nqn.2024-01.com.seaweedfs:nvme-vol1",
SizeBytes: 1 << 30,
Epoch: 1,
Role: blockvol.RoleToWire(blockvol.RolePrimary),
Status: StatusActive,
})
if err != nil {
t.Fatalf("Register: %v", err)
}
entry, ok := r.Lookup("nvme-vol1")
if !ok {
t.Fatal("nvme-vol1 not found")
}
if entry.NvmeAddr != "10.0.0.1:4420" {
t.Fatalf("NvmeAddr = %q, want 10.0.0.1:4420", entry.NvmeAddr)
}
if entry.NQN != "nqn.2024-01.com.seaweedfs:nvme-vol1" {
t.Fatalf("NQN = %q, want nqn.2024-01.com.seaweedfs:nvme-vol1", entry.NQN)
}
}
// TestQA_NVMe_MissingFieldsDegradeToISCSI verifies that entries without NVMe
// fields still work correctly via iSCSI (backward compatibility).
func TestQA_NVMe_MissingFieldsDegradeToISCSI(t *testing.T) {
r := NewBlockVolumeRegistry()
r.Register(&BlockVolumeEntry{
Name: "iscsi-only",
VolumeServer: "s1:18080",
Path: "/data/iscsi-only.blk",
IQN: "iqn.2024.com.seaweedfs:iscsi-only",
ISCSIAddr: "10.0.0.1:3260",
// NvmeAddr and NQN intentionally omitted.
SizeBytes: 1 << 30,
Epoch: 1,
Status: StatusActive,
})
entry, ok := r.Lookup("iscsi-only")
if !ok {
t.Fatal("iscsi-only not found")
}
if entry.NvmeAddr != "" {
t.Fatalf("NvmeAddr should be empty for iSCSI-only volume, got %q", entry.NvmeAddr)
}
if entry.NQN != "" {
t.Fatalf("NQN should be empty for iSCSI-only volume, got %q", entry.NQN)
}
// iSCSI fields should still work.
if entry.ISCSIAddr != "10.0.0.1:3260" {
t.Fatalf("ISCSIAddr = %q", entry.ISCSIAddr)
}
}
// TestQA_NVMe_HeartbeatSetsNvmeFields verifies that a full heartbeat with
// NVMe fields updates the registry entry. This is critical for master restart
// reconstruction — NvmeAddr/NQN must be propagated from heartbeat.
func TestQA_NVMe_HeartbeatSetsNvmeFields(t *testing.T) {
r := NewBlockVolumeRegistry()
r.Register(&BlockVolumeEntry{
Name: "vol1",
VolumeServer: "s1",
Path: "/data/vol1.blk",
Status: StatusPending,
// NvmeAddr/NQN NOT set at creation (simulates pre-NVMe registration).
})
// Full heartbeat arrives with NVMe fields.
r.UpdateFullHeartbeat("s1", []*master_pb.BlockVolumeInfoMessage{
{
Path: "/data/vol1.blk",
VolumeSize: 1 << 30,
Epoch: 1,
Role: 1,
NvmeAddr: "10.0.0.1:4420",
Nqn: "nqn.2024-01.com.seaweedfs:vol1",
},
}, "")
entry, ok := r.Lookup("vol1")
if !ok {
t.Fatal("vol1 not found after heartbeat")
}
if entry.Status != StatusActive {
t.Fatalf("Status = %v, want Active", entry.Status)
}
// BUG DETECTION: If these fail, UpdateFullHeartbeat doesn't propagate NVMe fields.
// This is critical for master restart recovery.
if entry.NvmeAddr != "10.0.0.1:4420" {
t.Fatalf("NvmeAddr not updated by heartbeat: got %q, want 10.0.0.1:4420", entry.NvmeAddr)
}
if entry.NQN != "nqn.2024-01.com.seaweedfs:vol1" {
t.Fatalf("NQN not updated by heartbeat: got %q, want nqn.2024-01.com.seaweedfs:vol1", entry.NQN)
}
}
// TestQA_NVMe_HeartbeatClearsStaleNvme verifies that if a heartbeat omits NVMe
// fields (server no longer has NVMe enabled), the registry should reflect that.
func TestQA_NVMe_HeartbeatClearsStaleNvme(t *testing.T) {
r := NewBlockVolumeRegistry()
r.Register(&BlockVolumeEntry{
Name: "vol1",
VolumeServer: "s1",
Path: "/data/vol1.blk",
NvmeAddr: "10.0.0.1:4420", // was NVMe-enabled
NQN: "nqn.2024-01.com.seaweedfs:vol1",
Status: StatusActive,
})
// Heartbeat without NVMe fields (NVMe disabled on volume server).
r.UpdateFullHeartbeat("s1", []*master_pb.BlockVolumeInfoMessage{
{
Path: "/data/vol1.blk",
VolumeSize: 1 << 30,
Epoch: 2,
Role: 1,
// NvmeAddr and Nqn intentionally empty.
},
}, "")
entry, _ := r.Lookup("vol1")
// After heartbeat with empty NVMe fields, stale NVMe info should be cleared.
// (If not cleared, CSI may try to connect via stale NVMe address.)
if entry.NvmeAddr != "" {
t.Logf("WARNING: stale NvmeAddr not cleared by heartbeat: %q (may cause CSI to use wrong transport)", entry.NvmeAddr)
// This is a design decision — some implementations keep stale data.
// We log a warning rather than failing, since the current code may
// intentionally preserve NvmeAddr until explicitly cleared.
}
}
// TestQA_NVMe_PartialFields_OnlyAddr verifies behavior when only NvmeAddr is
// set but NQN is missing. The CSI driver needs both to connect.
func TestQA_NVMe_PartialFields_OnlyAddr(t *testing.T) {
r := NewBlockVolumeRegistry()
r.Register(&BlockVolumeEntry{
Name: "partial-nvme",
VolumeServer: "s1",
Path: "/data/partial.blk",
NvmeAddr: "10.0.0.1:4420",
// NQN is missing — NVMe connect will fail without it.
Status: StatusActive,
})
entry, _ := r.Lookup("partial-nvme")
if entry.NvmeAddr == "" {
t.Fatal("NvmeAddr should be preserved")
}
if entry.NQN != "" {
t.Fatal("NQN should be empty (partial field)")
}
// The CSI driver must check both NvmeAddr != "" && NQN != "" before attempting NVMe.
}
// TestQA_NVMe_PartialFields_OnlyNQN verifies behavior with NQN but no addr.
func TestQA_NVMe_PartialFields_OnlyNQN(t *testing.T) {
r := NewBlockVolumeRegistry()
r.Register(&BlockVolumeEntry{
Name: "partial-nqn",
VolumeServer: "s1",
Path: "/data/partial2.blk",
NQN: "nqn.2024-01.com.seaweedfs:partial2",
Status: StatusActive,
})
entry, _ := r.Lookup("partial-nqn")
if entry.NQN == "" {
t.Fatal("NQN should be preserved")
}
if entry.NvmeAddr != "" {
t.Fatal("NvmeAddr should be empty (partial field)")
}
}
// TestQA_NVMe_SwapPrimaryReplica_PreservesNvme verifies that after SwapPrimaryReplica,
// the promoted replica's NVMe fields are available in the entry.
func TestQA_NVMe_SwapPrimaryReplica_PreservesNvme(t *testing.T) {
r := NewBlockVolumeRegistry()
r.Register(&BlockVolumeEntry{
Name: "failover-vol",
VolumeServer: "primary-s1",
Path: "/data/vol.blk",
IQN: "iqn:primary",
ISCSIAddr: "10.0.0.1:3260",
NvmeAddr: "10.0.0.1:4420",
NQN: "nqn:vol-primary",
ReplicaServer: "replica-s2",
ReplicaPath: "/data/vol-replica.blk",
ReplicaIQN: "iqn:replica",
ReplicaISCSIAddr: "10.0.0.2:3260",
Epoch: 5,
Role: 1,
})
newEpoch, err := r.SwapPrimaryReplica("failover-vol")
if err != nil {
t.Fatalf("SwapPrimaryReplica: %v", err)
}
if newEpoch != 6 {
t.Fatalf("newEpoch = %d, want 6", newEpoch)
}
entry, _ := r.Lookup("failover-vol")
// After swap, the old primary's NVMe fields are now stale.
// The new primary (old replica) hasn't had its NVMe fields set yet
// — they'll come in via the next heartbeat.
if entry.VolumeServer != "replica-s2" {
t.Fatalf("VolumeServer = %q, want replica-s2", entry.VolumeServer)
}
// NvmeAddr from old primary should NOT persist on the new primary entry.
// (It pointed to old primary's NVMe target.)
// Current behavior: SwapPrimaryReplica doesn't touch NvmeAddr/NQN.
// This test documents the current behavior so we track it.
t.Logf("NvmeAddr after swap: %q (may be stale from old primary)", entry.NvmeAddr)
t.Logf("NQN after swap: %q (may be stale from old primary)", entry.NQN)
}
// TestQA_NVMe_PromoteBestReplica_NvmeFieldsCopied verifies that when a replica
// with NVMe fields is promoted to primary, its NVMe fields end up in the entry.
func TestQA_NVMe_PromoteBestReplica_NvmeFieldsCopied(t *testing.T) {
r := NewBlockVolumeRegistry()
r.MarkBlockCapable("healthy-replica")
r.Register(&BlockVolumeEntry{
Name: "promote-vol",
VolumeServer: "dead-primary",
Path: "/data/vol.blk",
NvmeAddr: "10.0.0.1:4420",
NQN: "nqn:vol-on-primary",
Epoch: 5,
Role: 1,
LeaseTTL: 30 * time.Second,
WALHeadLSN: 100,
Replicas: []ReplicaInfo{
{
Server: "healthy-replica",
Path: "/data/vol-replica.blk",
IQN: "iqn:replica",
ISCSIAddr: "10.0.0.2:3260",
HealthScore: 1.0,
WALHeadLSN: 100,
LastHeartbeat: time.Now(),
Role: blockvol.RoleToWire(blockvol.RoleReplica),
},
},
})
r.mu.Lock()
r.addToServer("healthy-replica", "promote-vol")
r.mu.Unlock()
_, err := r.PromoteBestReplica("promote-vol")
if err != nil {
t.Fatalf("PromoteBestReplica: %v", err)
}
entry, _ := r.Lookup("promote-vol")
if entry.VolumeServer != "healthy-replica" {
t.Fatalf("VolumeServer = %q, want healthy-replica", entry.VolumeServer)
}
// The promoted replica's NVMe fields should come from the next heartbeat,
// NOT from the old primary. Test that old primary's NVMe fields don't persist.
t.Logf("NvmeAddr after promotion: %q (should be updated by replica heartbeat)", entry.NvmeAddr)
t.Logf("NQN after promotion: %q (should be updated by replica heartbeat)", entry.NQN)
}
// TestQA_NVMe_HeartbeatProto_RoundTrip verifies that BlockVolumeInfoMessage
// NVMe fields survive the proto conversion round-trip.
func TestQA_NVMe_HeartbeatProto_RoundTrip(t *testing.T) {
msg := blockvol.BlockVolumeInfoMessage{
Path: "/data/vol.blk",
VolumeSize: 1 << 30,
Epoch: 5,
Role: 1,
NvmeAddr: "10.0.0.1:4420",
NQN: "nqn.2024-01.com.seaweedfs:vol1",
}
// Convert to proto and back.
proto := blockvol.InfoMessageToProto(msg)
if proto.NvmeAddr != "10.0.0.1:4420" {
t.Fatalf("proto NvmeAddr = %q", proto.NvmeAddr)
}
if proto.Nqn != "nqn.2024-01.com.seaweedfs:vol1" {
t.Fatalf("proto Nqn = %q", proto.Nqn)
}
back := blockvol.InfoMessageFromProto(proto)
if back.NvmeAddr != msg.NvmeAddr {
t.Fatalf("round-trip NvmeAddr: got %q, want %q", back.NvmeAddr, msg.NvmeAddr)
}
if back.NQN != msg.NQN {
t.Fatalf("round-trip NQN: got %q, want %q", back.NQN, msg.NQN)
}
}
// TestQA_NVMe_HeartbeatProto_EmptyFields verifies empty NVMe fields survive
// round-trip without becoming non-empty.
func TestQA_NVMe_HeartbeatProto_EmptyFields(t *testing.T) {
msg := blockvol.BlockVolumeInfoMessage{
Path: "/data/vol.blk",
Epoch: 1,
Role: 1,
// NvmeAddr and NQN empty.
}
proto := blockvol.InfoMessageToProto(msg)
if proto.NvmeAddr != "" {
t.Fatalf("proto NvmeAddr should be empty, got %q", proto.NvmeAddr)
}
if proto.Nqn != "" {
t.Fatalf("proto Nqn should be empty, got %q", proto.Nqn)
}
back := blockvol.InfoMessageFromProto(proto)
if back.NvmeAddr != "" || back.NQN != "" {
t.Fatalf("empty NVMe fields should survive round-trip: NvmeAddr=%q NQN=%q", back.NvmeAddr, back.NQN)
}
}
// TestQA_NVMe_FullHeartbeat_MasterRestart verifies the full master-restart
// reconstruction sequence: volume created with NVMe → master restarts →
// heartbeat rebuilds registry → NVMe fields available for Lookup.
func TestQA_NVMe_FullHeartbeat_MasterRestart(t *testing.T) {
// Simulate master restart: fresh registry.
r := NewBlockVolumeRegistry()
// Volume server sends first full heartbeat after master restart.
// The heartbeat includes NVMe fields.
r.UpdateFullHeartbeat("s1:18080", []*master_pb.BlockVolumeInfoMessage{
{
Path: "/data/vol1.blk",
VolumeSize: 1 << 30,
Epoch: 10,
Role: 1,
NvmeAddr: "10.0.0.1:4420",
Nqn: "nqn.2024-01.com.seaweedfs:vol1",
},
}, "")
// After heartbeat, volume should be reconstructed with NVMe fields.
// Currently the registry uses nameFromPath() to find/create entries.
// If the entry was auto-created from heartbeat, check NVMe fields.
entries := r.ListByServer("s1:18080")
if len(entries) == 0 {
t.Log("NOTE: fresh registry after master restart may not auto-create entries from heartbeat")
t.Log("This is expected if the design requires explicit Register before heartbeat updates work")
t.Skip("auto-creation from heartbeat not supported — entries must be pre-registered")
}
// If entries exist, verify NVMe fields.
for _, e := range entries {
if e.Path == "/data/vol1.blk" {
if e.NvmeAddr != "10.0.0.1:4420" {
t.Errorf("NvmeAddr not reconstructed from heartbeat: got %q", e.NvmeAddr)
}
if e.NQN != "nqn.2024-01.com.seaweedfs:vol1" {
t.Errorf("NQN not reconstructed from heartbeat: got %q", e.NQN)
}
return
}
}
t.Error("vol1.blk entry not found after heartbeat reconstruction")
}
// TestQA_NVMe_ListByServerIncludesNvmeFields verifies that ListByServer returns
// entries with NVMe fields intact (not stripped during aggregation).
func TestQA_NVMe_ListByServerIncludesNvmeFields(t *testing.T) {
r := NewBlockVolumeRegistry()
r.Register(&BlockVolumeEntry{
Name: "vol-nvme",
VolumeServer: "s1",
Path: "/data/vol-nvme.blk",
NvmeAddr: "10.0.0.1:4420",
NQN: "nqn:vol-nvme",
})
r.Register(&BlockVolumeEntry{
Name: "vol-iscsi",
VolumeServer: "s1",
Path: "/data/vol-iscsi.blk",
ISCSIAddr: "10.0.0.1:3260",
})
entries := r.ListByServer("s1")
if len(entries) != 2 {
t.Fatalf("expected 2 entries, got %d", len(entries))
}
var foundNvme bool
for _, e := range entries {
if e.Name == "vol-nvme" {
foundNvme = true
if e.NvmeAddr != "10.0.0.1:4420" {
t.Errorf("NvmeAddr stripped in ListByServer: got %q", e.NvmeAddr)
}
if e.NQN != "nqn:vol-nvme" {
t.Errorf("NQN stripped in ListByServer: got %q", e.NQN)
}
}
}
if !foundNvme {
t.Error("vol-nvme not found in ListByServer results")
}
}
// =============================================================================
// Integration Tests: NVMe Publication End-to-End Flows
//
// These tests exercise the full control-plane path that the user described:
// Create → Allocate returns NVMe fields → Registry stores them →
// Heartbeat refreshes them → Lookup/CSI returns them → Failover preserves them.
// Uses integrationMaster() mock (no real gRPC/NVMe).
// =============================================================================
// nvmeIntegrationMaster creates an integrationMaster with NVMe-capable
// allocate callback that returns NvmeAddr and NQN.
func nvmeIntegrationMaster(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) {
// Simulate volume servers with NVMe enabled.
// Each server has NVMe on :4420 and a deterministic NQN.
host := server[:strings.Index(server, ":")]
return &blockAllocResult{
Path: fmt.Sprintf("/data/%s.blk", name),
IQN: fmt.Sprintf("iqn.2024.test:%s", name),
ISCSIAddr: server[:strings.Index(server, ":")] + ":3260",
NvmeAddr: host + ":4420",
NQN: fmt.Sprintf("nqn.2024-01.com.seaweedfs:vol.%s", name),
ReplicaDataAddr: server[:strings.Index(server, ":")] + ":14260",
ReplicaCtrlAddr: server[:strings.Index(server, ":")] + ":14261",
RebuildListenAddr: server[:strings.Index(server, ":")] + ":15000",
}, nil
}
ms.blockVSDelete = func(ctx context.Context, server string, name string) error {
return nil
}
ms.blockRegistry.MarkBlockCapable("10.0.0.1:9333")
ms.blockRegistry.MarkBlockCapable("10.0.0.2:9333")
ms.blockRegistry.MarkBlockCapable("10.0.0.3:9333")
return ms
}
// TestIntegration_NVMe_CreateReturnsNvmeAddr tests the Kubernetes PVC flow:
// CreateBlockVolume → master picks a server → returns NvmeAddr + NQN for CSI.
func TestIntegration_NVMe_CreateReturnsNvmeAddr(t *testing.T) {
ms := nvmeIntegrationMaster(t)
ctx := context.Background()
resp, err := ms.CreateBlockVolume(ctx, &master_pb.CreateBlockVolumeRequest{
Name: "pvc-abc",
SizeBytes: 100 << 30, // 100GB
})
if err != nil {
t.Fatalf("CreateBlockVolume: %v", err)
}
// Primary should have NVMe fields.
if resp.NvmeAddr == "" {
t.Fatal("CreateBlockVolume response missing NvmeAddr — CSI can't use NVMe/TCP")
}
if resp.Nqn == "" {
t.Fatal("CreateBlockVolume response missing NQN — CSI can't use NVMe/TCP")
}
if !strings.Contains(resp.Nqn, "pvc-abc") {
t.Fatalf("NQN should contain volume name, got %q", resp.Nqn)
}
// NVMe address should match the primary volume server's host.
primaryHost := resp.VolumeServer[:strings.Index(resp.VolumeServer, ":")]
expectedNvmeAddr := primaryHost + ":4420"
if resp.NvmeAddr != expectedNvmeAddr {
t.Fatalf("NvmeAddr = %q, want %q (primary's NVMe port)", resp.NvmeAddr, expectedNvmeAddr)
}
t.Logf("PVC created: server=%s nvme=%s nqn=%s", resp.VolumeServer, resp.NvmeAddr, resp.Nqn)
}
// TestIntegration_NVMe_LookupReturnsNvmeAddr tests CSI ControllerPublishVolume:
// Lookup returns NvmeAddr + NQN so the node plugin can `nvme connect`.
func TestIntegration_NVMe_LookupReturnsNvmeAddr(t *testing.T) {
ms := nvmeIntegrationMaster(t)
ctx := context.Background()
createResp, err := ms.CreateBlockVolume(ctx, &master_pb.CreateBlockVolumeRequest{
Name: "pvc-lookup-1",
SizeBytes: 50 << 30,
})
if err != nil {
t.Fatalf("Create: %v", err)
}
// CSI calls Lookup to get connection details.
lookupResp, err := ms.LookupBlockVolume(ctx, &master_pb.LookupBlockVolumeRequest{Name: "pvc-lookup-1"})
if err != nil {
t.Fatalf("Lookup: %v", err)
}
// NVMe fields must match what was returned at creation.
if lookupResp.NvmeAddr != createResp.NvmeAddr {
t.Fatalf("Lookup NvmeAddr = %q, Create returned %q", lookupResp.NvmeAddr, createResp.NvmeAddr)
}
if lookupResp.Nqn != createResp.Nqn {
t.Fatalf("Lookup NQN = %q, Create returned %q", lookupResp.Nqn, createResp.Nqn)
}
// iSCSI fields should also be available (fallback path).
if lookupResp.IscsiAddr == "" {
t.Fatal("Lookup should also return iSCSI addr for fallback")
}
if lookupResp.Iqn == "" {
t.Fatal("Lookup should also return IQN for fallback")
}
t.Logf("CSI Lookup: nvme=%s nqn=%s iscsi=%s iqn=%s",
lookupResp.NvmeAddr, lookupResp.Nqn, lookupResp.IscsiAddr, lookupResp.Iqn)
}
// TestIntegration_NVMe_FailoverUpdatesNvmeAddr tests that after failover,
// Lookup returns the NEW primary's NVMe address (not the dead server's).
func TestIntegration_NVMe_FailoverUpdatesNvmeAddr(t *testing.T) {
ms := nvmeIntegrationMaster(t)
ctx := context.Background()
createResp, err := ms.CreateBlockVolume(ctx, &master_pb.CreateBlockVolumeRequest{
Name: "pvc-failover-nvme",
SizeBytes: 10 << 30,
})
if err != nil {
t.Fatalf("Create: %v", err)
}
primaryVS := createResp.VolumeServer
primaryHost := primaryVS[:strings.Index(primaryVS, ":")]
originalNvmeAddr := createResp.NvmeAddr
// Expire lease for immediate failover.
ms.blockRegistry.UpdateEntry("pvc-failover-nvme", func(entry *BlockVolumeEntry) {
entry.LastLeaseGrant = time.Now().Add(-1 * time.Minute)
})
// Primary dies → replica promoted.
ms.failoverBlockVolumes(primaryVS)
// Verify new primary is different.
entry, _ := ms.blockRegistry.Lookup("pvc-failover-nvme")
if entry.VolumeServer == primaryVS {
t.Fatal("failover didn't promote replica")
}
newPrimaryHost := entry.VolumeServer[:strings.Index(entry.VolumeServer, ":")]
// Simulate the new primary's heartbeat arriving with its NVMe fields.
// In production, the VS heartbeat collector sends this automatically.
ms.blockRegistry.UpdateFullHeartbeat(entry.VolumeServer, []*master_pb.BlockVolumeInfoMessage{
{
Path: entry.Path,
VolumeSize: 10 << 30,
Epoch: entry.Epoch,
Role: 1,
NvmeAddr: newPrimaryHost + ":4420",
Nqn: fmt.Sprintf("nqn.2024-01.com.seaweedfs:vol.pvc-failover-nvme"),
},
}, "")
// CSI re-publishes after failover: Lookup must return new NVMe address.
lookupResp, err := ms.LookupBlockVolume(ctx, &master_pb.LookupBlockVolumeRequest{Name: "pvc-failover-nvme"})
if err != nil {
t.Fatalf("post-failover Lookup: %v", err)
}
if lookupResp.NvmeAddr == originalNvmeAddr {
t.Fatalf("post-failover NvmeAddr still points to dead primary %q", originalNvmeAddr)
}
expectedNewAddr := newPrimaryHost + ":4420"
if lookupResp.NvmeAddr != expectedNewAddr {
t.Fatalf("post-failover NvmeAddr = %q, want %q", lookupResp.NvmeAddr, expectedNewAddr)
}
t.Logf("Failover: old=%s:%s → new=%s:%s",
primaryHost, originalNvmeAddr, newPrimaryHost, lookupResp.NvmeAddr)
}
// TestIntegration_NVMe_HeartbeatReconstructionAfterMasterRestart tests the
// master restart scenario:
// 1. Fresh registry (master just started)
// 2. Volume server sends heartbeat with NVMe fields
// 3. Registry auto-creates entry with NVMe fields
// 4. CSI Lookup returns NVMe connection details
func TestIntegration_NVMe_HeartbeatReconstructionAfterMasterRestart(t *testing.T) {
ms := nvmeIntegrationMaster(t)
ctx := context.Background()
// Step 1: Create volume normally.
createResp, err := ms.CreateBlockVolume(ctx, &master_pb.CreateBlockVolumeRequest{
Name: "pvc-restart-1",
SizeBytes: 20 << 30,
})
if err != nil {
t.Fatalf("Create: %v", err)
}
primaryVS := createResp.VolumeServer
primaryHost := primaryVS[:strings.Index(primaryVS, ":")]
// Step 2: Simulate master restart — fresh registry.
ms.blockRegistry = NewBlockVolumeRegistry()
ms.blockRegistry.MarkBlockCapable(primaryVS)
// Step 3: Volume server sends heartbeat with NVMe info.
ms.blockRegistry.UpdateFullHeartbeat(primaryVS, []*master_pb.BlockVolumeInfoMessage{
{
Path: "/data/pvc-restart-1.blk",
VolumeSize: 20 << 30,
Epoch: 1,
Role: 1,
NvmeAddr: primaryHost + ":4420",
Nqn: "nqn.2024-01.com.seaweedfs:vol.pvc-restart-1",
},
}, "")
// Step 4: CSI calls Lookup — must find NVMe details.
lookupResp, err := ms.LookupBlockVolume(ctx, &master_pb.LookupBlockVolumeRequest{Name: "pvc-restart-1"})
if err != nil {
t.Fatalf("Lookup after master restart: %v", err)
}
if lookupResp.NvmeAddr != primaryHost+":4420" {
t.Fatalf("NvmeAddr not reconstructed after master restart: got %q", lookupResp.NvmeAddr)
}
if lookupResp.Nqn != "nqn.2024-01.com.seaweedfs:vol.pvc-restart-1" {
t.Fatalf("NQN not reconstructed after master restart: got %q", lookupResp.Nqn)
}
t.Logf("Post-restart Lookup: nvme=%s nqn=%s", lookupResp.NvmeAddr, lookupResp.Nqn)
}
// TestIntegration_NVMe_MixedCluster tests a cluster where some volume servers
// have NVMe enabled and others don't. CSI should get NVMe when available,
// fall back to iSCSI otherwise.
func TestIntegration_NVMe_MixedCluster(t *testing.T) {
ms := &MasterServer{
blockRegistry: NewBlockVolumeRegistry(),
blockAssignmentQueue: NewBlockAssignmentQueue(),
blockFailover: newBlockFailoverState(),
}
callCount := 0
ms.blockVSAllocate = func(ctx context.Context, server string, name string, sizeBytes uint64, diskType string, durabilityMode string) (*blockAllocResult, error) {
callCount++
host := server[:strings.Index(server, ":")]
result := &blockAllocResult{
Path: fmt.Sprintf("/data/%s.blk", name),
IQN: fmt.Sprintf("iqn.2024.test:%s", name),
ISCSIAddr: host + ":3260",
ReplicaDataAddr: host + ":14260",
ReplicaCtrlAddr: host + ":14261",
RebuildListenAddr: host + ":15000",
}
// Only the first server (primary) has NVMe. Replica doesn't.
if callCount == 1 {
result.NvmeAddr = host + ":4420"
result.NQN = fmt.Sprintf("nqn.2024-01.com.seaweedfs:vol.%s", name)
}
return result, nil
}
ms.blockVSDelete = func(ctx context.Context, server string, name string) error {
return nil
}
ms.blockRegistry.MarkBlockCapable("nvme-vs:9333")
ms.blockRegistry.MarkBlockCapable("iscsi-vs:9333")
ctx := context.Background()
resp, err := ms.CreateBlockVolume(ctx, &master_pb.CreateBlockVolumeRequest{
Name: "pvc-mixed",
SizeBytes: 10 << 30,
})
if err != nil {
t.Fatalf("Create: %v", err)
}
// Primary was picked by PickServer (fewest volumes), should have NVMe.
lookupResp, err := ms.LookupBlockVolume(ctx, &master_pb.LookupBlockVolumeRequest{Name: "pvc-mixed"})
if err != nil {
t.Fatalf("Lookup: %v", err)
}
// In all cases, iSCSI should be available.
if lookupResp.IscsiAddr == "" {
t.Fatal("iSCSI addr must always be present")
}
// NVMe may or may not be present depending on which server was picked.
if lookupResp.NvmeAddr != "" {
t.Logf("Primary %s has NVMe: addr=%s nqn=%s", resp.VolumeServer, lookupResp.NvmeAddr, lookupResp.Nqn)
if lookupResp.Nqn == "" {
t.Fatal("if NvmeAddr is set, NQN must also be set")
}
} else {
t.Logf("Primary %s is iSCSI-only: iscsi=%s iqn=%s", resp.VolumeServer, lookupResp.IscsiAddr, lookupResp.Iqn)
}
}
// TestIntegration_NVMe_VolumeServerHeartbeatCollector tests the volume server
// side: CollectBlockVolumeHeartbeat populates NvmeAddr and NQN when NVMe
// is enabled on the BlockService.
func TestIntegration_NVMe_VolumeServerHeartbeatCollector(t *testing.T) {
dir := t.TempDir()
blockDir := dir + "/blocks"
os.MkdirAll(blockDir, 0755)
// Start BlockService WITH NVMe config.
bs := StartBlockService("127.0.0.1:0", blockDir, "iqn.2024.test:",
"127.0.0.1:3260,1",
NVMeConfig{
Enabled: true,
ListenAddr: "10.0.0.3:4420",
NQNPrefix: "nqn.2024-01.com.seaweedfs:vol.",
})
if bs == nil {
t.Fatal("StartBlockService returned nil")
}
defer bs.Shutdown()
// Create a volume.
_, _, _, err := bs.CreateBlockVol("test-nvme-hb", 4*1024*1024, "ssd", "")
if err != nil {
t.Fatalf("CreateBlockVol: %v", err)
}
// Collect heartbeat.
msgs := bs.CollectBlockVolumeHeartbeat()
if len(msgs) == 0 {
t.Fatal("no heartbeat messages collected")
}
var found bool
for _, msg := range msgs {
if strings.Contains(msg.Path, "test-nvme-hb") {
found = true
if msg.NvmeAddr != "10.0.0.3:4420" {
t.Fatalf("heartbeat NvmeAddr = %q, want 10.0.0.3:4420", msg.NvmeAddr)
}
if !strings.Contains(msg.NQN, "test-nvme-hb") {
t.Fatalf("heartbeat NQN should contain volume name, got %q", msg.NQN)
}
t.Logf("Heartbeat: nvme=%s nqn=%s", msg.NvmeAddr, msg.NQN)
}
}
if !found {
t.Fatal("test-nvme-hb not found in heartbeat messages")
}
}
// TestIntegration_NVMe_VolumeServerNoNvme tests that without NVMe config,
// the heartbeat correctly omits NvmeAddr and NQN.
func TestIntegration_NVMe_VolumeServerNoNvme(t *testing.T) {
dir := t.TempDir()
blockDir := dir + "/blocks"
os.MkdirAll(blockDir, 0755)
// Start BlockService WITHOUT NVMe.
bs := StartBlockService("127.0.0.1:0", blockDir, "iqn.2024.test:",
"127.0.0.1:3260,1", NVMeConfig{})
if bs == nil {
t.Fatal("StartBlockService returned nil")
}
defer bs.Shutdown()
bs.CreateBlockVol("test-no-nvme", 4*1024*1024, "", "")
msgs := bs.CollectBlockVolumeHeartbeat()
for _, msg := range msgs {
if strings.Contains(msg.Path, "test-no-nvme") {
if msg.NvmeAddr != "" {
t.Fatalf("NvmeAddr should be empty without NVMe config, got %q", msg.NvmeAddr)
}
if msg.NQN != "" {
t.Fatalf("NQN should be empty without NVMe config, got %q", msg.NQN)
}
return
}
}
t.Fatal("test-no-nvme not found in heartbeat")
}
// TestIntegration_NVMe_FullLifecycle_K8s simulates the complete K8s PVC lifecycle:
// Admin deploys 3 VS with NVMe → Pod requests PVC → CSI creates via master →
// Pod connects via NVMe/TCP → Primary dies → Failover → CSI re-publishes →
// Pod reconnects to new NVMe target.
func TestIntegration_NVMe_FullLifecycle_K8s(t *testing.T) {
ms := nvmeIntegrationMaster(t)
ctx := context.Background()
// ── Step 1: Admin deployed VS with --block-nvme-addr :4420 ──
// (Simulated by nvmeIntegrationMaster's allocate callback)
// ── Step 2: Pod requests PVC → CSI controller calls master ──
createResp, err := ms.CreateBlockVolume(ctx, &master_pb.CreateBlockVolumeRequest{
Name: "pvc-k8s-data",
SizeBytes: 100 << 30,
})
if err != nil {
t.Fatalf("CreateBlockVolume: %v", err)
}
primaryVS := createResp.VolumeServer
replicaVS := createResp.ReplicaServer
if replicaVS == "" {
t.Fatal("expected replica for HA")
}
t.Logf("Step 2: Created pvc-k8s-data on primary=%s replica=%s", primaryVS, replicaVS)
// ── Step 3: CSI controller passes NVMe details in PublishContext ──
lookupResp, err := ms.LookupBlockVolume(ctx, &master_pb.LookupBlockVolumeRequest{Name: "pvc-k8s-data"})
if err != nil {
t.Fatalf("Lookup: %v", err)
}
if lookupResp.NvmeAddr == "" || lookupResp.Nqn == "" {
t.Fatalf("CSI needs NVMe details: nvmeAddr=%q nqn=%q", lookupResp.NvmeAddr, lookupResp.Nqn)
}
// CSI node plugin would do: nvme connect -t tcp -a <host> -s 4420 -n <nqn>
publishNvmeAddr := lookupResp.NvmeAddr
publishNQN := lookupResp.Nqn
t.Logf("Step 3: CSI publish: nvme=%s nqn=%s", publishNvmeAddr, publishNQN)
// ── Step 4: Confirm assignments (VS heartbeats) ──
entry, _ := ms.blockRegistry.Lookup("pvc-k8s-data")
ms.blockAssignmentQueue.ConfirmFromHeartbeat(primaryVS, []blockvol.BlockVolumeInfoMessage{
{Path: entry.Path, Epoch: 1},
})
replicaPath := ""
if len(entry.Replicas) > 0 {
replicaPath = entry.Replicas[0].Path
} else {
replicaPath = entry.ReplicaPath
}
ms.blockAssignmentQueue.ConfirmFromHeartbeat(replicaVS, []blockvol.BlockVolumeInfoMessage{
{Path: replicaPath, Epoch: 1},
})
// ── Step 5: Primary VS dies ──
ms.blockRegistry.UpdateEntry("pvc-k8s-data", func(e *BlockVolumeEntry) {
e.LastLeaseGrant = time.Now().Add(-1 * time.Minute)
})
ms.failoverBlockVolumes(primaryVS)
entry, _ = ms.blockRegistry.Lookup("pvc-k8s-data")
if entry.VolumeServer == primaryVS {
t.Fatal("failover didn't promote replica")
}
newPrimaryVS := entry.VolumeServer
newPrimaryHost := newPrimaryVS[:strings.Index(newPrimaryVS, ":")]
t.Logf("Step 5: Failover: new primary=%s epoch=%d", newPrimaryVS, entry.Epoch)
// ── Step 6: New primary's heartbeat arrives with NVMe info ──
ms.blockRegistry.UpdateFullHeartbeat(newPrimaryVS, []*master_pb.BlockVolumeInfoMessage{
{
Path: entry.Path,
VolumeSize: 100 << 30,
Epoch: entry.Epoch,
Role: 1,
NvmeAddr: newPrimaryHost + ":4420",
Nqn: "nqn.2024-01.com.seaweedfs:vol.pvc-k8s-data",
},
}, "")
// ── Step 7: CSI re-publishes → node plugin reconnects via NVMe ──
lookupResp, err = ms.LookupBlockVolume(ctx, &master_pb.LookupBlockVolumeRequest{Name: "pvc-k8s-data"})
if err != nil {
t.Fatalf("post-failover Lookup: %v", err)
}
// NVMe target must now point to the NEW primary.
if lookupResp.NvmeAddr == publishNvmeAddr {
t.Fatalf("NvmeAddr still points to dead primary: %q", lookupResp.NvmeAddr)
}
expectedNewNvme := newPrimaryHost + ":4420"
if lookupResp.NvmeAddr != expectedNewNvme {
t.Fatalf("NvmeAddr = %q, want %q (new primary)", lookupResp.NvmeAddr, expectedNewNvme)
}
if lookupResp.Nqn != publishNQN {
// NQN is volume-specific, should be same regardless of which server hosts it.
t.Logf("Note: NQN changed from %q to %q (expected: same across failover)", publishNQN, lookupResp.Nqn)
}
t.Logf("Step 7: CSI re-publish: new nvme=%s nqn=%s", lookupResp.NvmeAddr, lookupResp.Nqn)
// ── Step 8: Cleanup — delete volume ──
_, err = ms.DeleteBlockVolume(ctx, &master_pb.DeleteBlockVolumeRequest{Name: "pvc-k8s-data"})
if err != nil {
t.Fatalf("Delete: %v", err)
}
if _, ok := ms.blockRegistry.Lookup("pvc-k8s-data"); ok {
t.Fatal("volume should be deleted")
}
t.Log("Step 8: Volume deleted")
}
// =============================================================================
// C2: NVMe Toggle on Running VS
//
// Simulates a volume server enabling NVMe, sending heartbeats with NVMe
// fields, then disabling NVMe and sending heartbeats without. Verifies
// that the registry reflects the current state unconditionally.
// =============================================================================
// TestQA_NVMe_ToggleNvmeOnRunningVS tests the primary-side NVMe toggle:
// iSCSI-only → enable NVMe via heartbeat → disable NVMe via heartbeat.
func TestQA_NVMe_ToggleNvmeOnRunningVS(t *testing.T) {
r := NewBlockVolumeRegistry()
// Step 1: Register volume with NvmeAddr="" (iSCSI-only initially).
err := r.Register(&BlockVolumeEntry{
Name: "toggle-vol",
VolumeServer: "vs1:18080",
Path: "/data/toggle-vol.blk",
IQN: "iqn.2024.com.seaweedfs:toggle-vol",
ISCSIAddr: "10.0.0.1:3260",
// NvmeAddr intentionally empty — iSCSI-only at creation.
SizeBytes: 1 << 30,
Epoch: 1,
Role: blockvol.RoleToWire(blockvol.RolePrimary),
Status: StatusActive,
})
if err != nil {
t.Fatalf("Register: %v", err)
}
entry, ok := r.Lookup("toggle-vol")
if !ok {
t.Fatal("toggle-vol not found after Register")
}
if entry.NvmeAddr != "" {
t.Fatalf("initial NvmeAddr should be empty, got %q", entry.NvmeAddr)
}
// Step 2: Heartbeat arrives with NvmeAddr (admin enabled NVMe on VS).
r.UpdateFullHeartbeat("vs1:18080", []*master_pb.BlockVolumeInfoMessage{
{
Path: "/data/toggle-vol.blk",
VolumeSize: 1 << 30,
Epoch: 1,
Role: 1,
NvmeAddr: "10.0.0.1:4420",
Nqn: "nqn.2024-01.com.seaweedfs:toggle-vol",
},
}, "")
entry, _ = r.Lookup("toggle-vol")
if entry.NvmeAddr != "10.0.0.1:4420" {
t.Fatalf("after enable heartbeat: NvmeAddr = %q, want 10.0.0.1:4420", entry.NvmeAddr)
}
if entry.NQN != "nqn.2024-01.com.seaweedfs:toggle-vol" {
t.Fatalf("after enable heartbeat: NQN = %q, want nqn.2024-01.com.seaweedfs:toggle-vol", entry.NQN)
}
// Step 3: Heartbeat arrives with NvmeAddr="" (admin disabled NVMe on VS).
// UpdateFullHeartbeat unconditionally writes NvmeAddr/NQN, so empty clears.
r.UpdateFullHeartbeat("vs1:18080", []*master_pb.BlockVolumeInfoMessage{
{
Path: "/data/toggle-vol.blk",
VolumeSize: 1 << 30,
Epoch: 1,
Role: 1,
// NvmeAddr and Nqn intentionally empty — NVMe disabled.
},
}, "")
entry, _ = r.Lookup("toggle-vol")
if entry.NvmeAddr != "" {
t.Fatalf("after disable heartbeat: NvmeAddr should be empty, got %q", entry.NvmeAddr)
}
if entry.NQN != "" {
t.Fatalf("after disable heartbeat: NQN should be empty, got %q", entry.NQN)
}
// Step 4: Lookup returns empty NvmeAddr after disable — CSI falls back to iSCSI.
entry, ok = r.Lookup("toggle-vol")
if !ok {
t.Fatal("toggle-vol disappeared")
}
if entry.NvmeAddr != "" {
t.Fatalf("Lookup after disable: NvmeAddr = %q, want empty", entry.NvmeAddr)
}
if entry.ISCSIAddr != "10.0.0.1:3260" {
t.Fatalf("iSCSI addr should be preserved: got %q", entry.ISCSIAddr)
}
}
// TestQA_NVMe_ToggleNvmeOnRunningVS_ReplicaSide tests the same toggle behavior
// on a replica: enable NVMe via replica heartbeat → disable via heartbeat.
func TestQA_NVMe_ToggleNvmeOnRunningVS_ReplicaSide(t *testing.T) {
r := NewBlockVolumeRegistry()
// Step 1: Register volume with a replica that has no NvmeAddr.
err := r.Register(&BlockVolumeEntry{
Name: "toggle-replica-vol",
VolumeServer: "primary-vs:18080",
Path: "/data/toggle-replica-vol.blk",
IQN: "iqn.2024.com.seaweedfs:toggle-replica-vol",
ISCSIAddr: "10.0.0.1:3260",
SizeBytes: 1 << 30,
Epoch: 1,
Role: blockvol.RoleToWire(blockvol.RolePrimary),
Status: StatusActive,
LeaseTTL: 30 * time.Second,
WALHeadLSN: 100,
Replicas: []ReplicaInfo{
{
Server: "replica-vs:18080",
Path: "/data/toggle-replica-vol.blk",
IQN: "iqn.2024.com.seaweedfs:toggle-replica-vol-r",
ISCSIAddr: "10.0.0.2:3260",
HealthScore: 1.0,
WALHeadLSN: 100,
LastHeartbeat: time.Now(),
Role: blockvol.RoleToWire(blockvol.RoleReplica),
// NvmeAddr intentionally empty — replica has no NVMe initially.
},
},
})
if err != nil {
t.Fatalf("Register: %v", err)
}
r.mu.Lock()
r.addToServer("replica-vs:18080", "toggle-replica-vol")
r.mu.Unlock()
// Verify replica has no NvmeAddr initially.
entry, _ := r.Lookup("toggle-replica-vol")
if len(entry.Replicas) == 0 {
t.Fatal("expected at least one replica")
}
if entry.Replicas[0].NvmeAddr != "" {
t.Fatalf("initial replica NvmeAddr should be empty, got %q", entry.Replicas[0].NvmeAddr)
}
// Step 2: Replica heartbeat arrives with NvmeAddr (NVMe enabled on replica VS).
r.UpdateFullHeartbeat("replica-vs:18080", []*master_pb.BlockVolumeInfoMessage{
{
Path: "/data/toggle-replica-vol.blk",
VolumeSize: 1 << 30,
Epoch: 1,
Role: uint32(blockvol.RoleToWire(blockvol.RoleReplica)),
HealthScore: 1.0,
WalHeadLsn: 100,
NvmeAddr: "10.0.0.2:4420",
Nqn: "nqn.2024-01.com.seaweedfs:toggle-replica-vol",
},
}, "")
entry, _ = r.Lookup("toggle-replica-vol")
if entry.Replicas[0].NvmeAddr != "10.0.0.2:4420" {
t.Fatalf("after enable heartbeat: replica NvmeAddr = %q, want 10.0.0.2:4420", entry.Replicas[0].NvmeAddr)
}
if entry.Replicas[0].NQN != "nqn.2024-01.com.seaweedfs:toggle-replica-vol" {
t.Fatalf("after enable heartbeat: replica NQN = %q", entry.Replicas[0].NQN)
}
// Step 3: Replica heartbeat arrives without NvmeAddr (NVMe disabled on replica VS).
r.UpdateFullHeartbeat("replica-vs:18080", []*master_pb.BlockVolumeInfoMessage{
{
Path: "/data/toggle-replica-vol.blk",
VolumeSize: 1 << 30,
Epoch: 1,
Role: uint32(blockvol.RoleToWire(blockvol.RoleReplica)),
HealthScore: 1.0,
WalHeadLsn: 100,
// NvmeAddr and Nqn intentionally empty — NVMe disabled.
},
}, "")
entry, _ = r.Lookup("toggle-replica-vol")
if entry.Replicas[0].NvmeAddr != "" {
t.Fatalf("after disable heartbeat: replica NvmeAddr should be empty, got %q", entry.Replicas[0].NvmeAddr)
}
if entry.Replicas[0].NQN != "" {
t.Fatalf("after disable heartbeat: replica NQN should be empty, got %q", entry.Replicas[0].NQN)
}
}
// =============================================================================
// C3: Promotion → Immediate Lookup (race window)
//
// After PromoteBestReplica, the promoted replica's NVMe fields from its
// ReplicaInfo are copied into the entry. This tests three sub-cases:
// (a) Replica had NvmeAddr → Lookup gets it immediately
// (b) Replica had empty NvmeAddr → Lookup returns empty (CSI falls back)
// (c) Heartbeat after promotion fills in NvmeAddr
// =============================================================================
func TestQA_NVMe_PromotionThenImmediateLookup(t *testing.T) {
// Sub-case (a): Replica heartbeated NvmeAddr into ReplicaInfo → promote →
// Lookup returns NvmeAddr immediately (no extra heartbeat needed).
t.Run("ReplicaHasNvme", func(t *testing.T) {
r := NewBlockVolumeRegistry()
// Mark servers as block-capable so promotion Gate 4 (liveness) passes.
r.MarkBlockCapable("dead-primary:18080")
r.MarkBlockCapable("healthy-replica:18080")
err := r.Register(&BlockVolumeEntry{
Name: "promo-nvme-vol",
VolumeServer: "dead-primary:18080",
Path: "/data/promo-nvme-vol.blk",
IQN: "iqn:promo-primary",
ISCSIAddr: "10.0.0.1:3260",
NvmeAddr: "10.0.0.1:4420",
NQN: "nqn:promo-primary",
SizeBytes: 1 << 30,
Epoch: 5,
Role: blockvol.RoleToWire(blockvol.RolePrimary),
Status: StatusActive,
LeaseTTL: 30 * time.Second,
WALHeadLSN: 200,
Replicas: []ReplicaInfo{
{
Server: "healthy-replica:18080",
Path: "/data/promo-nvme-vol.blk",
IQN: "iqn:promo-replica",
ISCSIAddr: "10.0.0.2:3260",
NvmeAddr: "10.0.0.2:4420", // Replica has NVMe!
NQN: "nqn:promo-replica",
HealthScore: 1.0,
WALHeadLSN: 200,
LastHeartbeat: time.Now(),
Role: blockvol.RoleToWire(blockvol.RoleReplica),
},
},
})
if err != nil {
t.Fatalf("Register: %v", err)
}
r.mu.Lock()
r.addToServer("healthy-replica:18080", "promo-nvme-vol")
r.mu.Unlock()
newEpoch, err := r.PromoteBestReplica("promo-nvme-vol")
if err != nil {
t.Fatalf("PromoteBestReplica: %v", err)
}
if newEpoch != 6 {
t.Fatalf("newEpoch = %d, want 6", newEpoch)
}
// Immediate Lookup — no heartbeat needed.
entry, ok := r.Lookup("promo-nvme-vol")
if !ok {
t.Fatal("promo-nvme-vol not found after promotion")
}
if entry.VolumeServer != "healthy-replica:18080" {
t.Fatalf("VolumeServer = %q, want healthy-replica:18080", entry.VolumeServer)
}
// CORRECT behavior: NvmeAddr is available immediately from ReplicaInfo.
if entry.NvmeAddr != "10.0.0.2:4420" {
t.Fatalf("NvmeAddr = %q, want 10.0.0.2:4420 (should be copied from replica)", entry.NvmeAddr)
}
if entry.NQN != "nqn:promo-replica" {
t.Fatalf("NQN = %q, want nqn:promo-replica (should be copied from replica)", entry.NQN)
}
})
// Sub-case (b): Replica ReplicaInfo has empty NvmeAddr (heartbeat not yet
// received or old replica) → promote → Lookup returns empty NvmeAddr →
// CSI falls back to iSCSI. This documents the pre-heartbeat window.
t.Run("ReplicaMissingNvme", func(t *testing.T) {
r := NewBlockVolumeRegistry()
// Mark servers as block-capable so promotion Gate 4 (liveness) passes.
r.MarkBlockCapable("dead-primary:18080")
r.MarkBlockCapable("replica-no-nvme:18080")
err := r.Register(&BlockVolumeEntry{
Name: "promo-nonvme-vol",
VolumeServer: "dead-primary:18080",
Path: "/data/promo-nonvme-vol.blk",
IQN: "iqn:promo2-primary",
ISCSIAddr: "10.0.0.1:3260",
NvmeAddr: "10.0.0.1:4420",
NQN: "nqn:promo2-primary",
SizeBytes: 1 << 30,
Epoch: 5,
Role: blockvol.RoleToWire(blockvol.RolePrimary),
Status: StatusActive,
LeaseTTL: 30 * time.Second,
WALHeadLSN: 200,
Replicas: []ReplicaInfo{
{
Server: "replica-no-nvme:18080",
Path: "/data/promo-nonvme-vol.blk",
IQN: "iqn:promo2-replica",
ISCSIAddr: "10.0.0.3:3260",
// NvmeAddr intentionally empty — replica hasn't heartbeated NVMe.
HealthScore: 1.0,
WALHeadLSN: 200,
LastHeartbeat: time.Now(),
Role: blockvol.RoleToWire(blockvol.RoleReplica),
},
},
})
if err != nil {
t.Fatalf("Register: %v", err)
}
r.mu.Lock()
r.addToServer("replica-no-nvme:18080", "promo-nonvme-vol")
r.mu.Unlock()
_, err = r.PromoteBestReplica("promo-nonvme-vol")
if err != nil {
t.Fatalf("PromoteBestReplica: %v", err)
}
// Immediate Lookup — NvmeAddr should be empty (replica had none).
entry, ok := r.Lookup("promo-nonvme-vol")
if !ok {
t.Fatal("promo-nonvme-vol not found after promotion")
}
if entry.VolumeServer != "replica-no-nvme:18080" {
t.Fatalf("VolumeServer = %q, want replica-no-nvme:18080", entry.VolumeServer)
}
// Pre-heartbeat window: NvmeAddr is empty. CSI must fall back to iSCSI.
if entry.NvmeAddr != "" {
t.Fatalf("NvmeAddr = %q, want empty (replica had no NVMe info)", entry.NvmeAddr)
}
if entry.NQN != "" {
t.Fatalf("NQN = %q, want empty (replica had no NVMe info)", entry.NQN)
}
// iSCSI should still be available for fallback.
if entry.ISCSIAddr != "10.0.0.3:3260" {
t.Fatalf("ISCSIAddr = %q, want 10.0.0.3:3260 (iSCSI fallback)", entry.ISCSIAddr)
}
})
// Sub-case (c): Same as (b) but then heartbeat arrives from the promoted
// server with NvmeAddr → entry updated → Lookup returns it.
// This proves heartbeat fixes the post-promotion race window.
t.Run("HeartbeatFixesPostPromotion", func(t *testing.T) {
r := NewBlockVolumeRegistry()
// Mark servers as block-capable so promotion Gate 4 (liveness) passes.
r.MarkBlockCapable("dead-primary:18080")
r.MarkBlockCapable("promoted-replica:18080")
err := r.Register(&BlockVolumeEntry{
Name: "promo-fix-vol",
VolumeServer: "dead-primary:18080",
Path: "/data/promo-fix-vol.blk",
IQN: "iqn:promo3-primary",
ISCSIAddr: "10.0.0.1:3260",
NvmeAddr: "10.0.0.1:4420",
NQN: "nqn:promo3-primary",
SizeBytes: 1 << 30,
Epoch: 5,
Role: blockvol.RoleToWire(blockvol.RolePrimary),
Status: StatusActive,
LeaseTTL: 30 * time.Second,
WALHeadLSN: 200,
Replicas: []ReplicaInfo{
{
Server: "promoted-replica:18080",
Path: "/data/promo-fix-vol.blk",
IQN: "iqn:promo3-replica",
ISCSIAddr: "10.0.0.4:3260",
// NvmeAddr intentionally empty — pre-heartbeat window.
HealthScore: 1.0,
WALHeadLSN: 200,
LastHeartbeat: time.Now(),
Role: blockvol.RoleToWire(blockvol.RoleReplica),
},
},
})
if err != nil {
t.Fatalf("Register: %v", err)
}
r.mu.Lock()
r.addToServer("promoted-replica:18080", "promo-fix-vol")
r.mu.Unlock()
newEpoch, err := r.PromoteBestReplica("promo-fix-vol")
if err != nil {
t.Fatalf("PromoteBestReplica: %v", err)
}
// Verify NvmeAddr is empty immediately after promotion.
entry, _ := r.Lookup("promo-fix-vol")
if entry.NvmeAddr != "" {
t.Fatalf("NvmeAddr should be empty immediately after promotion, got %q", entry.NvmeAddr)
}
// Heartbeat arrives from the promoted server WITH NvmeAddr.
// This is the fix: the new primary's heartbeat fills in NVMe fields.
r.UpdateFullHeartbeat("promoted-replica:18080", []*master_pb.BlockVolumeInfoMessage{
{
Path: "/data/promo-fix-vol.blk",
VolumeSize: 1 << 30,
Epoch: newEpoch,
Role: 1,
NvmeAddr: "10.0.0.4:4420",
Nqn: "nqn.2024-01.com.seaweedfs:promo-fix-vol",
},
}, "")
// Now Lookup should return the NvmeAddr.
entry, ok := r.Lookup("promo-fix-vol")
if !ok {
t.Fatal("promo-fix-vol not found after heartbeat")
}
if entry.NvmeAddr != "10.0.0.4:4420" {
t.Fatalf("NvmeAddr = %q after heartbeat fix, want 10.0.0.4:4420", entry.NvmeAddr)
}
if entry.NQN != "nqn.2024-01.com.seaweedfs:promo-fix-vol" {
t.Fatalf("NQN = %q after heartbeat fix, want nqn.2024-01.com.seaweedfs:promo-fix-vol", entry.NQN)
}
// Verify the volume server is the promoted replica.
if entry.VolumeServer != "promoted-replica:18080" {
t.Fatalf("VolumeServer = %q, want promoted-replica:18080", entry.VolumeServer)
}
})
}