myxce/src/tools/test-commit-durability.sh
Rongsong Shen ee8473bd99 feat(admin-config-tool): drop ha/data/ entirely, match real client TLS
deploy-ha.sh no longer takes a <data-dir> argument or --build-config
flag. Every deploy now generates a fresh self-signed TLS cert/key
(openssl, RSA 2048, 10y validity) directly into the deploy dir's
certs/, and builds cluster.bin directly into controller/ctrl-1/conf/
via xceadmin -- no intermediate staging directory, nothing preserved
across runs. run-ha-soak.sh (/xce-test-ha) updated to match: dropped
its own XCE_HA_DATADIR-based precondition entirely. Updated 4 callers
(run-ha-soak.sh, test-heartbeat-tolerance.sh, test-commit-durability.sh,
both xce-deploy-ha/xce-test-ha SKILL.md docs).

Separately: xcecommon.wire.connect() was loading a client-side TLS
cert, diverging from the real reference client -- netio.c's
create_ssl_context() presents no certificate at all when acting as a
client (cert_file/key_file both NULL, bare TLS_client_method()).
Fixed connect() to match, and since nothing else consumed cert_dir
(verify_mode=CERT_NONE throughout, no CA check either), removed it
end-to-end: discovery.follow_redirects(), push.push_config()/
probe_config(), and the --cert-dir CLI flag.

Server-side cert loading (mock TLS test servers) is unaffected --
moved into a shared session-scoped tls_certs fixture (new
tests/conftest.py, openssl-generated) replacing 3 duplicated
REPO_HA_DATA_DIR/skipif blocks. Net result: those tests no longer skip
under a hermetic nix build sandbox (which never had ha/data/) --
verified 165/165 passed, 0 skipped, via both meson compile and a full
nix build --option sandbox false .#xcecore_debug rebuild (previously
128 passed / 37 skipped there).

ha/data/{xce.crt,xce.key} removed from git tracking. Requirements
2.3/2.4 and 8.3/8.4/8.5 marked superseded in requirements.md with
dated correction notes.
2026-07-22 14:50:51 +00:00

764 lines
37 KiB
Bash
Executable file

#!/bin/bash
# test-commit-durability.sh — bug-661 reproduction: committed jobs lost across failover.
#
# Spec: ha-commit-durability (.kiro/specs/ha-commit-durability/)
# Reproduces the manual /kiro-discovery session of 2026-06-17 where a 3-node
# controller cluster, driven by xce_test under rolling primary kills, PERMANENTLY
# lost jobs that had already returned "jobsubmit ok" (committed) — joblist dropped
# from 10 jobs to 3.
#
# Root cause (bug-661): "committed" != "durable". WAL fsync is asynchronous
# (persist.c fsync_worker, interval wal_sync_ms); the standby ACKs a WAL entry
# before fdatasync, and commit.c advances committed_lsn on majority *ACK* (page
# cache only). A SIGKILL during the async window drops the un-fsync'd tail; on
# restart persist_recover hits a CRC break and replays ~0 entries, yet the hollow
# node can still win election and overwrite the good timeline.
#
# THE INVARIANT UNDER TEST (Requirement 1, durable commit):
# Any job that returned "jobsubmit ok" MUST remain visible in joblist for the
# remainder of the run, across any sequence of single-node SIGKILL + restart.
# The cluster never loses a committed job (n_failures tolerated = floor(N/2) = 1).
#
# ── soak mode: A SOAK / RACE REPRODUCTION, NOT A DETERMINISTIC ONE ─────────────
# bug-661 is a commit-durability RACE: wal_sync_ms defaults to 10ms
# (config.h DEFAULT_WAL_SYNC_MS), so the committed-but-not-yet-fsync'd window is
# ~10ms wide. Loss only occurs when a SIGKILL lands inside that window on enough
# nodes. This soak fires bursts and kills with no delay across many rounds to hit
# it; empirically it surfaces loss within a handful of rounds but NOT on every
# single round.
#
# Interpret soak results asymmetrically:
# FAIL ("DATA LOSS") => bug-661 PROVEN (a committed job vanished). Definitive.
# PASS (all rounds) => the race did not fire this run; NOT proof of durability.
# Re-run or raise ROUNDS. After the ha-commit-durability
# fix (fsync-before-ACK), it MUST pass deterministically
# regardless of timing — that is the real acceptance bar.
#
# ── deterministic mode: WHY IT PROVES THE GAP, NOT SIGKILL DATA-LOSS ───────────
# SCOPE CLARIFICATION (ratify with reviewer). The task asks for a deterministic
# mode that "reports DATA LOSS without relying on race timing". But on a SINGLE
# HOST, committed-job DATA LOSS via SIGKILL is PHYSICALLY NON-DETERMINISTIC, for
# two independent reasons rooted in the code:
# 1. persist_wal_async (persist.c:340) pwrites entry bytes INLINE (persist.c
# :375,383) — they reach the OS PAGE CACHE immediately; only fdatasync is
# deferred to fsync_worker. A SIGKILL does NOT evict page-cache pages: the
# kernel still flushes them to disk, so an un-fdatasync'd pwrite'd entry is
# STILL recoverable after SIGKILL+restart on one host.
# 2. Each STANDBY fdatasyncs BEFORE sending its WalAck (persist.c:511-515), so
# every committed job already has a durable copy on the standbys; killing
# the primary alone never loses it. The incident's loss needed multi-node
# kills + a hollow node re-leading before catch-up repair — inherently racy
# (a prior rolling-primary-kill "deterministic" attempt was REJECTED: the
# same command gave GREEN/RED/RED across 3 runs — it just swapped the ~10ms
# fsync race for a hollow-re-lead-vs-catch-up race).
#
# The bug-661 invariant that IS deterministic is the DURABILITY GAP on the
# PRIMARY (src/controller/CLAUDE.md "Commit Durability" TARGET RULE; Req 1): when
# the controller replies "jobsubmit ok", the entry must already be fdatasync'd on
# a fault-tolerant majority INCLUDING THE PRIMARY's own copy. On the unfixed build
# the primary defers fdatasync to the fsync_worker timer (every --wal-sync-ms ms),
# so with a LARGE --wal-sync-ms the primary pwrites but performs ZERO fdatasync
# between append and ack. THAT gap is deterministic and observable WITHOUT any
# race: launch each controller under `strace -f -tt -e trace=fdatasync,fsync` and
# assert that NO primary fdatasync/fsync occurs in the wall-clock window between
# job submit and the "jobsubmit ok" reply. RED on unfixed build = ack-before-
# primary-durable. After the fsync-before-ACK fix, a primary fdatasync MUST
# precede the ack -> GREEN. This verdict is identical on every run (5/5 RED on the
# unfixed build); it does not depend on timing a kill.
#
# Usage:
# bash src/tools/test-commit-durability.sh [deploy-dir] [rounds]
# bash src/tools/test-commit-durability.sh --deterministic [deploy-dir]
#
# deploy-dir : cluster root (default /tmp/xce-ha-deploy). Deployed if absent.
# rounds : number of kill+restart failover rounds (default 8) — soak mode only.
#
# --deterministic : run the DETERMINISTIC durability-gap proof (strace-based,
# above) instead of the racy soak. Requires `strace` on PATH (ptrace exec-wrap
# only — does NOT require PTRACE_ATTACH, which is blocked under
# yama/ptrace_scope=1 in containers). On the CURRENT (unfixed) build it
# reports RED (no primary fdatasync precedes the ack). After the fix it must
# go GREEN. See run_deterministic() below.
#
# Requires a current build: run /xce-build controller first.
set -u
# ── mode selection ────────────────────────────────────────────────────────────
# Default = original racy soak (back-compat for existing callers). Pass
# --deterministic as the FIRST arg to select the deterministic reproduction.
MODE="soak"
if [ "${1:-}" = "--deterministic" ] || [ "${1:-}" = "--det" ]; then
MODE="deterministic"
shift
fi
WORKTREE=$(cd "$(dirname "$0")/../.." && pwd)
BINARY="$WORKTREE/src/xce.build/controller/xce_controller"
XCE_TEST="$WORKTREE/src/xce.build/controller/xce_test"
DEPLOY_DIR="${1:-/tmp/xce-ha-deploy}"
ROUNDS="${2:-8}"
PEERS="localhost:9991,localhost:9992,localhost:9993"
MASTERS="localhost:9991,localhost:9992,localhost:9993"
LOGDIR="$DEPLOY_DIR/logs"
NODES=(ctrl-1 ctrl-2 ctrl-3)
QUEUE="${QUEUE:-xce}"
JOBUSER="${JOBUSER:-rshen}"
# Jobs submitted per round (before the kill) and after the new primary settles.
JOBS_BEFORE_KILL="${JOBS_BEFORE_KILL:-3}"
JOBS_AFTER_KILL="${JOBS_AFTER_KILL:-1}"
# Deterministic mode: a LARGE fsync cadence so the primary never fdatasyncs a
# committed entry inside the test window. With this the committed-but-not-durable
# window is the entire run, not a ~10ms race — so an strace of the primary shows
# ZERO fdatasync between a job submit and its "jobsubmit ok" ack. Plumbed into the
# controller launch via the generated start.sh ("$*" passthrough) as --wal-sync-ms.
DET_WAL_SYNC_MS="${DET_WAL_SYNC_MS:-30000}"
# When non-empty, start_node appends these args to start.sh (e.g. --wal-sync-ms).
EXTRA_CTRL_ARGS=""
# Where we remember every JobId the cluster acknowledged. Kept in the worktree
# (not /tmp) so it survives sandbox /tmp teardown between background-poll cycles.
ACKED_FILE="$(mktemp "$WORKTREE/.xce-durability-acked.XXXXXX")"
PASS=0
FAIL=0
INCONC=0 # durability could not be assessed (cluster not serving) — NOT data loss
green() { printf '\033[32m%s\033[0m\n' "$*"; }
red() { printf '\033[31m%s\033[0m\n' "$*"; }
yellow() { printf '\033[33m%s\033[0m\n' "$*"; }
pass() { green " PASS: $*"; PASS=$((PASS+1)); }
fail() { red " FAIL: $*"; FAIL=$((FAIL+1)); }
# A LIVENESS/AVAILABILITY failure is a real fault, but it is NOT a bug-661
# durability violation — keep it a distinct verdict so it is never mislabeled
# "DATA LOSS". inconc() = durability inconclusive (cluster unreachable), reported
# but kept separate from both PASS and DATA-LOSS so one outage does not cascade
# into a pile of false "committed job vanished" lines.
inconc() { yellow " INCONCLUSIVE: $*"; INCONC=$((INCONC+1)); }
cleanup_exit() { rm -f "$ACKED_FILE"; kill_cluster 2>/dev/null || true; }
trap cleanup_exit EXIT
# ── cluster lifecycle ─────────────────────────────────────────────────────────
deploy_cluster() {
if [ ! -f "$BINARY" ] || [ ! -f "$XCE_TEST" ]; then
red "Binary not found: $BINARY / $XCE_TEST"
red "Run /xce-build controller first."
exit 2
fi
yellow "Deploying 3-node cluster to $DEPLOY_DIR ..."
bash "$WORKTREE/src/tools/deploy-ha.sh" "$BINARY" "$DEPLOY_DIR" "$PEERS"
}
start_node() {
# EXTRA_CTRL_ARGS (e.g. --wal-sync-ms <N>) is passed through start.sh's "$*".
#
# STRACE_TRACE=1 (deterministic mode) exec-wraps the controller under strace so
# its fdatasync/fsync syscalls are timestamped into $LOGDIR/<node>.strace.log.
# We MUST use ptrace exec-wrap (strace launches the child) — PTRACE_ATTACH
# (strace -p <pid>) is blocked under yama/ptrace_scope=1 in containers. -f
# follows the fsync_worker pthread; -tt gives HH:MM:SS.uuuuuu wall-clock so the
# parser can window syscalls to the submit→ack interval. execve on the
# controller's self-re-exec (ha_convergence) keeps the tracer attached.
if [ -n "${STRACE_TRACE:-}" ]; then
VERBOSE=-vvv strace -f -tt -e trace=fdatasync,fsync \
-o "$LOGDIR/$1.strace.log" \
"$DEPLOY_DIR/scripts/start.sh" "$1" $EXTRA_CTRL_ARGS >/dev/null 2>&1 &
else
VERBOSE=-vvv "$DEPLOY_DIR/scripts/start.sh" "$1" $EXTRA_CTRL_ARGS >/dev/null 2>&1 &
fi
}
start_cluster() {
kill_cluster 2>/dev/null || true
sleep 0.3
start_node ctrl-1; sleep 0.5
start_node ctrl-2; sleep 0.5
start_node ctrl-3; sleep 0.3
}
kill_cluster() {
# Exact process-name match (-x): never matches this script's own cmdline,
# which contains the literal "xce_controller" in its grep/pgrep patterns.
pkill -9 -x xce_controller 2>/dev/null || true
sleep 0.5
}
node_pid() { pgrep -f "xce_controller.*--myname $1" 2>/dev/null | head -1; }
# SIGKILL a single named node (drops any WAL tail not yet fsync'd by the timer).
kill_node() {
local pid; pid=$(node_pid "$1")
[ -n "$pid" ] && kill -9 "$pid" 2>/dev/null || true
sleep 0.5
}
# Last role line in a node's log is its current role (tolerates log append/rotate).
node_role() {
grep -aoE 'CTRL_PRIMARY|CTRL_STANDBY|CTRL_ELECTING' "$LOGDIR/$1.log" 2>/dev/null | tail -1
}
# The live node whose most recent role transition is CTRL_PRIMARY.
current_primary() {
local n
for n in "${NODES[@]}"; do
[ -n "$(node_pid "$n")" ] || continue
[ "$(node_role "$n")" = "CTRL_PRIMARY" ] && { echo "$n"; return 0; }
done
return 1
}
# NOTE: serve-readiness ("is the cluster serving yet") is no longer decided by
# polling controller logs. It is now an xce_test exit-code gate via the
# `wait-primary` primitive (see wait_primary_primitive below). The log helpers
# above (node_role / current_primary / node_pid) survive ONLY for node-lifecycle
# decisions the shell still owns: picking which node to SIGKILL and locating a
# node's strace log — never for a pass/fail verdict.
# ── xce_test primitive drivers (piped stdin; exit code IS the verdict) ─────────
#
# Every cluster interaction now goes through an `xce_test` script whose PROCESS
# EXIT CODE is the pass/fail verdict (xce-test-e2e-primitives Req 1). The shell
# never parses joblist/jobsub prose for a verdict anymore — it pipes a script,
# checks `$?`, and (for jobsub) reads the tool's own authoritative captured id
# off an `echo $jobid` line after the clean-ack gate already passed.
#
# ASAN_OPTIONS=detect_leaks=0 on every invocation: the client has a known
# out-of-scope leak (design.md note); the exit-code verdict is only trustworthy
# with a clean exit, so leak detection must be off for the verdict to be valid.
# Run a piped xce_test script; return its exit code (the verdict). The script is
# passed on stdin; readline reads to EOF. xce_test does its own client-side
# master rotation, so a single invocation rides through a failover.
xce_test_script() {
local script="$1"
printf '%s' "$script" \
| ASAN_OPTIONS=detect_leaks=0 timeout 60 "$XCE_TEST"
}
# Serve-readiness gate (Req 4): block until a client op succeeds against the
# configured masters (or time out). Replaces the old log-polling wait_for_primary
# for the "is the cluster serving yet" decision. Returns 0 on success.
# Arg: timeout in ms (default 40000).
wait_primary_primitive() {
local timeout_ms="${1:-40000}"
xce_test_script "$(printf 'set masters %s\nclient\nwait-primary %s\n' \
"$MASTERS" "$timeout_ms")"
}
# Cluster HEALTH gate (NOT just "a primary serves"): every node process is back
# up (lifecycle via node_pid) AND a client op succeeds (wait-primary). This is
# the precondition for (a) killing the next primary — so we never SIGKILL down to
# a single node and induce a self-inflicted quorum loss the test then misreports
# as bug-661 — and (b) trusting a durability assertion. A rejoined node that has
# not finished catching up is the usual cause of the next round losing quorum, so
# we also settle briefly once all three are up. Returns 0 iff healthy in time.
# Args: <timeout_s (default 45)>.
wait_cluster_healthy() {
local timeout_s="${1:-45}" deadline n alive
deadline=$(( $(date +%s) + timeout_s ))
while [ "$(date +%s)" -lt "$deadline" ]; do
alive=0
for n in "${NODES[@]}"; do [ -n "$(node_pid "$n")" ] && alive=$((alive+1)); done
if [ "$alive" -eq "${#NODES[@]}" ] \
&& wait_primary_primitive 5000 >/dev/null 2>&1; then
sleep "${REJOIN_SETTLE_S:-5}" # let the rejoined node finish catch-up
return 0
fi
sleep 1
done
return 1
}
# Submit one job; echo the acked JobId (empty on failure). The ack VERDICT is the
# xce_test EXIT CODE (clean ack carrying a valid JobId => exit 0; bug-786 fix
# guarantees `jobsub` never reports "ok" with a malformed/uninitialized id and
# returns non-zero otherwise). On exit 0 we read the id from the `echo $jobid`
# line — that is the TOOL's authoritative captured id (after clean-ack gating),
# NOT a prose-grep of "jobsubmit ok". No more malformed-id grep.
xce_submit_one() {
local out rc id
# `jobsub` sets $jobid only on a clean ack; we then `echo` it on a uniquely
# marked line so the shell captures EXACTLY the tool's authoritative id
# (whatever fmt_jobid form it used — non-array N-N-N-N or array N-N-N-N[i]),
# format-agnostically, instead of re-imposing a brittle id regex. The marker
# and $jobid are SEPARATE whitespace-delimited tokens: $jobid is only
# substituted as a WHOLE token (a `$` glued to other text, e.g.
# `MARK=$jobid`, is NOT expanded), so we keep them apart and echo prints
# both. If the submit did not cleanly ack, jobsub fails -> session non-zero
# -> discard.
out=$(xce_test_script "$(printf 'set masters %s\nclient\njobsub -q %s -u %s -c "sleep 1000"\necho ACKED_JOBID $jobid\n' \
"$MASTERS" "$QUEUE" "$JOBUSER")")
rc=$?
[ "$rc" -ne 0 ] && return 1
# Exit 0 == clean ack. Pull the id off the marked line (second field, last
# such line wins). The id is whatever echo emitted after the marker token.
id=$(echo "$out" | sed -n 's/^ACKED_JOBID //p' | tail -1)
[ -z "$id" ] && return 1
echo "$id"
}
submit_n() {
local n=$1 i jid
for i in $(seq 1 "$n"); do
jid=$(xce_submit_one)
if [ -n "$jid" ]; then
echo "$jid" >> "$ACKED_FILE"
echo " submitted (acked): $jid"
else
yellow " submit did not cleanly ack (cluster mid-failover) — not counted"
fi
# Tight burst: keep the freshly-committed tail un-fsync'd when the kill
# lands (the ~10ms wal_sync_ms window). No long inter-submit delay.
done
}
# Build ONE xce_test script: `expect-job <id>` per acked id, gate the WHOLE
# no-loss assertion on the single exit code (Req 5, Req 8.3). No joblist prose
# membership parsing — `expect-job` fails the session (non-zero) on the first
# missing committed id and reports it. Returns 0 iff all acked jobs survive.
assert_no_loss() {
local label="$1" total script rc
total=$(sort -u "$ACKED_FILE" | grep -c . || true)
if [ "$total" -eq 0 ]; then
# Nothing acked yet (e.g. baseline before any clean ack) — vacuously OK.
pass "$label: no committed jobs yet (nothing to lose)"
return 0
fi
# DURABILITY IS ONLY ASSERTABLE AGAINST A SERVING PRIMARY. If the cluster is
# not serveable, `expect-job <id> not found` cannot tell a genuinely lost job
# from a merely-unreachable primary — reporting "DATA LOSS / bug-661" there is
# a FALSE verdict. Probe first; if the cluster is down, the result is
# INCONCLUSIVE (a liveness problem the caller already flags), NOT data loss.
if ! wait_primary_primitive "${SERVE_PROBE_MS:-20000}" >/dev/null 2>&1; then
inconc "$label: cluster not serving — durability NOT assessed (liveness failure, not data loss)"
return 2
fi
# set masters / client first, then one expect-job per unique acked id.
# Accumulate with explicit $'\n' — command substitution strips trailing
# newlines, so `script+=$(printf ...\n)` would concatenate lines into one
# malformed token.
script="set masters ${MASTERS}"$'\n'"client"$'\n'
local jid
while read -r jid; do
[ -z "$jid" ] && continue
script+="expect-job ${jid}"$'\n'
done < <(sort -u "$ACKED_FILE")
xce_test_script "$script"
rc=$?
if [ "$rc" -eq 0 ]; then
pass "$label: all $total committed jobs present after failover"
return 0
fi
# First check failed — a committed job is missing from a serving primary. To
# avoid mislabeling a momentary post-failover CATCH-UP LAG (a just-elected
# primary still applying replicated entries) as a permanent loss, CONFIRM the
# absence: settle, re-confirm the cluster is healthy, and re-run the check.
# bug-661's invariant is that an acked job stays visible — a job still absent
# after a generous settle is a genuine durability violation; one that
# reappears was transient catch-up, not loss.
yellow " $label: a committed job is missing — confirming (could be catch-up lag) ..."
sleep "${LOSS_CONFIRM_SETTLE_S:-8}"
wait_cluster_healthy 30 || true
xce_test_script "$script"
rc=$?
if [ "$rc" -eq 0 ]; then
pass "$label: all $total committed jobs present after a catch-up settle (transient lag, not loss)"
return 0
else
fail "$label: DATA LOSS — a committed job is PERSISTENTLY absent from a SERVING cluster after a confirm settle (bug-661); see expect-job output above"
return 1
fi
}
# Assert the cluster currently lists EXACTLY N jobs (Req 5.1/5.2), gated on the
# xce_test exit code. Used by the cold-restart-all recovery phase. Returns 0 iff
# the live count equals N.
#
# Retries up to 3 times (2s settle sleep between) before declaring a mismatch:
# bug-1396's original "extra job" observation and a fresh repro of this exact
# check both trace to the SAME client-side race as bug-1422 (see
# .wolf/buglog.json) — right after a fresh election (e.g. this phase's cold
# restart), libxce's failover retry loop can exhaust its budget without ever
# reaching the new primary and surface a stale E_XCE_STANDBY from whichever
# standby it last hit, which reads here as "xce_joblist: Unknown error 10014"
# / "could not determine job count" rather than a real count. Confirmed via a
# live repro: the aggregate expect-jobs call failed this way while the very
# next per-id assert_no_loss check (16 individual expect-job calls) found
# every job present. A count still wrong after a real settle + 2 retries is a
# genuine mismatch, not this race.
assert_job_count() {
local label="$1" expected="$2" rc attempt
for attempt in 1 2 3; do
xce_test_script "$(printf 'set masters %s\nclient\nexpect-jobs %s\n' \
"$MASTERS" "$expected")"
rc=$?
if [ "$rc" -eq 0 ]; then
pass "$label: cluster lists exactly $expected jobs"
return 0
fi
if [ "$attempt" -lt 3 ]; then
yellow " $label attempt $attempt/3: count check failed (likely post-election client retry-storm race, bug-1422 class) — settling 2s and retrying"
sleep 2
fi
done
# bug-1396: a bare count mismatch isn't enough to diagnose an extra-job
# anomaly — dump the live joblist and the harness's own ACKED_FILE
# ledger side by side so the diverging job ID is visible in the log
# instead of only the count.
yellow " $label: job count mismatch after 3 attempts — dumping live joblist vs ACKED_FILE for diagnosis"
xce_test_script "$(printf 'set masters %s\nclient\njoblist\n' "$MASTERS")"
echo " --- ACKED_FILE (tracked committed jobs, deduplicated) ---"
sort -u "$ACKED_FILE" | sed 's/^/ /'
fail "$label: job count != $expected after recovery (see joblist/ACKED_FILE dump above)"
return 1
}
# ── soak mode (original racy reproduction) ────────────────────────────────────
run_soak() {
echo ""
yellow "=== bug-661 SOAK: committed-job durability across rolling failover ==="
echo " deploy=$DEPLOY_DIR rounds=$ROUNDS queue=$QUEUE (racy ~10ms wal_sync_ms window)"
echo ""
bash "$DEPLOY_DIR/scripts/cleanup.sh" >/dev/null 2>&1 || true
start_cluster
# Serve-readiness is now an xce_test exit-code gate (Req 4), not log polling:
# wait-primary blocks until a client op succeeds against the masters.
if ! wait_primary_primitive 40000; then
fail "cluster did not become serveable (wait-primary timed out) within 40s"
exit 1
fi
# current_primary reads logs ONLY to pick which node to SIGKILL — node
# lifecycle, allowed in the shell. The "is it serving" decision above was
# wait-primary, not log prose.
green "Initial primary (for victim selection): $(current_primary || echo '?')"
# Seed a baseline batch so there is committed state before the first kill.
echo ""
echo "--- seeding initial committed jobs ---"
submit_n 4
assert_no_loss "baseline" || true # record, continue even if already broken
# ── rolling failover rounds ───────────────────────────────────────────────
local round victim vpid
for round in $(seq 1 "$ROUNDS"); do
echo ""
echo "--- round $round/$ROUNDS ---"
# Only kill into a HEALTHY cluster (all 3 nodes up + serving). Killing the
# current primary while the previous round's restarted node has not yet
# rejoined would drop the live set to ONE node (no quorum) — a
# self-inflicted availability loss the soak must not cause, and must never
# misreport as durability loss. If the cluster never gets healthy, that is
# a real LIVENESS failure (recorded once); skip this round's kill+assert
# rather than cascading false DATA-LOSS lines.
if ! wait_cluster_healthy 60; then
fail "round $round: cluster not healthy (3 nodes up + serving) before kill — LIVENESS failure, not data loss"
continue
fi
submit_n "$JOBS_BEFORE_KILL"
# Pick the victim from log role (node lifecycle); fall back to round-robin.
victim=$(current_primary || true)
[ -z "$victim" ] && victim="${NODES[$(( round % 3 ))]}"
vpid=$(node_pid "$victim")
if [ -z "$vpid" ]; then
yellow " no live victim found; skipping kill this round"
else
# SIGKILL via `!`-shell-escape inside an xce_test script (Req 2): the
# script confirms the cluster is serveable, then escapes to the shell
# to drop the primary. SIGKILL (not SIGSTOP) drops any WAL tail not
# yet fsync'd by the timer. Gate on the xce_test exit code.
echo " SIGKILL primary $victim (pid $vpid) via !kill immediately after submit ..."
if ! xce_test_script "$(printf 'set masters %s\nclient\nwait-primary 5000\n!kill -9 %s\n' \
"$MASTERS" "$vpid")"; then
yellow " !kill script returned non-zero (kill may still have landed)"
fi
fi
# Let the surviving two elect a new primary and SERVE again (exit-code
# gate). Post-kill election SLA = ELECT_TIMEOUT_MS (default 60s; a loaded
# sandbox can run slow — this is a LIVENESS bound, not a durability one).
if ! wait_primary_primitive "${ELECT_TIMEOUT_MS:-60000}"; then
fail "round $round: cluster not serveable after killing $victim (>$(( ${ELECT_TIMEOUT_MS:-60000} / 1000 ))s) — LIVENESS failure, not data loss"
# keep going; assert_no_loss self-gates and reports INCONCLUSIVE, not loss
else
echo " new primary (for next victim): $(current_primary || echo '?')"
fi
sleep 2
# Submit against the new primary while one node is still down.
submit_n "$JOBS_AFTER_KILL"
# Restart the killed node via `!`-shell-escape (Req 2) so it rejoins
# (reproduces hollow-rejoin election). start.sh is the node-lifecycle
# entrypoint; we escape to it from inside an xce_test script.
if [ -n "${victim:-}" ]; then
echo " restarting $victim (rejoin) via !bash start.sh ..."
xce_test_script "$(printf '!VERBOSE=-vvv bash %s %s %s >/dev/null 2>&1 &\n' \
"$DEPLOY_DIR/scripts/start.sh" "$victim" "$EXTRA_CTRL_ARGS")" \
|| yellow " !bash start.sh returned non-zero"
fi
# Wait until the cluster is fully HEALTHY again (rejoined node back up +
# serving + caught up) before asserting durability — so the verdict
# reflects a real cluster, not a mid-recovery transient. If it never
# returns to health that is a LIVENESS failure (recorded distinctly);
# assert_no_loss then self-detects the non-serving state as INCONCLUSIVE
# rather than crying DATA LOSS.
if ! wait_cluster_healthy 60; then
fail "round $round: cluster did not return to healthy (3 up + serving) after $victim rejoined — LIVENESS failure, not data loss"
fi
assert_no_loss "round $round" || true
done
echo ""
echo "--- final durability check ---"
sleep 2
assert_no_loss "final" || true
# ── NEW: cold-restart-all + expect-jobs recovery phase (Req 8.3) ──────────
# A full cold stop + cold start of every node, then assert ALL committed
# jobs recover. This exercises the durability/recovery path end-to-end (a
# bug-778-class WAL-replay regression would surface as a count mismatch).
run_cold_restart_recovery
}
# Full cold restart of the cluster, then assert every acked job survives a
# from-disk recovery. The cold stop/start lifecycle is driven via `!`-shell-
# escape inside xce_test scripts (the task's `!`-driven cold-restart-all path,
# Req 2); the SERVE-readiness gate and the count assertion go through the
# primitives (wait-primary + expect-jobs / expect-job), exit-code-gated.
run_cold_restart_recovery() {
local committed
committed=$(sort -u "$ACKED_FILE" | grep -c . || true)
echo ""
yellow "=== cold-restart-all recovery: assert $committed committed jobs survive a full restart ==="
# Cold stop EVERY node via `!`-shell-escape (Req 2): we escape to the shell
# to kill the whole cluster, proving the `!`-driven cold path.
echo " cold stop (!kill all nodes) ..."
xce_test_script "$(printf '!pkill -9 -x xce_controller; sleep 1\n')" \
|| true # pkill exits 1 when nothing matched; harmless here.
sleep 1
# Cold start every node from disk via `!bash start.sh` (Req 2).
echo " cold start (!bash start.sh all nodes) ..."
local n
for n in "${NODES[@]}"; do
xce_test_script "$(printf '!VERBOSE=-vvv bash %s %s %s >/dev/null 2>&1 &\n' \
"$DEPLOY_DIR/scripts/start.sh" "$n" "$EXTRA_CTRL_ARGS")" \
|| yellow " !bash start.sh $n returned non-zero"
sleep 0.5
done
# SERVE-readiness after the cold restart (exit-code gate, generous timeout:
# a from-disk recovery + election can take longer than a live failover).
if ! wait_primary_primitive 60000; then
fail "cold-restart: cluster not serveable within 60s after full cold restart"
return 1
fi
sleep 3
# Assert ALL committed jobs recovered after the full cold restart. We check
# both the exact count (expect-jobs) and per-id membership (expect-job),
# each gated on the xce_test exit code — no joblist prose parsing.
assert_job_count "cold-restart" "$committed" || true
assert_no_loss "cold-restart" || true
}
# ── deterministic mode (timing-independent DURABILITY-GAP proof) ──────────────
#
# WHAT THIS PROVES (and why it is deterministic — see the header SCOPE
# CLARIFICATION for why single-host SIGKILL data-loss is NOT deterministic):
# With --wal-sync-ms=$DET_WAL_SYNC_MS (default 30s) the primary's fsync_worker
# timer never fires inside the test window, so EVERY entry the primary appends
# stays in the OS page cache, never fdatasync'd (persist_wal_async, the append
# path, does NOT fsync — only the timer does). Each controller runs under strace
# capturing fdatasync/fsync with wall-clock timestamps. We submit one job, record
# the wall-clock instant T0 just before submit and T1 the instant "jobsubmit ok"
# returns, then assert: ZERO fdatasync/fsync occurred on the PRIMARY in [T0,T1].
# That is the bug-661 durability gap — the controller acked an entry it had not
# made durable on its own disk (src/controller/CLAUDE.md "Commit Durability"
# TARGET RULE / Req 1). The standbys DO fdatasync before WalAck (persist.c:515),
# so they show syncs in-window; only the primary's missing sync is the defect.
#
# VERDICT IS RACE-FREE: it depends only on whether the primary issued an
# fdatasync before the ack returned, which is fixed by the code path (timer-
# deferred vs sync-before-ack), not by when a kill lands. Same result every run.
# RED (unfixed): 0 primary fdatasync/fsync in [T0,T1] -> ack-before-durable.
# GREEN (fixed): >=1 primary fdatasync/fsync in [T0,T1] preceding the ack.
#
# IMPLEMENTATION NOTE: strace ptrace exec-wrap only (start_node launches the
# controller under strace). PTRACE_ATTACH (strace -p) is blocked under
# yama/ptrace_scope=1 in containers, so we never attach to a running pid.
# Convert an strace -tt timestamp "HH:MM:SS.uuuuuu" (or a `date +%H:%M:%S.%N`
# capture) to seconds-of-day as a float. Pure awk; handles the day-wrap-free
# common case (a submit never straddles midnight in this harness).
_hms_to_sod() {
awk -F'[:.]' '{ printf "%d.%06d\n", ($1*3600+$2*60+$3), substr($4"000000",1,6) }' <<<"$1"
}
# Count PRIMARY fdatasync/fsync syscalls whose -tt timestamp falls in [T0,T1].
# Args: <strace-log> <T0 sod> <T1 sod>. Echoes the count.
_primary_syncs_in_window() {
local log="$1" t0="$2" t1="$3"
[ -f "$log" ] || { echo 0; return; }
# strace -f -tt line shape: "<pid> HH:MM:SS.uuuuuu fdatasync(<fd>) = 0"
awk -v t0="$t0" -v t1="$t1" '
/fdatasync|fsync/ {
ts=$2 # HH:MM:SS.uuuuuu
n=split(ts, a, /[:.]/)
if (n < 4) next
sod=a[1]*3600 + a[2]*60 + a[3] + a[4]/1000000.0
if (sod >= t0 && sod <= t1) c++
}
END { print c+0 }' "$log"
}
# One deterministic probe: submit a job, return RED/GREEN by counting primary
# fdatasync/fsync syscalls strictly inside the submit→ack window. Echoes a verdict
# line and sets PASS/FAIL. Returns 0 if RED (gap present), 1 if GREEN.
det_probe_once() {
local label="$1"
local primary primary_log t0 t1 t0_sod t1_sod jid syncs
primary=$(current_primary || true)
if [ -z "$primary" ]; then
fail "$label: no primary to probe"
return 2
fi
primary_log="$LOGDIR/$primary.strace.log"
if [ ! -f "$primary_log" ]; then
fail "$label: primary strace log missing ($primary_log) — strace exec-wrap failed?"
return 2
fi
# Capture the window with the SAME wall clock strace -tt uses (local time).
t0=$(date +%H:%M:%S.%N)
jid=$(xce_submit_one)
t1=$(date +%H:%M:%S.%N)
if [ -z "$jid" ]; then
yellow " $label: submit returned no JobId (cluster mid-failover?) — retrying once"
t0=$(date +%H:%M:%S.%N); jid=$(xce_submit_one); t1=$(date +%H:%M:%S.%N)
fi
if [ -z "$jid" ]; then
fail "$label: could not obtain a committed JobId to probe"
return 2
fi
echo "$jid" >> "$ACKED_FILE"
# Let strace flush the in-window syscalls (its output is line-buffered to file).
sleep 1
t0_sod=$(_hms_to_sod "$t0"); t1_sod=$(_hms_to_sod "$t1")
syncs=$(_primary_syncs_in_window "$primary_log" "$t0_sod" "$t1_sod")
echo " primary=$primary ack=$jid window=[$t0 .. $t1] primary fdatasync/fsync in-window=$syncs"
if [ "$syncs" -eq 0 ]; then
fail "$label: DURABILITY GAP — primary acked '$jid' with 0 fdatasync in-window (bug-661: ack-before-durable)"
return 0
else
pass "$label: primary fdatasync ($syncs) preceded ack of '$jid' (durable-before-ack)"
return 1
fi
}
run_deterministic() {
if ! command -v strace >/dev/null 2>&1; then
red "deterministic mode requires 'strace' on PATH (not found) — cannot run."
red "Re-run in an environment with strace, or use the soak mode."
exit 2
fi
EXTRA_CTRL_ARGS="--wal-sync-ms $DET_WAL_SYNC_MS"
STRACE_TRACE=1 # start_node will exec-wrap each controller under strace
local probes="${DET_PROBES:-3}"
echo ""
yellow "=== bug-661 DETERMINISTIC: ack-before-durable gap on the PRIMARY (strace) ==="
echo " deploy=$DEPLOY_DIR queue=$QUEUE probes=$probes --wal-sync-ms=$DET_WAL_SYNC_MS (no primary fsync in-window)"
echo " RED = primary acks a job with 0 fdatasync in the submit->ack window."
echo ""
bash "$DEPLOY_DIR/scripts/cleanup.sh" >/dev/null 2>&1 || true
# Clear stale strace logs so we never read a previous run's syscalls.
rm -f "$LOGDIR"/*.strace.log 2>/dev/null || true
start_cluster
# Serve-readiness via the exit-code primitive (Req 4), not log polling. The
# log-role current_primary below is used ONLY to locate the strace log of the
# primary to inspect — node lifecycle, allowed in the shell.
if ! wait_primary_primitive 40000; then
fail "cluster did not become serveable (wait-primary timed out) within 40s"
exit 1
fi
# Generous initial settle so the elected primary fully serves jobsub and its
# boot-time fdatasyncs (segment preallocation) are well before any probe window.
sleep 6
green "Initial primary (strace target): $(current_primary)"
# Repeat the probe several times against the stable primary. Every probe must
# be RED on the unfixed build — this is the in-run reproducibility evidence
# (the outer 5x driver gives cross-run evidence).
local i red_count=0
for i in $(seq 1 "$probes"); do
echo ""
echo "--- det probe $i/$probes ---"
if det_probe_once "probe $i"; then
red_count=$((red_count+1))
fi
sleep 1
done
echo ""
echo "--- deterministic summary ---"
echo " RED probes (durability gap observed): $red_count/$probes"
# The deterministic mode FAILS (exit non-zero) when the gap is present on the
# unfixed build (any RED probe). After the fix, all probes go GREEN and FAIL=0.
}
# ── setup + dispatch ──────────────────────────────────────────────────────────
[ -d "$DEPLOY_DIR" ] || deploy_cluster
if [ "$MODE" = "deterministic" ]; then
run_deterministic
else
run_soak
fi
# ── final verdict ─────────────────────────────────────────────────────────────
echo ""
echo "========================="
echo "Mode: $MODE"
echo "Results: ${PASS} passed, ${FAIL} failed, ${INCONC} inconclusive"
echo "Acked jobs total: $(sort -u "$ACKED_FILE" | grep -c .)"
if [ "${INCONC:-0}" -gt 0 ]; then
yellow "NOTE: ${INCONC} durability check(s) were INCONCLUSIVE because the cluster"
yellow " was not serving at the time — a LIVENESS/availability problem, NOT a"
yellow " bug-661 durability loss. 'FAIL' counts genuine faults only: a DATA LOSS"
yellow " is reported solely when a committed job is absent from a SERVING cluster."
fi
echo "========================="
# Exit non-zero on any genuine fault (real DATA LOSS on a serving cluster, or a
# cluster that never returned to health). Inconclusive durability checks do NOT
# by themselves fail the run, but a liveness failure that produced them is itself
# recorded as a FAIL above.
[ "$FAIL" -eq 0 ] && exit 0 || exit 1