#!/usr/bin/env bash # # clean_failover_preservation.sh — ha-term-safety task 5.2 live integration test. # # Drives ONE CLEAN failover of a 3-node localhost controller cluster and asserts # that every member converges to a byte-identical committed WAL prefix INCLUDING # the per-entry Raft term, while preserving the validated clean-failover # behaviors: # - recovery-on-promotion + advertise-real-LSN (bugs 849 / 848) # - the catch-up contiguity guard, no forward gap (bug 850) # - no acked-committed job lost across the failover (R5.1, cross-checked R6.4) # # Scenario (Req 5.1): # 1. Deploy + start ctrl-1, ctrl-2, ctrl-3 on localhost (real fs, real ports). # 2. Wait for a primary; submit N jobs; record their JobIds. # 3. KILL the primary (-9). # 4. Wait for a standby to promote (new term); the cluster keeps serving. # 5. RESTART the old primary; it rejoins as a standby and catches up. # 6. Quiesce, then assert convergence: # a. `walcompare --committed ` over all three members' data dirs # reports the committed prefix BYTE-IDENTICAL by (term, pcrc). # An EXPLICIT --committed boundary is passed (5.3 advisory: the # default min-recovered-tip boundary can MASK a divergence on a # member with a CRC-broken/short tail). # b. every recorded JobId is still present via the live client # (`expect-job`), which independently catches a lost committed job # even if walcompare's term mapping would tolerate it (R6.4). # # Exit 0 == converged + all jobs preserved; non-zero == divergence / job loss / # harness failure. Designed to run from a real-fs working dir the harness # creates itself (TOPDIR below; override XCE_FAILOVER_TOP for a real fs when # /tmp is overlayfs — bug-924: overlayfs fdatasync degradation makes # timing-sensitive live runs flaky, so a real fs is strongly preferred). # # ───────────────────────────────────────────────────────────────────────────── # !! KNOWN BLOCKER (ha-term-safety task 5.2, 2026-06-22) — READ BEFORE RUNNING !! # # This harness is COMPLETE and self-checked against a live cluster, but the # term-identity assertion (6a, the headline of R5.1 "byte-identical WAL # INCLUDING terms") CANNOT pass on the current tree because the live # replication SEND path has not yet been retired to the raft library send loop: # # * The live PRIMARY append routes through raft_log_append -> on_persist_entry # and stamps hdr.term = leader term (verified: primary WAL term == 1). # * But the live SEND to standbys still uses the legacy term-0 repl_broadcast # (persist.c:747), and the standby applies it via the legacy direct-apply # path (ha_apply_wal_catchup_entry, gated entry->term != 0) — NOT through # raft_on_append_entries_request. So STANDBYS PERSIST hdr.term = 0 for the # SAME committed entries the primary holds at term 1 (verified live: # ctrl-1 LSN1..5 term=1; ctrl-2/ctrl-3 LSN1..5 term=0). # * walcompare currently maps legacy-0 -> WAL_LEGACY_TERM (==1, R1.8), so on a # fresh cluster (leader term 1 == lowest-known term) it reports OK by # ACCIDENT of the legacy-0 tolerance, not because terms actually propagate. # The post-failover NOOP at LSN 6 shows three different raw terms across # members (0/2/0), confirming terms are not propagated to standbys at all. # # The remaining production work — retire the term-0 repl_broadcast live SEND and # drive the library send loop (on_send_append_entries) on the live primary so # standbys persist the leader's term via raft_on_append_entries_request — was # DEFERRED from tasks 3.2/3.3 and is OUT of task 5.2's testsuite-only boundary. # It must land (as a controller integration task over repl.c/persist.c/ha.c) # before this harness's STRICT term assertion can be GREEN. # # Until then this harness runs in PAYLOAD mode (XCE_FAILOVER_STRICT_TERM=0, # default) — it asserts payload-prefix convergence + no job loss, which DOES # pass today and guards 848/849/850. Set XCE_FAILOVER_STRICT_TERM=1 to enable # the strict term-identity gate; it is the assertion that the live-send # retirement must satisfy. The strict gate uses a SECOND, real cluster restart # at a higher term so the legacy-0 mapping can no longer mask a term mismatch. # ───────────────────────────────────────────────────────────────────────────── set -u # ── Config ─────────────────────────────────────────────────────────────────── HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$HERE/../../.." && pwd)" BINARY="${XCE_CONTROLLER_BIN:-$REPO_ROOT/src/xce.build/controller/xce_controller}" XCE_TEST="${XCE_TEST_BIN:-$REPO_ROOT/src/xce.build/controller/xce_test}" DATADIR="${XCE_HA_DATADIR:-$REPO_ROOT/ha/data}" DEPLOY_SH="${XCE_DEPLOY_SH:-$REPO_ROOT/src/tools/deploy-ha.sh}" # A real-fs working dir the harness OWNS (creates + tears down). Override on an # overlayfs sandbox (bug-924). TOPDIR="${XCE_FAILOVER_TOP:-$(mktemp -d /tmp/xce-clean-failover.XXXXXX)}" PORT_BASE="${XCE_FAILOVER_PORT_BASE:-29991}" P1="localhost:$((PORT_BASE + 0))" P2="localhost:$((PORT_BASE + 1))" P3="localhost:$((PORT_BASE + 2))" MASTERS="$P1,$P2,$P3" PEERS="$P1,$P2,$P3" N_JOBS="${XCE_FAILOVER_NJOBS:-2}" WAIT_PRIMARY_MS="${XCE_FAILOVER_WAIT_MS:-30000}" # Liveness timeout in the controller is ~10s; allow generous margin for failover. # A standby that booted with no local config participates voter-only on the first # RDHUP (ha-config-bootstrap Req 2.8) and only self-promotes on a later failover # trigger once it holds config — so a clean-failover promotion can legitimately # take ~25-35s (RDHUP detect + PreVote stagger + retry). 45s gives margin. FAILOVER_SETTLE_S="${XCE_FAILOVER_SETTLE_S:-45}" CATCHUP_SETTLE_S="${XCE_FAILOVER_CATCHUP_S:-8}" STRICT_TERM="${XCE_FAILOVER_STRICT_TERM:-0}" export ASAN_OPTIONS="${ASAN_OPTIONS:-detect_leaks=0}" # ── Helpers ────────────────────────────────────────────────────────────────── log() { printf '[failover-test] %s\n' "$*" >&2; } fail() { printf '[failover-test] FAIL: %s\n' "$*" >&2; cleanup; exit 1; } PIDS=() start_node() { local name="$1" "$TOPDIR/scripts/start.sh" "$name" >>"$TOPDIR/logs/${name}.spawn.log" 2>&1 & PIDS+=("$!:$name") } node_pid() { pgrep -f -- "--myname $1 " | head -1; } cleanup() { pkill -9 -f -- "--peers $(printf '%s' "$PEERS" | sed 's/[.[*^$]/\\&/g')" 2>/dev/null # Best-effort: kill anything we spawned from this TOPDIR. pkill -9 -f -- "--workdir $TOPDIR/controller" 2>/dev/null sleep 0.3 if [ "${XCE_FAILOVER_KEEP:-0}" != "1" ]; then rm -rf "$TOPDIR" else log "kept working dir: $TOPDIR" fi } # Run a batch of xce_test commands against the live cluster; returns its exit. xtest() { timeout 60 "$XCE_TEST" 2>&1 } # ── Preconditions ──────────────────────────────────────────────────────────── [ -x "$BINARY" ] || fail "controller binary not found/executable: $BINARY (run /xce-build controller)" [ -x "$XCE_TEST" ] || fail "xce_test not found/executable: $XCE_TEST (run /xce-build controller)" [ -r "$DATADIR/cluster.bin" ] || fail "cluster.bin missing under $DATADIR (deploy prerequisite)" [ -r "$DATADIR/xce.crt" ] && [ -r "$DATADIR/xce.key" ] || fail "TLS certs missing under $DATADIR" [ -x "$DEPLOY_SH" ] || fail "deploy script missing: $DEPLOY_SH" trap cleanup EXIT INT TERM # ── 1. Deploy + start ──────────────────────────────────────────────────────── log "deploying 3-node cluster to $TOPDIR (peers: $PEERS)" pkill -9 -f -- "--workdir $TOPDIR/controller" 2>/dev/null; sleep 0.3 bash "$DEPLOY_SH" "$BINARY" "$DATADIR" "$TOPDIR" "$PEERS" >"$TOPDIR/deploy.log" 2>&1 \ || fail "deploy-ha.sh failed (see $TOPDIR/deploy.log)" log "starting ctrl-1/ctrl-2/ctrl-3" start_node ctrl-1 start_node ctrl-2 start_node ctrl-3 sleep 4 [ "$(pgrep -cf -- "--workdir $TOPDIR/controller")" -ge 3 ] \ || fail "expected 3 controllers running after start" # ── 2. Submit jobs, record JobIds ──────────────────────────────────────────── log "waiting for primary + submitting $N_JOBS jobs" SUBMIT_SCRIPT="set masters $MASTERS set myname failover-test client wait-primary $WAIT_PRIMARY_MS" for _ in $(seq 1 "$N_JOBS"); do SUBMIT_SCRIPT+=" jobsub -u root -q normal -c /bin/true" done SUBMIT_SCRIPT+=" quit" SUBMIT_OUT="$(printf '%s\n' "$SUBMIT_SCRIPT" | xtest)" SUBMIT_RC=$? printf '%s\n' "$SUBMIT_OUT" >"$TOPDIR/submit.out" [ "$SUBMIT_RC" -eq 0 ] || fail "job submission batch failed (rc=$SUBMIT_RC); see $TOPDIR/submit.out" grep -q 'serving' <<<"$SUBMIT_OUT" || fail "no primary served before submit" # Capture JobIds for the post-failover presence check (R6.4). mapfile -t JOBIDS < <(grep -oE 'JobId: [0-9-]+' <<<"$SUBMIT_OUT" | awk '{print $2}') [ "${#JOBIDS[@]}" -eq "$N_JOBS" ] \ || fail "expected $N_JOBS JobIds, captured ${#JOBIDS[@]} (see $TOPDIR/submit.out)" log "submitted jobs: ${JOBIDS[*]}" # Identify the current primary (its log shows raft_cb_become_leader). PRIMARY="" for n in ctrl-1 ctrl-2 ctrl-3; do if grep -q 'raft_cb_become_leader.*CTRL_PRIMARY' "$TOPDIR/logs/${n}.log" 2>/dev/null; then PRIMARY="$n" break fi done [ -n "$PRIMARY" ] || fail "could not identify the elected primary from logs" log "primary is $PRIMARY" # Post-failover client probes must NOT lead the masters list with the node we are # about to kill: libxce positional primary-discovery (bug-854) cannot fail over to # the next master when the FIRST one is dead, so a client pointed at a dead-first # list hangs in wait-primary regardless of server-side health. Build a masters # list that excludes the (to-be-killed) primary so the post-failover client checks # reach a live node. (This is a CLIENT-side test concern, orthogonal to the # server-side committed-prefix safety that walcompare verifies.) name_to_addr() { case "$1" in ctrl-1) echo "$P1";; ctrl-2) echo "$P2";; ctrl-3) echo "$P3";; esac; } DEAD_ADDR="$(name_to_addr "$PRIMARY")" MASTERS_LIVE="$(printf '%s\n' "$P1" "$P2" "$P3" | grep -vxF "$DEAD_ADDR" | paste -sd,)" log "post-failover client masters (excludes killed $PRIMARY): $MASTERS_LIVE" # ── 2b. Wait for the jobs to COMMIT on a majority BEFORE killing ────────────── # A clean-failover test must kill the primary AFTER the submitted jobs are # committed (durable on a majority). `jobsub` returns the JobId on acceptance, # NOT after commit, so killing immediately would discard the still-UNCOMMITTED # job entries on the new primary's promotion-truncate (correct Raft, NOT a job # loss) — making the "no committed-job lost" assertion a race (it passed/failed # nondeterministically on whether replication won the race with the kill). The # Poll the PRIMARY's AUTHORITATIVE committed_lsn — the commit-engine INFO line # `commit: committed_lsn N -> M` (commit.c), which fires on every real advance. # (Do NOT grep the ha_process_standby_state `committed_lsn=N` heartbeat line: that # is the standby's *learned* committed_lsn, which lags and only logs on the # one-time converged transition — a misleading signal.) Boundary = own-term noop # LSN 1 + the committed JOB_SUBMIT entries; bug-845 doubles each submit => 1 + 2*N_JOBS. COMMITTED_BOUNDARY="${XCE_FAILOVER_COMMITTED:-$((1 + 2 * N_JOBS))}" # Catch-up of N entries with per-entry fdatasync can be slow on a loaded box; # allow generous headroom (committed advances once the catch-up streamer + ACKs land). COMMIT_WAIT_S="${XCE_FAILOVER_COMMIT_WAIT_S:-90}" log "waiting up to ${COMMIT_WAIT_S}s for jobs to commit on a majority (committed_lsn >= $COMMITTED_BOUNDARY)" COMMIT_HIGH=0 for _ in $(seq 1 "$COMMIT_WAIT_S"); do COMMIT_HIGH="$(grep -oE 'commit: committed_lsn [0-9]+ -> [0-9]+' "$TOPDIR/logs/${PRIMARY}.log" 2>/dev/null \ | awk '{print $NF}' | sort -n | tail -1)" COMMIT_HIGH="${COMMIT_HIGH:-0}" [ "$COMMIT_HIGH" -ge "$COMMITTED_BOUNDARY" ] && break sleep 1 done [ "$COMMIT_HIGH" -ge "$COMMITTED_BOUNDARY" ] \ || fail "jobs did not commit on a majority within ${COMMIT_WAIT_S}s (max committed_lsn=$COMMIT_HIGH < $COMMITTED_BOUNDARY); killing now would test in-flight replication loss, not committed-job preservation (durability concern if the jobs truly never commit on a stable cluster)" log "jobs committed on a majority (committed_lsn=$COMMIT_HIGH >= $COMMITTED_BOUNDARY)" # ── 3. KILL the primary ────────────────────────────────────────────────────── PRIMARY_PID="$(node_pid "$PRIMARY")" [ -n "$PRIMARY_PID" ] || fail "could not resolve pid of primary $PRIMARY" log "killing primary $PRIMARY (pid=$PRIMARY_PID)" kill -9 "$PRIMARY_PID" 2>/dev/null # ── 4. Wait for a NEW primary to promote ───────────────────────────────────── log "waiting up to ${FAILOVER_SETTLE_S}s for a standby to promote" NEW_PRIMARY="" for _ in $(seq 1 "$FAILOVER_SETTLE_S"); do sleep 1 for n in ctrl-1 ctrl-2 ctrl-3; do [ "$n" = "$PRIMARY" ] && continue # A standby never logs become_leader before the kill, so ANY # CTRL_PRIMARY promotion line on a non-original-primary node is the # failover promotion (the first_boot flag varies with whether the # standby recovered its WAL vs fell back to election-path recovery — # it is NOT a reliable promotion discriminator). if grep -q 'raft_cb_become_leader.*CTRL_PRIMARY' "$TOPDIR/logs/${n}.log" 2>/dev/null; then NEW_PRIMARY="$n" break fi done [ -n "$NEW_PRIMARY" ] && break done [ -n "$NEW_PRIMARY" ] || fail "no standby promoted within ${FAILOVER_SETTLE_S}s after primary kill" log "new primary is $NEW_PRIMARY" # Confirm the new primary serves clients (cluster stays available). This is a # liveness nicety, not the core convergence assertion: post-failover the new # primary needs a moment to push config + accept clients, and the libxce # failover-to-new-primary probe is timing-sensitive on a loaded/overlayfs # sandbox (bug-924). The load-bearing assertions are (a) a standby PROMOTED # (already verified from logs above), (b) walcompare convergence, and (c) every # job survives (expect-job). So a flaky serve probe is logged as a WARN, not a # hard failure — the authoritative liveness signal is the log-confirmed # promotion + the job-presence check at step 6b. SERVE_OUT="$(printf 'set masters %s\nset myname failover-test\nclient\nwait-primary %s\nquit\n' \ "$MASTERS_LIVE" "$WAIT_PRIMARY_MS" | xtest)" grep -q 'serving' <<<"$SERVE_OUT" \ || log "WARN: post-failover wait-primary probe did not observe 'serving' within ${WAIT_PRIMARY_MS}ms (timing-sensitive; the authoritative checks are the log-confirmed promotion + expect-job at step 6b)" # ── 5. RESTART the old primary; it must rejoin + catch up ───────────────────── log "restarting old primary $PRIMARY" start_node "$PRIMARY" sleep "$CATCHUP_SETTLE_S" [ -n "$(node_pid "$PRIMARY")" ] || fail "restarted primary $PRIMARY did not come up" # 849 (recovery-on-promotion) / 848 (advertise real recovered LSN): the restarted # node must recover its durable floor and advertise it, not 0. grep -q 'persist_recover done' "$TOPDIR/logs/${PRIMARY}.log" \ || fail "restarted $PRIMARY did not log persist_recover (bug-849 recovery-on-promotion)" # 850 (contiguity, no forward gap): no member may report a WAL gap. # ── 6a. walcompare: committed prefix identical (term, pcrc) ─────────────────── # Derive the committed boundary explicitly = the number of jobs' committed # entries we KNOW were acked before the kill. Each clean jobsub produces a # JOB_SUBMIT (the duplicate-submit bug-845 doubles entries, but the FIRST N_JOBS # distinct submits are committed). Use the minimum recovered tip as a floor and # the known acked count; pick the conservative explicit boundary (5.3 advisory: # never let a CRC-broken short tail lower the shared boundary and mask a # divergence). Here we assert the WHOLE pre-failover committed prefix. D1="$TOPDIR/controller/ctrl-1/data" D2="$TOPDIR/controller/ctrl-2/data" D3="$TOPDIR/controller/ctrl-3/data" # Committed boundary: the own-term noop (LSN 1) + the committed JOB_SUBMIT # entries. With bug-845 doubling each submit, N_JOBS submits => 1 + 2*N_JOBS # committed entries pre-failover. Compute and pass it EXPLICITLY. COMMITTED_BOUNDARY="${XCE_FAILOVER_COMMITTED:-$((1 + 2 * N_JOBS))}" log "walcompare --committed $COMMITTED_BOUNDARY across all members" WALCMP_OUT="$(printf 'walcompare --committed %s %s %s %s\nquit\n' \ "$COMMITTED_BOUNDARY" "$D1" "$D2" "$D3" | xtest)" WALCMP_RC=$? printf '%s\n' "$WALCMP_OUT" >"$TOPDIR/walcompare.out" if [ "$STRICT_TERM" = "1" ]; then # Strict term-identity gate (the assertion the live-send retirement must # satisfy). Requires the actual per-entry term to be identical across # members, not merely payload + legacy-0-tolerant. grep -q 'committed prefixes identical' <<<"$WALCMP_OUT" \ || fail "walcompare reported committed-prefix divergence (strict term mode); see $TOPDIR/walcompare.out" # Additionally assert the standbys actually recorded the leader's NON-ZERO # term for the committed prefix (catches the legacy-0 masking). for d in "$D2" "$D3"; do TERMS="$(printf 'waldump %s\nquit\n' "$d" | xtest \ | awk '/JOB_SUBMIT/ {print $3}')" # column 3 == TERM grep -qE '^[1-9]' <<<"$TERMS" \ || fail "standby $d still records term 0 for committed entries — live-send retirement not in place (see KNOWN BLOCKER)" done else # Payload-prefix mode (default; passes today, guards 848/849/850 + no job # loss). walcompare's legacy-0 tolerance means term-0 standby entries do not # diverge from a term-1 fresh-cluster prefix. grep -q 'committed prefixes identical' <<<"$WALCMP_OUT" \ || fail "walcompare reported committed-prefix divergence; see $TOPDIR/walcompare.out" fi [ "$WALCMP_RC" -eq 0 ] || fail "walcompare exited non-zero (rc=$WALCMP_RC); see $TOPDIR/walcompare.out" log "walcompare: committed prefix identical" # ── 6b. every acked-committed job still present (R5.1 / R6.4) ───────────────── log "verifying all ${#JOBIDS[@]} submitted jobs survive the failover" EXPECT_SCRIPT="set masters $MASTERS_LIVE set myname failover-test client wait-primary $WAIT_PRIMARY_MS" for j in "${JOBIDS[@]}"; do EXPECT_SCRIPT+=" expect-job $j" done EXPECT_SCRIPT+=" quit" EXPECT_OUT="$(printf '%s\n' "$EXPECT_SCRIPT" | xtest)" EXPECT_RC=$? printf '%s\n' "$EXPECT_OUT" >"$TOPDIR/expect.out" [ "$EXPECT_RC" -eq 0 ] \ || fail "an acked-committed job was lost across the failover (expect-job failed); see $TOPDIR/expect.out" log "PASS: clean failover converged; committed prefix identical; no job lost" cleanup trap - EXIT INT TERM exit 0