mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-05 14:02:00 +02:00
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>
1449 lines
47 KiB
Go
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")
|
|
}
|