Files
seaweedfs/weed/storage/blockvol/test/consistency_test.go
T
pingqiuandClaude Opus 4.6 785a7d7efd feat: wire real pinner into flusher retention + real WAL scan executor (Phase 07 P1)
Pinner wired to real retention:
- NewPinner calls vol.SetV2RetentionFloor(p.MinWALRetentionFloor)
- Flusher.RetentionFloorFn() / SetRetentionFloorFn() exposed
- SetV2RetentionFloor chains with existing shipper retention floor
- Holds actually prevent WAL reclaim (not just tracked state)

Executor uses real WAL scan:
- BlockVol.ScanWALEntries(fromLSN, callback) wraps wal.ScanFrom
  with real fd, walOffset, checkpointLSN
- Executor.StreamWALEntries uses ScanWALEntries (not stub)
- Reads real WAL entries, tracks highest LSN scanned

CommittedLSN mapping:
- Explicitly documented as interim V1 model (committed = checkpointed)
- Will diverge when V2 distributed commit separates from local flush

Carry-forward:
- TransferSnapshot/TransferFullBase/TruncateWAL: stubs (need extent I/O)
- Control intent from confirmed failover: deferred

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

1449 lines
47 KiB
Go

//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")
}