test(ha-term-safety): clean-failover live harness; mark 5.2/5.4/5.5 blocked on bug-990 (live-send retirement)

Task 5.2 harness deploys a real 3-node localhost cluster, kills the primary,
promotes a standby, restarts the old primary, and asserts convergence via
walcompare --committed + per-job expect-job. Runs GREEN in PAYLOAD mode
(guards 848/849/850 + no committed-job loss).

The term-identity half of R5.1 is BLOCKED (bug-990): live replication still
uses term-0 repl_broadcast; the library send loop (on_send_append_entries) has
no live caller, so standbys persist hdr.term=0. The live-send retirement was
deferred across tasks 3.2/3.3 into verification tasks 5.2/5.4 that cannot
contain the production cutover. Needs a dedicated implementation task.
This commit is contained in:
Rongsong Shen 2026-06-22 13:27:21 +00:00
parent a166ecb45b
commit fda15336d5
2 changed files with 314 additions and 0 deletions

View file

@ -117,6 +117,7 @@
- Observable completion: the clean-failover test converges to identical `(term,lsn)` WALs and the 848/849/850 behaviors remain green
- _Requirements: 5.1, 5.2, 5.3, 5.4_
- _Depends: 3.3_
- _Blocked: bug-990 — the term-identity half of R5.1 cannot be GREEN because the LIVE replication SEND is still term-0 `repl_broadcast` (persist.c:747); the library send loop (`on_send_append_entries`→`repl_send_append_entries`) has ZERO live callers (no live `raft_send_append_entries` driver), so standbys persist hdr.term=0 and walcompare only passes by masking via legacy-0→WAL_LEGACY_TERM on a fresh single-term cluster. This is the live-send retirement DEFERRED across 3.2→3.3→"5.2/5.4" but never given an implementation task (5.2/5.4 are testsuite-boundary verification tasks that cannot contain the production cutover over repl.c/persist.c/ha.c). NEEDS a new impl task (e.g. "3.4 Retire term-0 repl_broadcast + drive the library send on the live primary"). Live deploy IS feasible here; harness `clean_failover_preservation.sh` is committed + runs GREEN in PAYLOAD mode (guards 848/849/850 + no job loss) and flips to the term gate via XCE_FAILOVER_STRICT_TERM=1 once the cutover lands._
- [x] 5.3 Cross-member `walcompare` divergence tool
- Build a tool that diffs member WALs by `(lsn → term, pcrc)` and exits non-zero on any committed-prefix divergence, reporting the diverging LSN and per-member entry identities; reuse the existing WAL decoder
@ -129,12 +130,14 @@
- Observable completion: the regression passes on the fixed code and fails on pre-fix code; no acked-committed job is absent; committed config reference identical across members
- _Requirements: 6.1, 6.2, 6.3, 6.4, 6.5, 6.6, 7.11_
- _Depends: 5.2, 5.3, 4.3_
- _Blocked: transitively on bug-990 (same live-send retirement gap as 5.2) — the R6 headline asserts `(term,lsn,pcrc)` committed-prefix identity, which the term-0 live send cannot satisfy. Unblocks once the live-send cutover lands and 5.2 passes._
- [ ] 5.5 Full controller build + suite green under ASAN
- Run `/xce-build controller`; the controller compiles and links (build restored) and all controller unit/integration tests plus the adapter tests pass under ASAN with no regression in election/durability behavior
- Observable completion: controller test binary exits 0; ASAN clean; clean-failover and durability suites still green
- _Requirements: 1.1, 2.3, 3.1, 4.1, 5.1, 6.1, 7.1_
- _Depends: 5.1, 5.2, 5.4_
- _Blocked: depends on 5.2 + 5.4 (both blocked on bug-990). The controller-build + unit-suite-green-under-ASAN portion is independently runnable TODAY (174 Tests 0 Failures on the direct binary; bug-924 overlayfs caveat for the meson harness), but the "clean-failover suite green" acceptance requires 5.2. Run the build/unit gate after the live-send cutover lands._
## Implementation Notes

View file

@ -0,0 +1,311 @@
#!/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 <C>` 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 margin for failover.
FAILOVER_SETTLE_S="${XCE_FAILOVER_SETTLE_S:-20}"
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"
# ── 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
if grep -q 'raft_cb_become_leader.*CTRL_PRIMARY.*first_boot=0' "$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" "$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
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