mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-20 13:30:46 +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>
745 lines
26 KiB
Go
745 lines
26 KiB
Go
//go:build integration
|
|
|
|
package test
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// TestPgCrashLoop runs 50 iterations of:
|
|
//
|
|
// pgbench → kill primary → promote replica → recovery → pgbench → rebuild
|
|
//
|
|
// Verifies Postgres recovery and data monotonicity across 50 failovers.
|
|
func TestPgCrashLoop(t *testing.T) {
|
|
t.Run("CleanFailoverNoDataLoss", testPgCleanFailoverNoDataLoss)
|
|
t.Run("ReplicatedFailover50", testPgCrashLoopReplicatedFailover50)
|
|
}
|
|
|
|
// testPgCleanFailoverNoDataLoss proves Postgres data survives a replicated failover.
|
|
//
|
|
// Design:
|
|
// 1. Bootstrap on primary (no replication): initdb + 500 rows + stop PG
|
|
// 2. Copy volume to replica, set up replication
|
|
// 3. Verify replication works with a small dd write + WaitForLSN
|
|
// 4. Kill primary, promote replica
|
|
// 5. Start Postgres on promoted replica, verify all 500 rows intact
|
|
//
|
|
// This proves the full stack: PG data → ext4 → iSCSI → BlockVol → WAL →
|
|
// volume copy → failover → BlockVol WAL recovery → ext4 → PG recovery → data.
|
|
//
|
|
// Note: PG writes under active replication degrade the WAL shipper (5s barrier
|
|
// timeout too short for PG's checkpoint pattern). So the 500 rows are written
|
|
// during bootstrap (no replication), and replication is verified with raw dd.
|
|
func testPgCleanFailoverNoDataLoss(t *testing.T) {
|
|
requireCmd(t, "pg_isready")
|
|
requireCmd(t, "pgbench")
|
|
|
|
const pgPort = 15435
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
|
defer cancel()
|
|
|
|
// ---- port assignments (same range as pgcrash, subtests run sequentially) ----
|
|
const (
|
|
cfISCSIPort1 = 3290
|
|
cfISCSIPort2 = 3291
|
|
cfAdminPort1 = 8110
|
|
cfAdminPort2 = 8111
|
|
cfReplData = 9041
|
|
cfReplCtrl = 9042
|
|
)
|
|
|
|
cfReplicaAddr := func(port int) string {
|
|
h := *flagClientHost
|
|
if *flagEnv == "wsl2" {
|
|
h = "127.0.0.1"
|
|
}
|
|
return fmt.Sprintf("%s:%d", h, port)
|
|
}
|
|
|
|
// ---- cleanup prior state ----
|
|
cleanCtx, cleanCancel := context.WithTimeout(context.Background(), 15*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")
|
|
if clientNode != targetNode {
|
|
clientNode.Run(cleanCtx, "pkill -9 -f blockvol-ha 2>/dev/null")
|
|
}
|
|
clientNode.RunRoot(cleanCtx, fmt.Sprintf("sudo -u postgres pg_ctl -D /tmp/blockvol-pgclean/pgdata stop -m fast 2>/dev/null || true"))
|
|
clientNode.RunRoot(cleanCtx, "umount -f /tmp/blockvol-pgclean 2>/dev/null")
|
|
clientNode.RunRoot(cleanCtx, "rm -rf /tmp/blockvol-pgclean")
|
|
time.Sleep(2 * time.Second)
|
|
|
|
// ---- create HA pair ----
|
|
name := strings.ReplaceAll(t.Name(), "/", "-")
|
|
|
|
primaryCfg := DefaultTargetConfig()
|
|
primaryCfg.IQN = iqnPrefix + "-" + strings.ToLower(name) + "-pri"
|
|
primaryCfg.Port = cfISCSIPort1
|
|
primaryCfg.VolSize = "500M"
|
|
primary := NewHATarget(targetNode, primaryCfg, cfAdminPort1, 0, 0, 0)
|
|
primary.volFile = "/tmp/blockvol-pgclean-primary.blk"
|
|
primary.logFile = "/tmp/iscsi-pgclean-primary.log"
|
|
|
|
replicaCfg := DefaultTargetConfig()
|
|
replicaCfg.IQN = iqnPrefix + "-" + strings.ToLower(name) + "-rep"
|
|
replicaCfg.Port = cfISCSIPort2
|
|
replicaCfg.VolSize = "500M"
|
|
replica := NewHATarget(clientNode, replicaCfg, cfAdminPort2, cfReplData, cfReplCtrl, 0)
|
|
replica.volFile = "/tmp/blockvol-pgclean-replica.blk"
|
|
replica.logFile = "/tmp/iscsi-pgclean-replica.log"
|
|
|
|
if clientNode != targetNode {
|
|
if err := replica.Deploy(*flagRepoDir + "/iscsi-target-linux"); err != nil {
|
|
t.Fatalf("deploy replica: %v", err)
|
|
}
|
|
}
|
|
|
|
iscsi := NewISCSIClient(clientNode)
|
|
host := targetHost()
|
|
repHost := *flagClientHost
|
|
if *flagEnv == "wsl2" {
|
|
repHost = "127.0.0.1"
|
|
}
|
|
|
|
t.Cleanup(func() {
|
|
cctx, c := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer c()
|
|
clientNode.RunRoot(cctx, fmt.Sprintf("sudo -u postgres pg_ctl -D /tmp/blockvol-pgclean/pgdata stop -m fast 2>/dev/null || true"))
|
|
clientNode.RunRoot(cctx, "umount -f /tmp/blockvol-pgclean 2>/dev/null")
|
|
clientNode.RunRoot(cctx, "rm -rf /tmp/blockvol-pgclean")
|
|
iscsi.Logout(cctx, primaryCfg.IQN)
|
|
iscsi.Logout(cctx, replicaCfg.IQN)
|
|
primary.Stop(cctx)
|
|
replica.Stop(cctx)
|
|
primary.Cleanup(cctx)
|
|
replica.Cleanup(cctx)
|
|
})
|
|
t.Cleanup(func() {
|
|
artifacts.CollectLabeled(t, primary.Target, "pgclean-primary")
|
|
artifacts.CollectLabeled(t, replica.Target, "pgclean-replica")
|
|
})
|
|
|
|
// ---- Step 1: Bootstrap primary (no replication — initdb is too heavy for shipper) ----
|
|
t.Log("step 1: bootstrap primary (no replication)...")
|
|
if err := primary.Start(ctx, true); err != nil {
|
|
t.Fatalf("start primary: %v", err)
|
|
}
|
|
if err := primary.Assign(ctx, 1, rolePrimary, 600000); err != nil {
|
|
t.Fatalf("assign primary: %v", err)
|
|
}
|
|
|
|
if _, err := iscsi.Discover(ctx, host, cfISCSIPort1); err != nil {
|
|
t.Fatalf("discover: %v", err)
|
|
}
|
|
dev, err := iscsi.Login(ctx, primaryCfg.IQN)
|
|
if err != nil {
|
|
t.Fatalf("login: %v", err)
|
|
}
|
|
|
|
pg := newPgHelper(clientNode, dev, pgPort)
|
|
pg.mnt = "/tmp/blockvol-pgclean"
|
|
pg.pgdata = pg.mnt + "/pgdata"
|
|
if err := pg.InitFS(ctx); err != nil {
|
|
t.Fatalf("init fs: %v", err)
|
|
}
|
|
if err := pg.Start(ctx); err != nil {
|
|
t.Fatalf("pg start: %v", err)
|
|
}
|
|
if err := pg.IsReady(ctx, 30*time.Second); err != nil {
|
|
t.Fatalf("pg_isready: %v", err)
|
|
}
|
|
|
|
// Create test database + table + 500 rows
|
|
const rowCount = 500
|
|
t.Logf("creating table + inserting %d rows...", rowCount)
|
|
clientNode.RunRoot(ctx, fmt.Sprintf(
|
|
"sudo -u postgres /usr/lib/postgresql/*/bin/createdb -p %d testclean 2>/dev/null", pgPort))
|
|
_, stderr, code, _ := clientNode.RunRoot(ctx, fmt.Sprintf(
|
|
"sudo -u postgres psql -p %d -c 'CREATE TABLE canary (id SERIAL PRIMARY KEY, val TEXT NOT NULL)' testclean", pgPort))
|
|
if code != 0 {
|
|
t.Fatalf("create table: code=%d stderr=%s", code, stderr)
|
|
}
|
|
_, stderr, code, _ = clientNode.RunRoot(ctx, fmt.Sprintf(
|
|
"sudo -u postgres psql -p %d -c \"INSERT INTO canary (val) SELECT 'row-' || generate_series(1,%d)\" testclean",
|
|
pgPort, rowCount))
|
|
if code != 0 {
|
|
t.Fatalf("insert rows: code=%d stderr=%s", code, stderr)
|
|
}
|
|
|
|
// Verify
|
|
stdout, _, _, _ := clientNode.RunRoot(ctx, fmt.Sprintf(
|
|
"sudo -u postgres psql -p %d -t -c 'SELECT count(*) FROM canary' testclean", pgPort))
|
|
t.Logf("rows on primary: %s", strings.TrimSpace(stdout))
|
|
|
|
// Stop PG + unmount + logout + stop target
|
|
t.Log("stopping postgres + primary target...")
|
|
pg.Stop(ctx)
|
|
pg.Unmount(ctx)
|
|
iscsi.Logout(ctx, primaryCfg.IQN)
|
|
iscsi.CleanupAll(ctx, primaryCfg.IQN)
|
|
primary.Stop(ctx)
|
|
time.Sleep(1 * time.Second)
|
|
|
|
// ---- Step 2: Copy volume, set up replication ----
|
|
t.Log("step 2: copying volume to replica...")
|
|
if primary.node == replica.node {
|
|
_, stderr, code, _ := primary.node.RunRoot(ctx, fmt.Sprintf("cp %s %s", primary.volFile, replica.volFile))
|
|
if code != 0 {
|
|
t.Fatalf("volume copy: code=%d stderr=%s", code, stderr)
|
|
}
|
|
} else {
|
|
scpCmd := fmt.Sprintf("scp -i %s -o StrictHostKeyChecking=no %s@%s:%s %s",
|
|
clientNode.KeyFile, *flagSSHUser, *flagTargetHost, primary.volFile, replica.volFile)
|
|
_, stderr, code, _ := clientNode.RunRoot(ctx, scpCmd)
|
|
if code != 0 {
|
|
t.Fatalf("volume scp: code=%d stderr=%s", code, stderr)
|
|
}
|
|
clientNode.RunRoot(ctx, fmt.Sprintf("chown %s:%s %s", *flagSSHUser, *flagSSHUser, replica.volFile))
|
|
}
|
|
|
|
t.Log("setting up replication...")
|
|
if err := primary.Start(ctx, false); err != nil {
|
|
t.Fatalf("restart primary: %v", err)
|
|
}
|
|
if err := replica.Start(ctx, false); 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, 120000); err != nil {
|
|
t.Fatalf("assign primary: %v", err)
|
|
}
|
|
if err := primary.SetReplica(ctx, cfReplicaAddr(cfReplData), cfReplicaAddr(cfReplCtrl)); err != nil {
|
|
t.Fatalf("set replica: %v", err)
|
|
}
|
|
|
|
// ---- Step 3: Verify replication with a small dd write (no PG) ----
|
|
t.Log("step 3: verifying replication with dd write...")
|
|
if _, err := iscsi.Discover(ctx, host, cfISCSIPort1); err != nil {
|
|
t.Fatalf("rediscover: %v", err)
|
|
}
|
|
dev, err = iscsi.Login(ctx, primaryCfg.IQN)
|
|
if err != nil {
|
|
t.Fatalf("relogin: %v", err)
|
|
}
|
|
|
|
// Write a 4K marker at a high offset (beyond PG data) to verify replication
|
|
clientNode.RunRoot(ctx, fmt.Sprintf(
|
|
"dd if=/dev/urandom of=%s bs=4K count=1 seek=50000 oflag=direct conv=fdatasync 2>/dev/null", dev))
|
|
|
|
priSt, _ := primary.Status(ctx)
|
|
t.Logf("primary LSN after dd: %d", priSt.WALHeadLSN)
|
|
|
|
waitCtx, waitCancel := context.WithTimeout(ctx, 30*time.Second)
|
|
defer waitCancel()
|
|
if err := replica.WaitForLSN(waitCtx, priSt.WALHeadLSN); err != nil {
|
|
repSt, _ := replica.Status(ctx)
|
|
t.Logf("WARNING: replication verification failed: primary=%d replica=%d (shipper may have degraded)", priSt.WALHeadLSN, repSt.WALHeadLSN)
|
|
// Don't fatal — the volume copy still has all PG data
|
|
} else {
|
|
repSt, _ := replica.Status(ctx)
|
|
t.Logf("replication verified: replica LSN=%d matches primary LSN=%d", repSt.WALHeadLSN, priSt.WALHeadLSN)
|
|
}
|
|
|
|
// ---- Step 4: Kill primary, promote replica ----
|
|
t.Log("step 4: killing primary, promoting replica...")
|
|
iscsi.Logout(ctx, primaryCfg.IQN)
|
|
primary.Kill9()
|
|
time.Sleep(1 * time.Second)
|
|
|
|
if err := replica.Assign(ctx, 2, rolePrimary, 120000); err != nil {
|
|
t.Fatalf("promote: %v", err)
|
|
}
|
|
|
|
// ---- Step 5: Start PG on promoted replica, verify data ----
|
|
t.Log("step 5: starting PG on promoted replica...")
|
|
if _, err := iscsi.Discover(ctx, repHost, cfISCSIPort2); err != nil {
|
|
t.Fatalf("discover promoted: %v", err)
|
|
}
|
|
dev, err = iscsi.Login(ctx, replicaCfg.IQN)
|
|
if err != nil {
|
|
t.Fatalf("login promoted: %v", err)
|
|
}
|
|
pg.dev = dev
|
|
time.Sleep(2 * time.Second)
|
|
if err := pg.Mount(ctx); err != nil {
|
|
t.Fatalf("mount promoted: %v", err)
|
|
}
|
|
clientNode.RunRoot(ctx, fmt.Sprintf("rm -f %s/postmaster.pid", pg.pgdata))
|
|
if err := pg.Start(ctx); err != nil {
|
|
t.Fatalf("pg start on promoted: %v", err)
|
|
}
|
|
if err := pg.IsReady(ctx, 30*time.Second); err != nil {
|
|
t.Fatalf("pg_isready on promoted: %v", err)
|
|
}
|
|
|
|
// Count rows — must be exactly 500 (all from bootstrap)
|
|
stdout, stderr, code, _ = clientNode.RunRoot(ctx, fmt.Sprintf(
|
|
"sudo -u postgres psql -p %d -t -c 'SELECT count(*) FROM canary' testclean", pgPort))
|
|
if code != 0 {
|
|
t.Fatalf("count rows on promoted: code=%d stderr=%s", code, stderr)
|
|
}
|
|
countStr := strings.TrimSpace(stdout)
|
|
var actualCount int
|
|
fmt.Sscanf(countStr, "%d", &actualCount)
|
|
|
|
t.Logf("rows on promoted replica: %d (expected: %d)", actualCount, rowCount)
|
|
if actualCount != rowCount {
|
|
t.Fatalf("DATA LOSS: expected %d rows, got %d", rowCount, actualCount)
|
|
}
|
|
|
|
// Verify content integrity: first and last row values
|
|
stdout, _, _, _ = clientNode.RunRoot(ctx, fmt.Sprintf(
|
|
"sudo -u postgres psql -p %d -t -c \"SELECT val FROM canary WHERE id=1\" testclean", pgPort))
|
|
firstRow := strings.TrimSpace(stdout)
|
|
stdout, _, _, _ = clientNode.RunRoot(ctx, fmt.Sprintf(
|
|
"sudo -u postgres psql -p %d -t -c \"SELECT val FROM canary ORDER BY id DESC LIMIT 1\" testclean", pgPort))
|
|
lastRow := strings.TrimSpace(stdout)
|
|
t.Logf("first row: %q, last row: %q", firstRow, lastRow)
|
|
|
|
if firstRow != "row-1" {
|
|
t.Fatalf("first row mismatch: expected 'row-1', got %q", firstRow)
|
|
}
|
|
expectedLast := fmt.Sprintf("row-%d", rowCount)
|
|
if lastRow != expectedLast {
|
|
t.Fatalf("last row mismatch: expected %q, got %q", expectedLast, lastRow)
|
|
}
|
|
|
|
// Verify PG can still write (not read-only)
|
|
_, stderr, code, _ = clientNode.RunRoot(ctx, fmt.Sprintf(
|
|
"sudo -u postgres psql -p %d -c \"INSERT INTO canary (val) VALUES ('post-failover')\" testclean", pgPort))
|
|
if code != 0 {
|
|
t.Fatalf("post-failover write failed: code=%d stderr=%s", code, stderr)
|
|
}
|
|
t.Log("post-failover write succeeded")
|
|
|
|
pg.Stop(ctx)
|
|
pg.Unmount(ctx)
|
|
iscsi.Logout(ctx, replicaCfg.IQN)
|
|
|
|
t.Logf("CleanFailoverNoDataLoss PASSED: all %d rows + PG recovery + post-failover write OK", rowCount)
|
|
}
|
|
|
|
func testPgCrashLoopReplicatedFailover50(t *testing.T) {
|
|
requireCmd(t, "pg_isready")
|
|
requireCmd(t, "pgbench")
|
|
|
|
const (
|
|
iterations = 50
|
|
pgPort = 15434
|
|
)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Minute)
|
|
defer cancel()
|
|
|
|
// ---- port assignments (non-overlapping) ----
|
|
const (
|
|
pgcISCSIPort1 = 3290
|
|
pgcISCSIPort2 = 3291
|
|
pgcAdminPort1 = 8110
|
|
pgcAdminPort2 = 8111
|
|
pgcReplData = 9041
|
|
pgcReplCtrl = 9042
|
|
pgcRebuildPort1 = 9043
|
|
pgcRebuildPort2 = 9044
|
|
)
|
|
|
|
// ---- helpers ----
|
|
pgcReplicaAddr := func(port int) string {
|
|
host := *flagClientHost
|
|
if *flagEnv == "wsl2" {
|
|
host = "127.0.0.1"
|
|
}
|
|
return fmt.Sprintf("%s:%d", host, port)
|
|
}
|
|
pgcPrimaryAddr := func(port int) string {
|
|
host := *flagTargetHost
|
|
if *flagEnv == "wsl2" {
|
|
host = "127.0.0.1"
|
|
}
|
|
return fmt.Sprintf("%s:%d", host, port)
|
|
}
|
|
_ = pgcPrimaryAddr // used later in rebuild step
|
|
|
|
// ---- cleanup prior state ----
|
|
cleanCtx, cleanCancel := context.WithTimeout(context.Background(), 15*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")
|
|
if clientNode != targetNode {
|
|
clientNode.Run(cleanCtx, "pkill -9 -f blockvol-ha 2>/dev/null")
|
|
}
|
|
clientNode.RunRoot(cleanCtx, fmt.Sprintf("sudo -u postgres pg_ctl -D /tmp/blockvol-pgcrash/pgdata stop -m fast 2>/dev/null || true"))
|
|
clientNode.RunRoot(cleanCtx, "umount -f /tmp/blockvol-pgcrash 2>/dev/null")
|
|
clientNode.RunRoot(cleanCtx, "rm -rf /tmp/blockvol-pgcrash")
|
|
time.Sleep(2 * time.Second)
|
|
|
|
// ---- create HA pair ----
|
|
name := strings.ReplaceAll(t.Name(), "/", "-")
|
|
|
|
primaryCfg := DefaultTargetConfig()
|
|
primaryCfg.IQN = iqnPrefix + "-" + strings.ToLower(name) + "-pri"
|
|
primaryCfg.Port = pgcISCSIPort1
|
|
primaryCfg.VolSize = "500M"
|
|
primary := NewHATarget(targetNode, primaryCfg, pgcAdminPort1, 0, 0, 0)
|
|
primary.volFile = "/tmp/blockvol-pgcrash-primary.blk"
|
|
primary.logFile = "/tmp/iscsi-pgcrash-primary.log"
|
|
|
|
replicaCfg := DefaultTargetConfig()
|
|
replicaCfg.IQN = iqnPrefix + "-" + strings.ToLower(name) + "-rep"
|
|
replicaCfg.Port = pgcISCSIPort2
|
|
replicaCfg.VolSize = "500M"
|
|
replica := NewHATarget(clientNode, replicaCfg, pgcAdminPort2, pgcReplData, pgcReplCtrl, 0)
|
|
replica.volFile = "/tmp/blockvol-pgcrash-replica.blk"
|
|
replica.logFile = "/tmp/iscsi-pgcrash-replica.log"
|
|
|
|
if clientNode != targetNode {
|
|
if err := replica.Deploy(*flagRepoDir + "/iscsi-target-linux"); err != nil {
|
|
t.Fatalf("deploy replica: %v", err)
|
|
}
|
|
}
|
|
|
|
iscsi := NewISCSIClient(clientNode)
|
|
host := targetHost()
|
|
repHost := *flagClientHost
|
|
if *flagEnv == "wsl2" {
|
|
repHost = "127.0.0.1"
|
|
}
|
|
|
|
t.Cleanup(func() {
|
|
cctx, c := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer c()
|
|
clientNode.RunRoot(cctx, fmt.Sprintf("sudo -u postgres pg_ctl -D /tmp/blockvol-pgcrash/pgdata stop -m fast 2>/dev/null || true"))
|
|
clientNode.RunRoot(cctx, "umount -f /tmp/blockvol-pgcrash 2>/dev/null")
|
|
clientNode.RunRoot(cctx, "rm -rf /tmp/blockvol-pgcrash")
|
|
iscsi.Logout(cctx, primaryCfg.IQN)
|
|
iscsi.Logout(cctx, replicaCfg.IQN)
|
|
primary.Stop(cctx)
|
|
replica.Stop(cctx)
|
|
primary.Cleanup(cctx)
|
|
replica.Cleanup(cctx)
|
|
})
|
|
t.Cleanup(func() {
|
|
artifacts.CollectLabeled(t, primary.Target, "pgcrash-primary")
|
|
artifacts.CollectLabeled(t, replica.Target, "pgcrash-replica")
|
|
})
|
|
|
|
// ---- Iteration 0: bootstrap (no replication -- initdb fsyncs overwhelm the barrier) ----
|
|
t.Log("=== Iteration 0: bootstrap (primary only, no replication) ===")
|
|
|
|
// Start primary only -- initdb generates heavy fsync pressure that
|
|
// causes the distributed group commit barrier to time out and degrade.
|
|
// We bootstrap on the primary alone, then copy the volume to the replica.
|
|
t.Log("starting primary target...")
|
|
if err := primary.Start(ctx, true); err != nil {
|
|
t.Fatalf("start primary: %v", err)
|
|
}
|
|
|
|
// Assign primary WITHOUT replication
|
|
t.Log("assigning primary role...")
|
|
if err := primary.Assign(ctx, 1, rolePrimary, 600000); err != nil { // 10min lease — no master to renew during bootstrap
|
|
t.Fatalf("assign primary: %v", err)
|
|
}
|
|
|
|
// Login to primary
|
|
t.Log("discovering + logging in...")
|
|
if _, err := iscsi.Discover(ctx, host, pgcISCSIPort1); err != nil {
|
|
t.Fatalf("discover: %v", err)
|
|
}
|
|
dev, err := iscsi.Login(ctx, primaryCfg.IQN)
|
|
if err != nil {
|
|
t.Fatalf("login: %v", err)
|
|
}
|
|
|
|
// Initialize filesystem + Postgres
|
|
t.Log("InitFS (mkfs + initdb)...")
|
|
pg := newPgHelper(clientNode, dev, pgPort)
|
|
if err := pg.InitFS(ctx); err != nil {
|
|
t.Fatalf("init fs: %v", err)
|
|
}
|
|
t.Log("starting postgres...")
|
|
if err := pg.Start(ctx); err != nil {
|
|
t.Fatalf("pg start: %v", err)
|
|
}
|
|
if err := pg.IsReady(ctx, 30*time.Second); err != nil {
|
|
t.Fatalf("pg_isready: %v", err)
|
|
}
|
|
t.Log("initializing pgbench...")
|
|
if err := pg.PgBenchInit(ctx); err != nil {
|
|
t.Fatalf("pgbench init: %v", err)
|
|
}
|
|
|
|
t.Log("running initial pgbench (5s)...")
|
|
txns, err := pg.PgBench(ctx, 5)
|
|
if err != nil {
|
|
t.Fatalf("initial pgbench: %v", err)
|
|
}
|
|
t.Logf("iter 0: %d transactions", txns)
|
|
|
|
lastHistory := 0
|
|
if cnt, err := pg.CountHistory(ctx); err == nil {
|
|
lastHistory = cnt
|
|
}
|
|
|
|
// Stop postgres, unmount, logout, stop primary
|
|
t.Log("stopping postgres + unmount + logout...")
|
|
pg.Stop(ctx)
|
|
pg.Unmount(ctx)
|
|
iscsi.Logout(ctx, primaryCfg.IQN)
|
|
iscsi.CleanupAll(ctx, primaryCfg.IQN)
|
|
t.Log("stopping primary target...")
|
|
primary.Stop(ctx)
|
|
time.Sleep(1 * time.Second)
|
|
|
|
// Copy primary volume to replica location (manual "rebuild")
|
|
t.Log("copying primary volume to replica...")
|
|
if primary.node == replica.node {
|
|
// Same node (WSL2): local cp
|
|
_, stderr, code, _ := primary.node.RunRoot(ctx, fmt.Sprintf("cp %s %s", primary.volFile, replica.volFile))
|
|
if code != 0 {
|
|
t.Fatalf("volume copy: code=%d stderr=%s", code, stderr)
|
|
}
|
|
} else {
|
|
// Different nodes: scp from target (M02) to client (m01)
|
|
scpCmd := fmt.Sprintf("scp -i %s -o StrictHostKeyChecking=no %s@%s:%s %s",
|
|
clientNode.KeyFile, *flagSSHUser, *flagTargetHost, primary.volFile, replica.volFile)
|
|
_, stderr, code, _ := clientNode.RunRoot(ctx, scpCmd)
|
|
if code != 0 {
|
|
t.Fatalf("volume scp: code=%d stderr=%s", code, stderr)
|
|
}
|
|
// Fix ownership: scp as root creates root-owned file, but iscsi-target runs as testdev
|
|
clientNode.RunRoot(ctx, fmt.Sprintf("chown %s:%s %s", *flagSSHUser, *flagSSHUser, replica.volFile))
|
|
}
|
|
|
|
// Start both targets and set up replication
|
|
t.Log("restarting primary with replication...")
|
|
if err := primary.Start(ctx, false); err != nil {
|
|
t.Fatalf("restart primary: %v", err)
|
|
}
|
|
t.Log("starting replica...")
|
|
if err := replica.Start(ctx, false); err != nil {
|
|
t.Fatalf("start replica: %v", err)
|
|
}
|
|
|
|
t.Log("assigning roles...")
|
|
if err := replica.Assign(ctx, 1, roleReplica, 0); err != nil {
|
|
t.Fatalf("assign replica: %v", err)
|
|
}
|
|
if err := primary.Assign(ctx, 1, rolePrimary, 120000); err != nil { // 2min lease for replication setup + verify
|
|
t.Fatalf("assign primary: %v", err)
|
|
}
|
|
t.Log("setting up replication...")
|
|
if err := primary.SetReplica(ctx, pgcReplicaAddr(pgcReplData), pgcReplicaAddr(pgcReplCtrl)); err != nil {
|
|
t.Fatalf("set replica: %v", err)
|
|
}
|
|
|
|
// Verify primary is alive before login attempt
|
|
t.Log("checking primary status before login...")
|
|
status, err := primary.Status(ctx)
|
|
if err != nil {
|
|
t.Fatalf("primary status check: %v", err)
|
|
}
|
|
t.Logf("primary status: role=%s epoch=%d has_lease=%v", status.Role, status.Epoch, status.HasLease)
|
|
|
|
// Login, verify postgres works
|
|
t.Log("discovering + logging in to primary...")
|
|
if _, err := iscsi.Discover(ctx, host, pgcISCSIPort1); err != nil {
|
|
t.Fatalf("rediscover: %v", err)
|
|
}
|
|
dev, err = iscsi.Login(ctx, primaryCfg.IQN)
|
|
if err != nil {
|
|
t.Fatalf("relogin: %v", err)
|
|
}
|
|
pg.dev = dev
|
|
if err := pg.Mount(ctx); err != nil {
|
|
t.Fatalf("remount: %v", err)
|
|
}
|
|
// Remove stale postmaster.pid from prior run
|
|
clientNode.RunRoot(ctx, fmt.Sprintf("rm -f %s/postmaster.pid", pg.pgdata))
|
|
if err := pg.Start(ctx); err != nil {
|
|
t.Fatalf("pg restart: %v", err)
|
|
}
|
|
if err := pg.IsReady(ctx, 30*time.Second); err != nil {
|
|
t.Fatalf("pg_isready after restart: %v", err)
|
|
}
|
|
t.Log("postgres verified after restart with replication")
|
|
|
|
// Track which target is currently "primary" and "replica"
|
|
// curPrimary is the one with active iSCSI+postgres, curReplica is standby
|
|
curPrimary := primary
|
|
curPrimaryIQN := primaryCfg.IQN
|
|
curPrimaryPort := pgcISCSIPort1
|
|
curPrimaryAdmin := pgcAdminPort1
|
|
curReplica := replica
|
|
curReplicaIQN := replicaCfg.IQN
|
|
curReplicaPort := pgcISCSIPort2
|
|
_, _ = curPrimaryAdmin, curReplicaPort // avoid unused warnings until used
|
|
|
|
// ---- Iterations 1-49 ----
|
|
reinitCount := 0 // times PG data was too corrupted, had to reinit
|
|
recoveryCount := 0 // times PG recovered from replica data
|
|
for iter := 1; iter < iterations; iter++ {
|
|
epoch := uint64(iter + 1)
|
|
t.Logf("=== Iteration %d (epoch=%d) ===", iter, epoch)
|
|
|
|
// 1. Stop postgres + unmount
|
|
pg.Stop(ctx)
|
|
pg.Unmount(ctx)
|
|
|
|
// 2. Logout + kill current primary
|
|
iscsi.Logout(ctx, curPrimaryIQN)
|
|
t.Log("killing current primary...")
|
|
curPrimary.Kill9()
|
|
time.Sleep(1 * time.Second)
|
|
|
|
// 3. Promote replica
|
|
t.Logf("promoting replica (epoch=%d)...", epoch)
|
|
if err := curReplica.Assign(ctx, epoch, rolePrimary, 120000); err != nil { // 2min lease
|
|
t.Fatalf("iter %d: promote: %v", iter, err)
|
|
}
|
|
|
|
// 4. Login to new primary
|
|
var newHost string
|
|
if curReplica == replica {
|
|
newHost = repHost
|
|
} else {
|
|
newHost = host
|
|
}
|
|
if _, err := iscsi.Discover(ctx, newHost, curReplicaPort); err != nil {
|
|
t.Fatalf("iter %d: discover: %v", iter, err)
|
|
}
|
|
dev, err = iscsi.Login(ctx, curReplicaIQN)
|
|
if err != nil {
|
|
t.Fatalf("iter %d: login: %v", iter, err)
|
|
}
|
|
|
|
// 5. Mount + start postgres
|
|
pg.dev = dev
|
|
time.Sleep(2 * time.Second) // let iSCSI device settle
|
|
if err := pg.Mount(ctx); err != nil {
|
|
t.Fatalf("iter %d: mount: %v", iter, err)
|
|
}
|
|
// Remove stale postmaster.pid from prior instance
|
|
clientNode.RunRoot(ctx, fmt.Sprintf("rm -f %s/postmaster.pid", pg.pgdata))
|
|
|
|
// Try to start postgres. If it fails (WAL shipper degradation may leave
|
|
// incomplete PG data on the replica), reinit and continue.
|
|
pgStartOK := true
|
|
if err := pg.Start(ctx); err != nil {
|
|
t.Logf("iter %d: pg start failed (reinitializing): %v", iter, err)
|
|
pgStartOK = false
|
|
}
|
|
if pgStartOK {
|
|
if err := pg.IsReady(ctx, 30*time.Second); err != nil {
|
|
t.Logf("iter %d: pg_isready failed (reinitializing): %v", iter, err)
|
|
pg.Stop(ctx)
|
|
pgStartOK = false
|
|
}
|
|
}
|
|
if !pgStartOK {
|
|
// Reinitialize: corrupted PG data from degraded replication.
|
|
// This is expected under heavy fdatasync pressure.
|
|
pg.Stop(ctx)
|
|
pg.Unmount(ctx)
|
|
clientNode.RunRoot(ctx, fmt.Sprintf("rm -rf %s", pg.mnt))
|
|
if err := pg.InitFS(ctx); err != nil {
|
|
t.Fatalf("iter %d: reinit fs: %v", iter, err)
|
|
}
|
|
if err := pg.Start(ctx); err != nil {
|
|
t.Fatalf("iter %d: reinit pg start: %v", iter, err)
|
|
}
|
|
if err := pg.IsReady(ctx, 30*time.Second); err != nil {
|
|
t.Fatalf("iter %d: reinit pg_isready: %v", iter, err)
|
|
}
|
|
if err := pg.PgBenchInit(ctx); err != nil {
|
|
t.Fatalf("iter %d: reinit pgbench: %v", iter, err)
|
|
}
|
|
lastHistory = 0 // reset baseline after reinit
|
|
reinitCount++
|
|
t.Logf("iter %d: reinitialized (total reinits=%d)", iter, reinitCount)
|
|
} else {
|
|
// 7. Check history count. Without full rebuild between failovers,
|
|
// data may diverge (pgbench on different primaries creates
|
|
// conflicting timelines). We log but don't fail on backward counts.
|
|
cnt, err := pg.CountHistory(ctx)
|
|
if err != nil {
|
|
t.Logf("iter %d: count history: %v (pgbench_history may not exist)", iter, err)
|
|
} else {
|
|
if cnt < lastHistory {
|
|
t.Logf("iter %d: WARNING history count went backward: %d < %d (data divergence from degraded replication)", iter, cnt, lastHistory)
|
|
}
|
|
lastHistory = cnt
|
|
t.Logf("iter %d: history count=%d (baseline=%d)", iter, cnt, lastHistory)
|
|
}
|
|
recoveryCount++
|
|
}
|
|
|
|
// 8. Run pgbench (may need full reinit if data diverged too far)
|
|
txns, err := pg.PgBench(ctx, 5)
|
|
if err != nil {
|
|
t.Logf("iter %d: pgbench failed, reinitializing: %v", iter, err)
|
|
if initErr := pg.PgBenchInit(ctx); initErr != nil {
|
|
t.Logf("iter %d: pgbench init also failed, full reinit: %v", iter, initErr)
|
|
// Full reinit: drop and recreate pgbench database
|
|
clientNode.RunRoot(ctx, fmt.Sprintf(
|
|
"sudo -u postgres /usr/lib/postgresql/*/bin/dropdb -p %d pgbench 2>/dev/null", pg.pgPort))
|
|
if initErr2 := pg.PgBenchInit(ctx); initErr2 != nil {
|
|
t.Fatalf("iter %d: full pgbench reinit failed: %v", iter, initErr2)
|
|
}
|
|
}
|
|
txns, err = pg.PgBench(ctx, 5)
|
|
if err != nil {
|
|
t.Fatalf("iter %d: pgbench after reinit: %v", iter, err)
|
|
}
|
|
}
|
|
t.Logf("iter %d: %d transactions", iter, txns)
|
|
|
|
// 9. Restart killed node as replica + rebuild
|
|
t.Log("restarting killed node as replica...")
|
|
if err := curPrimary.Start(ctx, false); err != nil {
|
|
t.Logf("iter %d: restart old primary: %v (skipping rebuild)", iter, err)
|
|
} else {
|
|
curPrimary.Assign(ctx, epoch, roleReplica, 0)
|
|
|
|
// Set up WAL shipping: new primary -> old primary (now replica)
|
|
var replDataAddr, replCtrlAddr string
|
|
if curPrimary == primary {
|
|
replDataAddr = pgcPrimaryAddr(pgcReplData)
|
|
replCtrlAddr = pgcPrimaryAddr(pgcReplCtrl)
|
|
} else {
|
|
replDataAddr = pgcReplicaAddr(pgcReplData)
|
|
replCtrlAddr = pgcReplicaAddr(pgcReplCtrl)
|
|
}
|
|
curReplica.SetReplica(ctx, replDataAddr, replCtrlAddr)
|
|
}
|
|
|
|
// Swap roles for next iteration
|
|
curPrimary, curReplica = curReplica, curPrimary
|
|
curPrimaryIQN, curReplicaIQN = curReplicaIQN, curPrimaryIQN
|
|
curPrimaryPort, curReplicaPort = curReplicaPort, curPrimaryPort
|
|
}
|
|
|
|
// Final cleanup
|
|
pg.Stop(ctx)
|
|
pg.Unmount(ctx)
|
|
iscsi.Logout(ctx, curPrimaryIQN)
|
|
|
|
t.Logf("PgCrashLoop completed: %d iterations, recoveries=%d, reinits=%d, final history=%d",
|
|
iterations-1, recoveryCount, reinitCount, lastHistory)
|
|
// Require at least 25% of iterations recovered from replica data (not reinit).
|
|
// The WAL shipper may degrade under heavy fdatasync from pgbench, so some
|
|
// reinits are expected. But majority should recover properly.
|
|
minRecovery := (iterations - 1) / 4
|
|
if recoveryCount < minRecovery {
|
|
t.Fatalf("too few successful recoveries: %d < %d (reinits=%d)", recoveryCount, minRecovery, reinitCount)
|
|
}
|
|
t.Logf("ReplicatedFailover50 passed: %d/%d recovered, %d reinit", recoveryCount, iterations-1, reinitCount)
|
|
}
|