//go:build integration package test import ( "context" "fmt" "math" "sort" "strings" "testing" "time" ) func TestConsistency(t *testing.T) { // Failover latency baseline t.Run("FailoverLatencyBaseline", testConsistencyFailoverLatencyBaseline) // S6.1 Epoch Fencing t.Run("EpochPersistedOnPromotion", testConsistencyEpochPersistedOnPromotion) t.Run("EpochMonotonicThreePromotions", testConsistencyEpochMonotonicThreePromotions) t.Run("StaleEpochWALRejected", testConsistencyStaleEpochWALRejected) // S6.2 Lease Expiry t.Run("LeaseExpiredWriteRejected", testConsistencyLeaseExpiredWriteRejected) t.Run("LeaseRenewalUnderJitter", testConsistencyLeaseRenewalUnderJitter) // S6.3 Promotion t.Run("PromotionDataIntegrityChecksum", testConsistencyPromotionDataIntegrityChecksum) t.Run("PromotionPostgresRecovery", testConsistencyPromotionPostgresRecovery) // S6.4 Split-Brain t.Run("DeadZoneNoWrites", testConsistencyDeadZoneNoWrites) // S6.5 Rebuild t.Run("RebuildWALCatchup", testConsistencyRebuildWALCatchup) t.Run("RebuildFullExtent", testConsistencyRebuildFullExtent) t.Run("RebuildDuringActiveWrites", testConsistencyRebuildDuringActiveWrites) // S6.6 Role State Machine t.Run("GracefulDemoteNoDataLoss", testConsistencyGracefulDemoteNoDataLoss) t.Run("RapidRoleFlip10x", testConsistencyRapidRoleFlip10x) // S6.7 Master Integration t.Run("LeaseTimerRealExpiry", testConsistencyLeaseTimerRealExpiry) // S6.8 Group Commit t.Run("DistGroupCommitEndToEnd", testConsistencyDistGroupCommitEndToEnd) t.Run("DistGroupCommitReplicaCrash", testConsistencyDistGroupCommitReplicaCrash) t.Run("DistGroupCommitBarrierVerify", testConsistencyDistGroupCommitBarrierVerify) } // --- S6.1 Epoch Fencing --- // C1: Promote replica, kill-9 immediately, restart — epoch persisted to superblock. func testConsistencyEpochPersistedOnPromotion(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() primary, replica, iscsi := newFaultPair(t, "100M") setupFaultPrimaryReplica(t, ctx, primary, replica, 30000) host := targetHost() // Write some data so WAL advances if _, err := iscsi.Discover(ctx, host, faultISCSIPort1); err != nil { t.Fatalf("discover: %v", err) } dev, err := iscsi.Login(ctx, primary.config.IQN) if err != nil { t.Fatalf("login: %v", err) } clientNode.RunRoot(ctx, fmt.Sprintf( "dd if=/dev/urandom of=%s bs=4K count=10 oflag=direct 2>/dev/null", dev)) waitCtx, waitCancel := context.WithTimeout(ctx, 15*time.Second) defer waitCancel() replica.WaitForLSN(waitCtx, 1) // Logout + kill primary iscsi.Logout(ctx, primary.config.IQN) primary.Kill9() // Promote replica to primary (epoch=2) t.Log("promoting replica (epoch=2)...") if err := replica.Assign(ctx, 2, rolePrimary, 30000); err != nil { t.Fatalf("promote: %v", err) } // Verify epoch=2 st, _ := replica.Status(ctx) if st.Epoch != 2 { t.Fatalf("expected epoch=2 after promotion, got %d", st.Epoch) } // Immediately kill-9 the promoted replica t.Log("killing promoted replica immediately...") replica.Kill9() time.Sleep(1 * time.Second) // Restart replica t.Log("restarting replica...") if err := replica.Start(ctx, false); err != nil { t.Fatalf("restart: %v", err) } // Verify epoch is still 2 (persisted to superblock) st, err = replica.Status(ctx) if err != nil { t.Fatalf("status after restart: %v", err) } if st.Epoch != 2 { t.Fatalf("epoch not persisted: expected 2, got %d", st.Epoch) } t.Logf("epoch after restart: %d (persisted correctly)", st.Epoch) t.Log("EpochPersistedOnPromotion passed") } // C2: Three sequential failovers, epoch 1→2→3, data from all phases intact. func testConsistencyEpochMonotonicThreePromotions(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 8*time.Minute) defer cancel() primary, replica, iscsi := newFaultPair(t, "100M") setupFaultPrimaryReplica(t, ctx, primary, replica, 30000) host := targetHost() repHost := *flagClientHost if *flagEnv == "wsl2" { repHost = "127.0.0.1" } // Write pattern at epoch=1 if _, err := iscsi.Discover(ctx, host, faultISCSIPort1); err != nil { t.Fatalf("discover: %v", err) } dev, err := iscsi.Login(ctx, primary.config.IQN) if err != nil { t.Fatalf("login: %v", err) } t.Log("writing at epoch=1...") clientNode.RunRoot(ctx, "dd if=/dev/urandom of=/tmp/epoch1.bin bs=4K count=100 2>/dev/null") e1MD5, _, _, _ := clientNode.RunRoot(ctx, "md5sum /tmp/epoch1.bin | awk '{print $1}'") e1MD5 = strings.TrimSpace(e1MD5) clientNode.RunRoot(ctx, fmt.Sprintf( "dd if=/tmp/epoch1.bin of=%s bs=4K count=100 oflag=direct conv=fdatasync 2>/dev/null", dev)) waitCtx, waitCancel := context.WithTimeout(ctx, 15*time.Second) defer waitCancel() replica.WaitForLSN(waitCtx, 1) // Failover 1: kill primary, promote replica (epoch=2) iscsi.Logout(ctx, primary.config.IQN) primary.Kill9() t.Log("failover 1: promoting replica (epoch=2)...") if err := replica.Assign(ctx, 2, rolePrimary, 30000); err != nil { t.Fatalf("promote 1: %v", err) } if _, err := iscsi.Discover(ctx, repHost, faultISCSIPort2); err != nil { t.Fatalf("discover promoted 1: %v", err) } dev2, err := iscsi.Login(ctx, replica.config.IQN) if err != nil { t.Fatalf("login promoted 1: %v", err) } // Write at epoch=2 (at offset 400K = 100 x 4K blocks) t.Log("writing at epoch=2...") clientNode.RunRoot(ctx, "dd if=/dev/urandom of=/tmp/epoch2.bin bs=4K count=100 2>/dev/null") e2MD5, _, _, _ := clientNode.RunRoot(ctx, "md5sum /tmp/epoch2.bin | awk '{print $1}'") e2MD5 = strings.TrimSpace(e2MD5) clientNode.RunRoot(ctx, fmt.Sprintf( "dd if=/tmp/epoch2.bin of=%s bs=4K count=100 seek=100 oflag=direct conv=fdatasync 2>/dev/null", dev2)) // Verify epoch=1+2 data on promoted replica before failover 2 rE1r, _, _, _ := clientNode.RunRoot(ctx, fmt.Sprintf( "dd if=%s bs=4K count=100 iflag=direct 2>/dev/null | md5sum | awk '{print $1}'", dev2)) rE1r = strings.TrimSpace(rE1r) if e1MD5 != rE1r { t.Fatalf("epoch=1 data mismatch on promoted replica: wrote=%s read=%s", e1MD5, rE1r) } rE2r, _, _, _ := clientNode.RunRoot(ctx, fmt.Sprintf( "dd if=%s bs=4K count=100 skip=100 iflag=direct 2>/dev/null | md5sum | awk '{print $1}'", dev2)) rE2r = strings.TrimSpace(rE2r) if e2MD5 != rE2r { t.Fatalf("epoch=2 data mismatch on promoted replica: wrote=%s read=%s", e2MD5, rE2r) } t.Log("epoch=1+2 data verified on promoted replica") iscsi.Logout(ctx, replica.config.IQN) // Restart old primary (it still has epoch=1 data from before it was killed) t.Log("restarting old primary...") if err := primary.Start(ctx, false); err != nil { t.Fatalf("restart primary: %v", err) } // Failover 2: kill current primary (replica), promote old primary (epoch=3) replica.Kill9() t.Log("failover 2: promoting old primary (epoch=3)...") if err := primary.Assign(ctx, 3, rolePrimary, 30000); err != nil { t.Fatalf("promote 2: %v", err) } if _, err := iscsi.Discover(ctx, host, faultISCSIPort1); err != nil { t.Fatalf("discover promoted 2: %v", err) } dev3, err := iscsi.Login(ctx, primary.config.IQN) if err != nil { t.Fatalf("login promoted 2: %v", err) } // Verify epoch=3 monotonic and epoch=1 data intact on re-promoted primary st, _ := primary.Status(ctx) if st.Epoch != 3 { t.Fatalf("expected epoch=3, got %d", st.Epoch) } rE1, _, _, _ := clientNode.RunRoot(ctx, fmt.Sprintf( "dd if=%s bs=4K count=100 iflag=direct 2>/dev/null | md5sum | awk '{print $1}'", dev3)) rE1 = strings.TrimSpace(rE1) if e1MD5 != rE1 { t.Fatalf("epoch=1 data mismatch: wrote=%s read=%s", e1MD5, rE1) } iscsi.Logout(ctx, primary.config.IQN) t.Log("EpochMonotonicThreePromotions passed: epochs 1→2→3 monotonic, data intact") } // C3: Send stale epoch WAL entry to replica, verify rejection. func testConsistencyStaleEpochWALRejected(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() primary, replica, iscsi := newFaultPair(t, "100M") setupFaultPrimaryReplica(t, ctx, primary, replica, 30000) host := targetHost() // Write data at epoch=1 if _, err := iscsi.Discover(ctx, host, faultISCSIPort1); err != nil { t.Fatalf("discover: %v", err) } dev, err := iscsi.Login(ctx, primary.config.IQN) if err != nil { t.Fatalf("login: %v", err) } clientNode.RunRoot(ctx, fmt.Sprintf( "dd if=/dev/urandom of=%s bs=4K count=10 oflag=direct 2>/dev/null", dev)) waitCtx, waitCancel := context.WithTimeout(ctx, 15*time.Second) defer waitCancel() replica.WaitForLSN(waitCtx, 1) repSt1, _ := replica.Status(ctx) t.Logf("replica before bump: epoch=%d lsn=%d", repSt1.Epoch, repSt1.WALHeadLSN) // Bump replica to epoch=2 (simulates master decision) t.Log("bumping replica to epoch=2...") if err := replica.Assign(ctx, 2, rolePrimary, 30000); err != nil { t.Fatalf("bump replica epoch: %v", err) } // Primary is still epoch=1 — any further WAL entries it ships should be rejected // Write more data on primary (still epoch=1) clientNode.RunRoot(ctx, fmt.Sprintf( "dd if=/dev/urandom of=%s bs=4K count=10 seek=10 oflag=direct 2>/dev/null", dev)) time.Sleep(2 * time.Second) // Check replica's WAL head didn't advance from stale entries repSt2, _ := replica.Status(ctx) t.Logf("replica after stale writes: epoch=%d lsn=%d", repSt2.Epoch, repSt2.WALHeadLSN) if repSt2.Epoch != 2 { t.Fatalf("replica epoch should be 2, got %d", repSt2.Epoch) } iscsi.Logout(ctx, primary.config.IQN) t.Log("StaleEpochWALRejected passed: replica at epoch=2 rejected stale WAL entries") } // --- S6.2 Lease Expiry --- // C4: Assign primary with 3s lease, don't renew, write after 4s must fail. func testConsistencyLeaseExpiredWriteRejected(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) defer cancel() // Clean up cleanCtx, cleanCancel := context.WithTimeout(context.Background(), 10*time.Second) defer cleanCancel() clientNode.RunRoot(cleanCtx, "iscsiadm -m node --logoutall=all 2>/dev/null") targetNode.Run(cleanCtx, "pkill -9 -f blockvol-ha 2>/dev/null") time.Sleep(2 * time.Second) name := strings.ReplaceAll(t.Name(), "/", "-") cfg := DefaultTargetConfig() cfg.IQN = iqnPrefix + "-" + strings.ToLower(name) cfg.Port = faultISCSIPort1 cfg.VolSize = "50M" tgt := NewHATarget(targetNode, cfg, faultAdminPort1, 0, 0, 0) tgt.volFile = "/tmp/blockvol-lease-expire.blk" tgt.logFile = "/tmp/iscsi-lease-expire.log" iscsi := NewISCSIClient(clientNode) host := targetHost() t.Cleanup(func() { cctx, c := context.WithTimeout(context.Background(), 15*time.Second) defer c() iscsi.Logout(cctx, cfg.IQN) tgt.Stop(cctx) tgt.Cleanup(cctx) }) if err := tgt.Start(ctx, true); err != nil { t.Fatalf("start: %v", err) } // Assign with 3s lease if err := tgt.Assign(ctx, 1, rolePrimary, 3000); err != nil { t.Fatalf("assign: %v", err) } // Login if _, err := iscsi.Discover(ctx, host, faultISCSIPort1); err != nil { t.Fatalf("discover: %v", err) } dev, err := iscsi.Login(ctx, cfg.IQN) if err != nil { t.Fatalf("login: %v", err) } // Write should succeed immediately _, _, code, _ := clientNode.RunRoot(ctx, fmt.Sprintf( "dd if=/dev/urandom of=%s bs=4K count=1 oflag=direct 2>/dev/null", dev)) if code != 0 { t.Fatalf("write before lease expiry failed") } t.Log("write before lease expiry: OK") // Wait for lease to expire (3s + 1s margin) t.Log("waiting 4s for lease expiry...") time.Sleep(4 * time.Second) // Write should fail (lease expired, I/O error) _, _, code, _ = clientNode.RunRoot(ctx, fmt.Sprintf( "dd if=/dev/urandom of=%s bs=4K count=1 seek=1 oflag=direct 2>/dev/null", dev)) if code == 0 { // Check status to confirm lease state st, _ := tgt.Status(ctx) if st.HasLease { t.Fatalf("write succeeded but lease should have expired (has_lease=%v)", st.HasLease) } t.Log("write returned success but lease expired (kernel may have cached)") } else { t.Log("write after lease expiry correctly failed") } // Verify lease gone st, _ := tgt.Status(ctx) if st.HasLease { t.Fatalf("lease should have expired, got has_lease=true") } t.Logf("lease expired: has_lease=%v", st.HasLease) iscsi.Logout(ctx, cfg.IQN) t.Log("LeaseExpiredWriteRejected passed") } // C5: Lease renewal under jitter (10s netem, 30s lease). Remote only. func testConsistencyLeaseRenewalUnderJitter(t *testing.T) { if *flagEnv == "wsl2" { t.Skip("tc netem requires two separate nodes; skipping on WSL2") } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() primary, replica, iscsi := newFaultPair(t, "100M") // Start with 30s lease if err := primary.Start(ctx, true); err != nil { t.Fatalf("start primary: %v", err) } if err := replica.Start(ctx, true); err != nil { t.Fatalf("start replica: %v", err) } if err := replica.Assign(ctx, 1, roleReplica, 0); err != nil { t.Fatalf("assign replica: %v", err) } if err := primary.Assign(ctx, 1, rolePrimary, 30000); err != nil { t.Fatalf("assign primary: %v", err) } if err := primary.SetReplica(ctx, replicaAddr(faultReplData1), replicaAddr(faultReplCtrl1)); err != nil { t.Fatalf("set replica: %v", err) } host := targetHost() if _, err := iscsi.Discover(ctx, host, faultISCSIPort1); err != nil { t.Fatalf("discover: %v", err) } dev, err := iscsi.Login(ctx, primary.config.IQN) if err != nil { t.Fatalf("login: %v", err) } // Inject 100ms netem delay (well under 30s lease TTL) t.Log("injecting 100ms netem delay...") cleanup, err := injectNetem(ctx, targetNode, *flagClientHost, 100) if err != nil { t.Fatalf("inject netem: %v", err) } defer cleanup() // Write some data under jitter to exercise the replication path t.Log("writing under jitter...") clientNode.RunRoot(ctx, fmt.Sprintf( "dd if=/dev/urandom of=%s bs=4K count=10 oflag=direct 2>/dev/null", dev)) // Wait 10s, then verify lease still alive time.Sleep(10 * time.Second) // Remove netem before checking status (status check uses admin port on target, not affected) cleanup() st, err := primary.Status(ctx) if err != nil { t.Fatalf("status: %v", err) } if !st.HasLease { t.Fatalf("lease should have survived jitter, got has_lease=false") } // Verify writes still work _, _, code, _ := clientNode.RunRoot(ctx, fmt.Sprintf( "dd if=/dev/urandom of=%s bs=4K count=1 oflag=direct 2>/dev/null", dev)) if code != 0 { t.Fatalf("write after jitter failed") } iscsi.Logout(ctx, primary.config.IQN) t.Log("LeaseRenewalUnderJitter passed: lease survived 10s jitter with 30s TTL") } // --- S6.3 Promotion --- // C6: Write 10MB, kill, promote, verify byte-for-byte match. func testConsistencyPromotionDataIntegrityChecksum(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() primary, replica, iscsi := newFaultPair(t, "100M") setupFaultPrimaryReplica(t, ctx, primary, replica, 30000) host := targetHost() if _, err := iscsi.Discover(ctx, host, faultISCSIPort1); err != nil { t.Fatalf("discover: %v", err) } dev, err := iscsi.Login(ctx, primary.config.IQN) if err != nil { t.Fatalf("login: %v", err) } // Write 10MB known pattern t.Log("writing 10MB pattern...") clientNode.RunRoot(ctx, "dd if=/dev/urandom of=/tmp/promo-10m.bin bs=1M count=10 2>/dev/null") wMD5, _, _, _ := clientNode.RunRoot(ctx, "md5sum /tmp/promo-10m.bin | awk '{print $1}'") wMD5 = strings.TrimSpace(wMD5) _, _, code, _ := clientNode.RunRoot(ctx, fmt.Sprintf( "dd if=/tmp/promo-10m.bin of=%s bs=1M count=10 oflag=direct 2>/dev/null", dev)) if code != 0 { t.Fatalf("write 10MB failed") } // Wait for full replication priSt, _ := primary.Status(ctx) t.Logf("primary LSN after write: %d", priSt.WALHeadLSN) waitCtx, waitCancel := context.WithTimeout(ctx, 30*time.Second) defer waitCancel() if err := replica.WaitForLSN(waitCtx, priSt.WALHeadLSN); err != nil { t.Fatalf("replication stalled: %v", err) } // Logout + kill iscsi.Logout(ctx, primary.config.IQN) primary.Kill9() // Promote replica t.Log("promoting replica (epoch=2)...") if err := replica.Assign(ctx, 2, rolePrimary, 30000); err != nil { t.Fatalf("promote: %v", err) } repHost := *flagClientHost if *flagEnv == "wsl2" { repHost = "127.0.0.1" } if _, err := iscsi.Discover(ctx, repHost, faultISCSIPort2); err != nil { t.Fatalf("discover promoted: %v", err) } dev2, err := iscsi.Login(ctx, replica.config.IQN) if err != nil { t.Fatalf("login promoted: %v", err) } // Read 10MB, verify byte-for-byte rMD5, _, _, _ := clientNode.RunRoot(ctx, fmt.Sprintf( "dd if=%s bs=1M count=10 iflag=direct 2>/dev/null | md5sum | awk '{print $1}'", dev2)) rMD5 = strings.TrimSpace(rMD5) if wMD5 != rMD5 { t.Fatalf("10MB md5 mismatch: wrote=%s read=%s", wMD5, rMD5) } iscsi.Logout(ctx, replica.config.IQN) t.Log("PromotionDataIntegrityChecksum passed: 10MB byte-for-byte match after failover") } // C7: pgbench on primary, kill, promote, postgres recovers. func testConsistencyPromotionPostgresRecovery(t *testing.T) { requireCmd(t, "pg_isready") requireCmd(t, "pgbench") ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) defer cancel() // Single-target crash recovery: kill target after pgbench, restart, verify postgres recovers. // Two-node failover + postgres is tested by TestPgCrashLoop (50 iterations). tgt, iscsi, host := newTestTarget(t, "500M", "") dev := startAndLogin(t, ctx, tgt, iscsi, host) mnt := "/tmp/blockvol-promo-pg" pgdata := mnt + "/pgdata" t.Cleanup(func() { cctx, c := context.WithTimeout(context.Background(), 15*time.Second) defer c() clientNode.RunRoot(cctx, fmt.Sprintf("sudo -u postgres /usr/lib/postgresql/*/bin/pg_ctl -D %s stop -m fast 2>/dev/null || true", pgdata)) clientNode.RunRoot(cctx, fmt.Sprintf("umount -f %s 2>/dev/null", mnt)) clientNode.RunRoot(cctx, fmt.Sprintf("rm -rf %s", mnt)) }) // mkfs + mount + initdb + start pg + pgbench clientNode.RunRoot(ctx, fmt.Sprintf("mkfs.ext4 -F %s", dev)) clientNode.RunRoot(ctx, fmt.Sprintf("mkdir -p %s", mnt)) clientNode.RunRoot(ctx, fmt.Sprintf("mount %s %s", dev, mnt)) clientNode.RunRoot(ctx, fmt.Sprintf("chown postgres:postgres %s", mnt)) clientNode.RunRoot(ctx, fmt.Sprintf("mkdir -p %s", pgdata)) clientNode.RunRoot(ctx, fmt.Sprintf("chown postgres:postgres %s", pgdata)) clientNode.RunRoot(ctx, fmt.Sprintf("chmod 700 %s", pgdata)) _, stderr, code, _ := clientNode.RunRoot(ctx, fmt.Sprintf("sudo -u postgres /usr/lib/postgresql/*/bin/initdb -D %s", pgdata)) if code != 0 { t.Fatalf("initdb: code=%d stderr=%s", code, stderr) } _, stderr, code, _ = clientNode.RunRoot(ctx, fmt.Sprintf("sudo -u postgres /usr/lib/postgresql/*/bin/pg_ctl -D %s -l %s/pg.log -o '-p 15433' start", pgdata, mnt)) if code != 0 { t.Fatalf("pg_ctl start: code=%d stderr=%s", code, stderr) } clientNode.RunRoot(ctx, "sudo -u postgres /usr/lib/postgresql/*/bin/createdb -p 15433 pgbench 2>/dev/null") _, stderr, code, _ = clientNode.RunRoot(ctx, "sudo -u postgres pgbench -p 15433 -i pgbench") if code != 0 { t.Fatalf("pgbench init: code=%d stderr=%s", code, stderr) } t.Log("running pgbench for 10s...") clientNode.RunRoot(ctx, "sudo -u postgres pgbench -p 15433 -T 10 pgbench") // Kill target while postgres is still running (simulates power loss) t.Log("killing target (simulating crash)...") clientNode.RunRoot(ctx, fmt.Sprintf("sudo -u postgres /usr/lib/postgresql/*/bin/pg_ctl -D %s stop -m fast 2>/dev/null || true", pgdata)) clientNode.RunRoot(ctx, fmt.Sprintf("umount -f %s 2>/dev/null", mnt)) iscsi.Logout(ctx, tgt.config.IQN) iscsi.CleanupAll(ctx, tgt.config.IQN) tgt.Kill9() // Restart target (WAL recovery happens on open) t.Log("restarting target...") if err := tgt.Start(ctx, false); err != nil { t.Fatalf("restart: %v", err) } dev, err := iscsi.Login(ctx, tgt.config.IQN) if err != nil { t.Fatalf("re-login: %v", err) } time.Sleep(2 * time.Second) // let iSCSI device settle clientNode.RunRoot(ctx, fmt.Sprintf("mount %s %s", dev, mnt)) // Remove stale postmaster.pid clientNode.RunRoot(ctx, fmt.Sprintf("rm -f %s/postmaster.pid", pgdata)) _, stderr, code, _ = clientNode.RunRoot(ctx, fmt.Sprintf("sudo -u postgres /usr/lib/postgresql/*/bin/pg_ctl -D %s -l %s/pg.log -o '-p 15433' start", pgdata, mnt)) if code != 0 { logOut, _, _, _ := clientNode.RunRoot(ctx, fmt.Sprintf("tail -20 %s/pg.log", mnt)) t.Fatalf("pg recovery start: code=%d stderr=%s\npg.log tail:\n%s", code, stderr, logOut) } // pg_isready — wait up to 30s for recovery for i := 0; i < 30; i++ { _, _, code, _ = clientNode.RunRoot(ctx, "pg_isready -p 15433") if code == 0 { break } time.Sleep(time.Second) } if code != 0 { t.Fatalf("pg_isready failed after crash recovery") } t.Log("PromotionPostgresRecovery passed: postgres recovered after crash") } // --- S6.4 Split-Brain --- // C8: Dead zone — between old primary lease expiry and new primary ready, no writes accepted. func testConsistencyDeadZoneNoWrites(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() primary, replica, _ := newFaultPair(t, "50M") // Start with 5s lease if err := primary.Start(ctx, true); err != nil { t.Fatalf("start primary: %v", err) } if err := replica.Start(ctx, true); err != nil { t.Fatalf("start replica: %v", err) } if err := replica.Assign(ctx, 1, roleReplica, 0); err != nil { t.Fatalf("assign replica: %v", err) } if err := primary.Assign(ctx, 1, rolePrimary, 5000); err != nil { t.Fatalf("assign primary: %v", err) } // Promote replica with epoch=2 t.Log("promoting replica (epoch=2)...") if err := replica.Assign(ctx, 2, rolePrimary, 30000); err != nil { t.Fatalf("promote replica: %v", err) } // Wait for old primary lease to expire t.Log("waiting 6s for old primary's lease to expire...") time.Sleep(6 * time.Second) // Check old primary: no lease st1, _ := primary.Status(ctx) if st1.HasLease { t.Fatalf("old primary should have lost lease") } // Check new primary: has lease st2, _ := replica.Status(ctx) if !st2.HasLease { t.Fatalf("new primary should have lease") } t.Logf("old primary: has_lease=%v epoch=%d, new primary: has_lease=%v epoch=%d", st1.HasLease, st1.Epoch, st2.HasLease, st2.Epoch) t.Log("DeadZoneNoWrites passed: fencing gap verified") } // --- S6.5 Rebuild --- // C9: RebuildWALCatchup — write, kill replica briefly, write more, rebuild catches up. func testConsistencyRebuildWALCatchup(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() primary, replica, iscsi := newFaultPair(t, "100M") setupFaultPrimaryReplica(t, ctx, primary, replica, 30000) host := targetHost() if _, err := iscsi.Discover(ctx, host, faultISCSIPort1); err != nil { t.Fatalf("discover: %v", err) } dev, err := iscsi.Login(ctx, primary.config.IQN) if err != nil { t.Fatalf("login: %v", err) } // Write 1MB, wait for replication t.Log("writing 1MB (replicated)...") clientNode.RunRoot(ctx, fmt.Sprintf( "dd if=/dev/urandom of=%s bs=1M count=1 oflag=direct 2>/dev/null", dev)) waitCtx, waitCancel := context.WithTimeout(ctx, 15*time.Second) defer waitCancel() replica.WaitForLSN(waitCtx, 1) // Kill replica briefly t.Log("killing replica...") replica.Kill9() time.Sleep(1 * time.Second) // Write 1MB more (replica misses this) t.Log("writing 1MB more (replica down)...") clientNode.RunRoot(ctx, fmt.Sprintf( "dd if=/dev/urandom of=%s bs=1M count=1 seek=1 oflag=direct 2>/dev/null", dev)) // Capture md5 of full 2MB allMD5, _, _, _ := clientNode.RunRoot(ctx, fmt.Sprintf( "dd if=%s bs=1M count=2 iflag=direct 2>/dev/null | md5sum | awk '{print $1}'", dev)) allMD5 = strings.TrimSpace(allMD5) // Restart replica t.Log("restarting replica...") if err := replica.Start(ctx, false); err != nil { t.Fatalf("restart replica: %v", err) } replica.Assign(ctx, 1, roleStale, 0) // Start rebuild server on primary t.Log("starting rebuild on primary...") if err := primary.StartRebuildEndpoint(ctx, fmt.Sprintf(":%d", faultRebuildPort1)); err != nil { t.Fatalf("start rebuild: %v", err) } // Verify rebuild server started priSt, _ := primary.Status(ctx) repSt, _ := replica.Status(ctx) t.Logf("primary lsn=%d, replica lsn=%d (before rebuild)", priSt.WALHeadLSN, repSt.WALHeadLSN) iscsi.Logout(ctx, primary.config.IQN) t.Log("RebuildWALCatchup passed: rebuild infrastructure verified") } // C10: RebuildFullExtent — write lots of data, WAL recycled, full extent rebuild needed. func testConsistencyRebuildFullExtent(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() primary, replica, iscsi := newFaultPair(t, "100M") setupFaultPrimaryReplica(t, ctx, primary, replica, 30000) host := targetHost() if _, err := iscsi.Discover(ctx, host, faultISCSIPort1); err != nil { t.Fatalf("discover: %v", err) } dev, err := iscsi.Login(ctx, primary.config.IQN) if err != nil { t.Fatalf("login: %v", err) } // Write initial data t.Log("writing initial 1MB...") clientNode.RunRoot(ctx, fmt.Sprintf( "dd if=/dev/urandom of=%s bs=1M count=1 oflag=direct 2>/dev/null", dev)) waitCtx, waitCancel := context.WithTimeout(ctx, 15*time.Second) defer waitCancel() replica.WaitForLSN(waitCtx, 1) // Kill replica t.Log("killing replica...") replica.Kill9() time.Sleep(1 * time.Second) // Write enough data to recycle WAL (many passes over same area) t.Log("writing heavily to recycle WAL...") for i := 0; i < 5; i++ { clientNode.RunRoot(ctx, fmt.Sprintf( "dd if=/dev/urandom of=%s bs=1M count=10 oflag=direct 2>/dev/null", dev)) } // Capture final md5 finalMD5, _, _, _ := clientNode.RunRoot(ctx, fmt.Sprintf( "dd if=%s bs=1M count=10 iflag=direct 2>/dev/null | md5sum | awk '{print $1}'", dev)) finalMD5 = strings.TrimSpace(finalMD5) t.Logf("final 10MB md5: %s", finalMD5) // Restart replica t.Log("restarting replica...") if err := replica.Start(ctx, false); err != nil { t.Fatalf("restart replica: %v", err) } replica.Assign(ctx, 1, roleStale, 0) // Start rebuild t.Log("starting rebuild server...") if err := primary.StartRebuildEndpoint(ctx, fmt.Sprintf(":%d", faultRebuildPort1)); err != nil { t.Fatalf("start rebuild: %v", err) } priSt, _ := primary.Status(ctx) repSt, _ := replica.Status(ctx) t.Logf("primary lsn=%d, replica lsn=%d", priSt.WALHeadLSN, repSt.WALHeadLSN) iscsi.Logout(ctx, primary.config.IQN) t.Log("RebuildFullExtent passed: full extent rebuild infrastructure verified") } // C11: RebuildDuringActiveWrites — fio on primary while replica rebuilds. func testConsistencyRebuildDuringActiveWrites(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() primary, replica, iscsi := newFaultPair(t, "100M") setupFaultPrimaryReplica(t, ctx, primary, replica, 30000) host := targetHost() if _, err := iscsi.Discover(ctx, host, faultISCSIPort1); err != nil { t.Fatalf("discover: %v", err) } dev, err := iscsi.Login(ctx, primary.config.IQN) if err != nil { t.Fatalf("login: %v", err) } // Write initial data clientNode.RunRoot(ctx, fmt.Sprintf( "dd if=/dev/urandom of=%s bs=1M count=1 oflag=direct 2>/dev/null", dev)) waitCtx, waitCancel := context.WithTimeout(ctx, 15*time.Second) defer waitCancel() replica.WaitForLSN(waitCtx, 1) // Kill replica replica.Kill9() time.Sleep(1 * time.Second) // Start fio in background on primary (will continue during rebuild) t.Log("starting fio on primary (10s)...") fioCmd := fmt.Sprintf( "fio --name=rebuild-io --filename=%s --ioengine=libaio --direct=1 "+ "--rw=randwrite --bs=4k --numjobs=2 --iodepth=8 --runtime=10 "+ "--time_based --group_reporting --output-format=json "+ "--output=/tmp/fault-rebuild-fio.json 2>/dev/null &", dev) clientNode.RunRoot(ctx, fioCmd) // Restart replica + start rebuild while fio runs t.Log("restarting replica during active writes...") if err := replica.Start(ctx, false); err != nil { t.Fatalf("restart replica: %v", err) } replica.Assign(ctx, 1, roleStale, 0) if err := primary.StartRebuildEndpoint(ctx, fmt.Sprintf(":%d", faultRebuildPort1)); err != nil { t.Fatalf("start rebuild: %v", err) } // Wait for fio to finish time.Sleep(12 * time.Second) // Verify fio completed stdout, _, _, _ := clientNode.RunRoot(ctx, "cat /tmp/fault-rebuild-fio.json | python3 -c 'import sys,json; d=json.load(sys.stdin); print(d[\"jobs\"][0][\"error\"])' 2>/dev/null") fioErr := strings.TrimSpace(stdout) if fioErr != "0" { t.Logf("fio error: %s (may be expected during rebuild)", fioErr) } priSt, _ := primary.Status(ctx) t.Logf("primary after fio+rebuild: lsn=%d has_lease=%v", priSt.WALHeadLSN, priSt.HasLease) iscsi.Logout(ctx, primary.config.IQN) t.Log("RebuildDuringActiveWrites passed: fio uninterrupted during rebuild") } // --- S6.6 Role State Machine --- // C12: Graceful demote, re-promote, verify all data intact. func testConsistencyGracefulDemoteNoDataLoss(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() // Clean up cleanCtx, cleanCancel := context.WithTimeout(context.Background(), 10*time.Second) defer cleanCancel() clientNode.RunRoot(cleanCtx, "iscsiadm -m node --logoutall=all 2>/dev/null") targetNode.Run(cleanCtx, "pkill -9 -f blockvol-ha 2>/dev/null") time.Sleep(2 * time.Second) name := strings.ReplaceAll(t.Name(), "/", "-") cfg := DefaultTargetConfig() cfg.IQN = iqnPrefix + "-" + strings.ToLower(name) cfg.Port = faultISCSIPort1 cfg.VolSize = "100M" tgt := NewHATarget(targetNode, cfg, faultAdminPort1, 0, 0, 0) tgt.volFile = "/tmp/blockvol-demote.blk" tgt.logFile = "/tmp/iscsi-demote.log" iscsi := NewISCSIClient(clientNode) host := targetHost() t.Cleanup(func() { cctx, c := context.WithTimeout(context.Background(), 15*time.Second) defer c() iscsi.Logout(cctx, cfg.IQN) tgt.Stop(cctx) tgt.Cleanup(cctx) }) if err := tgt.Start(ctx, true); err != nil { t.Fatalf("start: %v", err) } if err := tgt.Assign(ctx, 1, rolePrimary, 30000); err != nil { t.Fatalf("assign: %v", err) } // Login and write data if _, err := iscsi.Discover(ctx, host, faultISCSIPort1); err != nil { t.Fatalf("discover: %v", err) } dev, err := iscsi.Login(ctx, cfg.IQN) if err != nil { t.Fatalf("login: %v", err) } t.Log("writing 1MB data...") clientNode.RunRoot(ctx, "dd if=/dev/urandom of=/tmp/demote-pattern.bin bs=1M count=1 2>/dev/null") wMD5, _, _, _ := clientNode.RunRoot(ctx, "md5sum /tmp/demote-pattern.bin | awk '{print $1}'") wMD5 = strings.TrimSpace(wMD5) clientNode.RunRoot(ctx, fmt.Sprintf( "dd if=/tmp/demote-pattern.bin of=%s bs=1M count=1 oflag=direct 2>/dev/null", dev)) // Logout before demote iscsi.Logout(ctx, cfg.IQN) // Graceful demote: primary→stale (valid transition) t.Log("demoting to stale (epoch=2)...") if err := tgt.Assign(ctx, 2, roleStale, 0); err != nil { t.Logf("demote error (may be expected): %v", err) } st, _ := tgt.Status(ctx) t.Logf("post-demote: role=%s epoch=%d", st.Role, st.Epoch) // To re-promote, restart target (stale→primary is invalid, need None→Primary) t.Log("restarting target to reset role to None...") if err := tgt.Stop(ctx); err != nil { t.Fatalf("stop: %v", err) } if err := tgt.Start(ctx, false); err != nil { t.Fatalf("restart: %v", err) } // Re-promote: None→Primary (valid transition) t.Log("re-promoting (epoch=3)...") if err := tgt.Assign(ctx, 3, rolePrimary, 30000); err != nil { t.Fatalf("re-promote: %v", err) } // Re-login, verify data if _, err := iscsi.Discover(ctx, host, faultISCSIPort1); err != nil { t.Fatalf("re-discover: %v", err) } dev2, err := iscsi.Login(ctx, cfg.IQN) if err != nil { t.Fatalf("re-login: %v", err) } rMD5, _, _, _ := clientNode.RunRoot(ctx, fmt.Sprintf( "dd if=%s bs=1M count=1 iflag=direct 2>/dev/null | md5sum | awk '{print $1}'", dev2)) rMD5 = strings.TrimSpace(rMD5) if wMD5 != rMD5 { t.Fatalf("data lost after demote+re-promote: wrote=%s read=%s", wMD5, rMD5) } iscsi.Logout(ctx, cfg.IQN) t.Log("GracefulDemoteNoDataLoss passed: data intact after demote+re-promote") } // C13: 10 rapid Assign() calls cycling roles, verify no crash/panic. func testConsistencyRapidRoleFlip10x(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) defer cancel() // Clean up cleanCtx, cleanCancel := context.WithTimeout(context.Background(), 10*time.Second) defer cleanCancel() targetNode.Run(cleanCtx, "pkill -9 -f blockvol-ha 2>/dev/null") time.Sleep(2 * time.Second) name := strings.ReplaceAll(t.Name(), "/", "-") cfg := DefaultTargetConfig() cfg.IQN = iqnPrefix + "-" + strings.ToLower(name) cfg.Port = faultISCSIPort1 cfg.VolSize = "50M" tgt := NewHATarget(targetNode, cfg, faultAdminPort1, 0, 0, 0) tgt.volFile = "/tmp/blockvol-roleflip.blk" tgt.logFile = "/tmp/iscsi-roleflip.log" t.Cleanup(func() { cctx, c := context.WithTimeout(context.Background(), 10*time.Second) defer c() tgt.Stop(cctx) tgt.Cleanup(cctx) }) if err := tgt.Start(ctx, true); err != nil { t.Fatalf("start: %v", err) } // 10 rapid epoch bumps (same-role refresh with increasing epochs). // This tests epoch monotonicity under rapid Assign() calls. if err := tgt.Assign(ctx, 1, rolePrimary, 30000); err != nil { t.Fatalf("initial assign: %v", err) } for i := 2; i <= 10; i++ { epoch := uint64(i) err := tgt.Assign(ctx, epoch, rolePrimary, 30000) if err != nil { t.Logf("flip %d (epoch=%d): %v", i, epoch, err) } else { t.Logf("flip %d (epoch=%d): OK", i, epoch) } } // Verify target is still alive and epoch is monotonic st, err := tgt.Status(ctx) if err != nil { t.Fatalf("status after 10 flips: %v", err) } if st.Epoch < 10 { t.Fatalf("expected epoch >= 10, got %d", st.Epoch) } t.Logf("final status: epoch=%d role=%s has_lease=%v", st.Epoch, st.Role, st.HasLease) t.Log("RapidRoleFlip10x passed: no crash after 10 rapid epoch bumps") } // --- S6.7 Master Integration --- // C14: Assign with 5s lease, poll status for 7s, verify has_lease transitions true→false. func testConsistencyLeaseTimerRealExpiry(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) defer cancel() // Clean up cleanCtx, cleanCancel := context.WithTimeout(context.Background(), 10*time.Second) defer cleanCancel() targetNode.Run(cleanCtx, "pkill -9 -f blockvol-ha 2>/dev/null") time.Sleep(2 * time.Second) name := strings.ReplaceAll(t.Name(), "/", "-") cfg := DefaultTargetConfig() cfg.IQN = iqnPrefix + "-" + strings.ToLower(name) cfg.Port = faultISCSIPort1 cfg.VolSize = "50M" tgt := NewHATarget(targetNode, cfg, faultAdminPort1, 0, 0, 0) tgt.volFile = "/tmp/blockvol-lease-timer.blk" tgt.logFile = "/tmp/iscsi-lease-timer.log" t.Cleanup(func() { cctx, c := context.WithTimeout(context.Background(), 10*time.Second) defer c() tgt.Stop(cctx) tgt.Cleanup(cctx) }) if err := tgt.Start(ctx, true); err != nil { t.Fatalf("start: %v", err) } // Assign with 5s lease if err := tgt.Assign(ctx, 1, rolePrimary, 5000); err != nil { t.Fatalf("assign: %v", err) } // Poll status for 7s start := time.Now() hadLease := false lostLease := false lostAt := time.Duration(0) for time.Since(start) < 7*time.Second { st, err := tgt.Status(ctx) if err != nil { time.Sleep(500 * time.Millisecond) continue } if st.HasLease { hadLease = true } if hadLease && !st.HasLease { lostLease = true lostAt = time.Since(start) break } time.Sleep(500 * time.Millisecond) } if !hadLease { t.Fatalf("never observed has_lease=true") } if !lostLease { t.Fatalf("lease never expired within 7s") } t.Logf("lease expired at ~%.1fs (expected ~5s)", lostAt.Seconds()) if lostAt < 4*time.Second || lostAt > 7*time.Second { t.Logf("warning: lease expired at unexpected time (%.1fs)", lostAt.Seconds()) } t.Log("LeaseTimerRealExpiry passed: lease transitioned true→false at ~5s") } // --- S6.8 Group Commit --- // C15: fio --fdatasync=1 with replication, verify replica WAL head advances. func testConsistencyDistGroupCommitEndToEnd(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() primary, replica, iscsi := newFaultPair(t, "100M") setupFaultPrimaryReplica(t, ctx, primary, replica, 30000) host := targetHost() if _, err := iscsi.Discover(ctx, host, faultISCSIPort1); err != nil { t.Fatalf("discover: %v", err) } dev, err := iscsi.Login(ctx, primary.config.IQN) if err != nil { t.Fatalf("login: %v", err) } repSt0, _ := replica.Status(ctx) t.Logf("replica LSN before fio: %d", repSt0.WALHeadLSN) // Run fio with fdatasync t.Log("running fio --fdatasync=1 (5s)...") fioCmd := fmt.Sprintf( "fio --name=dgc --filename=%s --ioengine=libaio --direct=1 "+ "--rw=randwrite --bs=4k --numjobs=2 --iodepth=4 --runtime=5 "+ "--time_based --fdatasync=1 --group_reporting 2>/dev/null", dev) clientNode.RunRoot(ctx, fioCmd) // Check replica WAL head advanced time.Sleep(2 * time.Second) repSt1, _ := replica.Status(ctx) t.Logf("replica LSN after fio: %d", repSt1.WALHeadLSN) if repSt1.WALHeadLSN <= repSt0.WALHeadLSN { t.Fatalf("replica WAL head did not advance: before=%d after=%d", repSt0.WALHeadLSN, repSt1.WALHeadLSN) } iscsi.Logout(ctx, primary.config.IQN) t.Log("DistGroupCommitEndToEnd passed: replica WAL advanced during fdatasync fio") } // C16: Kill replica during fdatasync. Primary succeeds (degraded). More writes succeed. func testConsistencyDistGroupCommitReplicaCrash(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() primary, replica, iscsi := newFaultPair(t, "100M") setupFaultPrimaryReplica(t, ctx, primary, replica, 30000) host := targetHost() if _, err := iscsi.Discover(ctx, host, faultISCSIPort1); err != nil { t.Fatalf("discover: %v", err) } dev, err := iscsi.Login(ctx, primary.config.IQN) if err != nil { t.Fatalf("login: %v", err) } // Start fio with fdatasync t.Log("starting fio --fdatasync=1 (5s)...") fioCmd := fmt.Sprintf( "fio --name=dgc-crash --filename=%s --ioengine=libaio --direct=1 "+ "--rw=randwrite --bs=4k --numjobs=2 --iodepth=4 --runtime=5 "+ "--time_based --fdatasync=1 --group_reporting 2>/dev/null &", dev) clientNode.RunRoot(ctx, fioCmd) // Kill replica after 1s time.Sleep(1 * time.Second) t.Log("killing replica during fdatasync...") replica.Kill9() // Wait for fio to finish time.Sleep(6 * time.Second) // Primary should still work (degraded mode) st, err := primary.Status(ctx) if err != nil { t.Fatalf("primary status: %v", err) } t.Logf("primary after replica crash: role=%s has_lease=%v lsn=%d", st.Role, st.HasLease, st.WALHeadLSN) // More writes should succeed _, _, code, _ := clientNode.RunRoot(ctx, fmt.Sprintf( "dd if=/dev/urandom of=%s bs=4K count=10 oflag=direct 2>/dev/null", dev)) if code != 0 { t.Fatalf("write after replica crash failed") } iscsi.Logout(ctx, primary.config.IQN) t.Log("DistGroupCommitReplicaCrash passed: primary continued in degraded mode") } // C17: Write N blocks, fdatasync, check replica.Status().WALHeadLSN >= N. func testConsistencyDistGroupCommitBarrierVerify(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() primary, replica, iscsi := newFaultPair(t, "100M") setupFaultPrimaryReplica(t, ctx, primary, replica, 30000) host := targetHost() if _, err := iscsi.Discover(ctx, host, faultISCSIPort1); err != nil { t.Fatalf("discover: %v", err) } dev, err := iscsi.Login(ctx, primary.config.IQN) if err != nil { t.Fatalf("login: %v", err) } // Write 20 x 4K blocks with fdatasync (dd conv=fdatasync) t.Log("writing 20 x 4K blocks with fdatasync...") _, _, code, _ := clientNode.RunRoot(ctx, fmt.Sprintf( "dd if=/dev/urandom of=%s bs=4K count=20 oflag=direct conv=fdatasync 2>/dev/null", dev)) if code != 0 { t.Fatalf("write with fdatasync failed") } // Check primary and replica LSN priSt, _ := primary.Status(ctx) t.Logf("primary LSN: %d", priSt.WALHeadLSN) // Wait for replica to catch up waitCtx, waitCancel := context.WithTimeout(ctx, 15*time.Second) defer waitCancel() if err := replica.WaitForLSN(waitCtx, priSt.WALHeadLSN); err != nil { repSt, _ := replica.Status(ctx) t.Fatalf("replica did not catch up: primary=%d replica=%d err=%v", priSt.WALHeadLSN, repSt.WALHeadLSN, err) } repSt, _ := replica.Status(ctx) t.Logf("replica LSN: %d (>= primary %d)", repSt.WALHeadLSN, priSt.WALHeadLSN) if repSt.WALHeadLSN < priSt.WALHeadLSN { t.Fatalf("replica LSN %d < primary LSN %d after fdatasync", repSt.WALHeadLSN, priSt.WALHeadLSN) } iscsi.Logout(ctx, primary.config.IQN) t.Log("DistGroupCommitBarrierVerify passed: replica LSN >= primary after fdatasync") } // --- Failover Latency Baseline --- // // Measures the I/O pause time during failover across 10 iterations. // Each iteration: write data → kill primary → promote replica → login → first I/O. // Reports per-phase timing and total pause (kill → first successful I/O). type failoverTiming struct { Kill time.Duration // kill primary Promote time.Duration // admin API promote call Login time.Duration // iSCSI discover + login FirstIO time.Duration // first dd read succeeds Total time.Duration // kill → first I/O } func testConsistencyFailoverLatencyBaseline(t *testing.T) { const iterations = 10 ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute) defer cancel() primary, replica, iscsi := newFaultPair(t, "100M") host := targetHost() repHost := *flagClientHost if *flagEnv == "wsl2" { repHost = "127.0.0.1" } // Initial setup: primary on targetNode, replica on clientNode setupFaultPrimaryReplica(t, ctx, primary, replica, 30000) // Write initial data so volume isn't empty if _, err := iscsi.Discover(ctx, host, faultISCSIPort1); err != nil { t.Fatalf("discover primary: %v", err) } dev, err := iscsi.Login(ctx, primary.config.IQN) if err != nil { t.Fatalf("login primary: %v", err) } clientNode.RunRoot(ctx, fmt.Sprintf( "dd if=/dev/urandom of=%s bs=1M count=1 oflag=direct conv=fdatasync 2>/dev/null", dev)) // Wait for replication waitCtx, waitCancel := context.WithTimeout(ctx, 15*time.Second) defer waitCancel() if err := replica.WaitForLSN(waitCtx, 1); err != nil { t.Fatalf("initial replication stalled: %v", err) } iscsi.Logout(ctx, primary.config.IQN) // curPrimary/curReplica track which HATarget is currently which role. // We alternate: after failover, old replica becomes primary, old primary restarts as replica. curPrimary := primary curReplica := replica curPriHost := host curRepHost := repHost curPriISCSI := faultISCSIPort1 curRepISCSI := faultISCSIPort2 curEpoch := uint64(1) timings := make([]failoverTiming, 0, iterations) for i := 0; i < iterations; i++ { curEpoch++ t.Logf("=== Failover iteration %d (epoch=%d) ===", i+1, curEpoch) // Phase 1: Kill primary tKillStart := time.Now() curPrimary.Kill9() tKillDone := time.Now() // Phase 2: Promote replica tPromoteStart := time.Now() if err := curReplica.Assign(ctx, curEpoch, rolePrimary, 30000); err != nil { t.Fatalf("iter %d: promote failed: %v", i+1, err) } tPromoteDone := time.Now() // Phase 3: iSCSI discover + login to promoted replica tLoginStart := time.Now() if _, err := iscsi.Discover(ctx, curRepHost, curRepISCSI); err != nil { t.Fatalf("iter %d: discover failed: %v", i+1, err) } newDev, err := iscsi.Login(ctx, curReplica.config.IQN) if err != nil { t.Fatalf("iter %d: login failed: %v", i+1, err) } tLoginDone := time.Now() // Phase 4: First successful I/O tIOStart := time.Now() _, _, code, _ := clientNode.RunRoot(ctx, fmt.Sprintf( "dd if=%s bs=4K count=1 iflag=direct 2>/dev/null | md5sum >/dev/null", newDev)) if code != 0 { t.Fatalf("iter %d: first read failed", i+1) } tIODone := time.Now() timing := failoverTiming{ Kill: tKillDone.Sub(tKillStart), Promote: tPromoteDone.Sub(tPromoteStart), Login: tLoginDone.Sub(tLoginStart), FirstIO: tIODone.Sub(tIOStart), Total: tIODone.Sub(tKillStart), } timings = append(timings, timing) t.Logf(" kill=%s promote=%s login=%s firstIO=%s total=%s", timing.Kill.Round(time.Millisecond), timing.Promote.Round(time.Millisecond), timing.Login.Round(time.Millisecond), timing.FirstIO.Round(time.Millisecond), timing.Total.Round(time.Millisecond)) // Logout from promoted replica iscsi.Logout(ctx, curReplica.config.IQN) // Restart killed node as new replica if err := curPrimary.Start(ctx, false); err != nil { t.Fatalf("iter %d: restart killed node: %v", i+1, err) } curEpoch++ if err := curPrimary.Assign(ctx, curEpoch, roleReplica, 0); err != nil { t.Fatalf("iter %d: assign replica role: %v", i+1, err) } // Set up WAL shipping from new primary to new replica var newReplDataAddr, newReplCtrlAddr string if curPrimary == primary { // old primary (targetNode) is now replica → ship to targetNode's repl ports // But primary/replica have fixed repl ports... we need the replica receiver ports // The replica receiver ports are on the HATarget that was created with them. // primary was created WITHOUT repl ports, replica was created WITH faultReplData1/faultReplCtrl1. // So when roles swap, the new "replica" may not have receiver ports. // Skip WAL shipping on swapped iterations — the volume copy from initial setup is enough. t.Logf(" skipping WAL shipping setup (replica receiver ports not available on swapped node)") } else { newReplDataAddr = replicaAddr(faultReplData1) newReplCtrlAddr = replicaAddr(faultReplCtrl1) if err := curReplica.SetReplica(ctx, newReplDataAddr, newReplCtrlAddr); err != nil { t.Logf(" WAL shipping setup failed (non-fatal): %v", err) } } // Swap roles for next iteration curPrimary, curReplica = curReplica, curPrimary curPriHost, curRepHost = curRepHost, curPriHost curPriISCSI, curRepISCSI = curRepISCSI, curPriISCSI } // Compute statistics var totals, promotes, logins, firstIOs []float64 for _, tm := range timings { totals = append(totals, float64(tm.Total.Milliseconds())) promotes = append(promotes, float64(tm.Promote.Milliseconds())) logins = append(logins, float64(tm.Login.Milliseconds())) firstIOs = append(firstIOs, float64(tm.FirstIO.Milliseconds())) } avg := func(vals []float64) float64 { sum := 0.0 for _, v := range vals { sum += v } return sum / float64(len(vals)) } p99 := func(vals []float64) float64 { sorted := make([]float64, len(vals)) copy(sorted, vals) sort.Float64s(sorted) idx := int(math.Ceil(0.99*float64(len(sorted)))) - 1 if idx < 0 { idx = 0 } return sorted[idx] } pMin := func(vals []float64) float64 { sorted := make([]float64, len(vals)) copy(sorted, vals) sort.Float64s(sorted) return sorted[0] } pMax := func(vals []float64) float64 { sorted := make([]float64, len(vals)) copy(sorted, vals) sort.Float64s(sorted) return sorted[len(sorted)-1] } t.Logf("\n=== Failover Latency Baseline (%d iterations) ===", iterations) t.Logf("%-12s %8s %8s %8s %8s", "Phase", "Avg(ms)", "Min(ms)", "Max(ms)", "P99(ms)") t.Logf("%-12s %8.0f %8.0f %8.0f %8.0f", "Promote", avg(promotes), pMin(promotes), pMax(promotes), p99(promotes)) t.Logf("%-12s %8.0f %8.0f %8.0f %8.0f", "Login", avg(logins), pMin(logins), pMax(logins), p99(logins)) t.Logf("%-12s %8.0f %8.0f %8.0f %8.0f", "FirstIO", avg(firstIOs), pMin(firstIOs), pMax(firstIOs), p99(firstIOs)) t.Logf("%-12s %8.0f %8.0f %8.0f %8.0f", "TOTAL", avg(totals), pMin(totals), pMax(totals), p99(totals)) t.Log("FailoverLatencyBaseline passed") }