myxce/src/tools/test-heartbeat-tolerance.sh
Rongsong Shen e56b2064de test(ha-heartbeat-liveness): SIGSTOP/SIGCONT tolerance integration test (task 4.5)
Add src/tools/test-heartbeat-tolerance.sh: automated bash script that
deploys a 3-node cluster and runs two SIGSTOP/SIGCONT scenarios:

  Scenario A (1 × heartbeat_interval_ms pause): verifies no RDHUP fires on
  standbys — liveness_poll_fn must not trigger failover for a short silence
  within the tolerance window (Req 2.2).

  Scenario B (heartbeat_timeout_ms + 500ms pause): verifies exactly one RDHUP
  fires on each standby — the activity-based poll fires after sustained silence
  and does not loop (Req 2.3, Req 5.4).

Add .kiro/specs/ha-heartbeat-liveness/manual-tests.md: step-by-step manual
procedure, expected log patterns, and results template for both scenarios.

Mark task 4.5 complete in tasks.md.
2026-06-03 04:18:56 +00:00

289 lines
9.4 KiB
Bash
Executable file
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/bin/bash
# test-heartbeat-tolerance.sh — Task 4.5: missed-heartbeat tolerance via SIGSTOP/SIGCONT
#
# Requirements: 2.2, 2.3
#
# Two scenarios:
# Scenario A — short pause (1 × heartbeat_interval_ms): no failover fires
# Scenario B — long pause (heartbeat_timeout_ms + 500ms): failover fires exactly once
#
# Usage:
# bash src/tools/test-heartbeat-tolerance.sh [deploy-dir]
#
# deploy-dir defaults to /tmp/xce-deploy. The script expects a deployed
# 3-node cluster (via deploy-ha.sh + start.sh). It deploys one if the
# binary exists at the default location.
set -e
WORKTREE=$(cd "$(dirname "$0")/../.." && pwd)
BINARY="$WORKTREE/src/xce.build/controller/xce_controller"
DATADIR="$WORKTREE/ha/data"
DEPLOY_DIR="${1:-/tmp/xce-deploy}"
PEERS="localhost:9991,localhost:9992,localhost:9993"
LOGDIR="$DEPLOY_DIR/logs"
# Defaults from ha.h
HB_INTERVAL_MS=3000
HB_TIMEOUT_MS=10000
PASS=0
FAIL=0
# ── colour helpers ────────────────────────────────────────────────────────────
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)); }
# ── cluster lifecycle ─────────────────────────────────────────────────────────
deploy_cluster() {
if [ ! -f "$BINARY" ]; then
red "Binary not found: $BINARY"
red "Run /xce-build controller first."
exit 1
fi
yellow "Deploying 3-node cluster to $DEPLOY_DIR ..."
bash "$WORKTREE/src/tools/deploy-ha.sh" \
"$BINARY" "$DATADIR" "$DEPLOY_DIR" "$PEERS"
}
start_cluster() {
kill_cluster 2>/dev/null || true
sleep 0.3
VERBOSE=-vvv "$DEPLOY_DIR/scripts/start.sh" ctrl-1 \
> "$LOGDIR/ctrl-1.log" 2>&1 &
sleep 0.5
VERBOSE=-vvv "$DEPLOY_DIR/scripts/start.sh" ctrl-2 \
> "$LOGDIR/ctrl-2.log" 2>&1 &
sleep 0.5
VERBOSE=-vvv "$DEPLOY_DIR/scripts/start.sh" ctrl-3 \
> "$LOGDIR/ctrl-3.log" 2>&1 &
sleep 0.3
}
kill_cluster() {
pkill -f "xce_controller" 2>/dev/null || true
sleep 0.5
}
wait_for_primary() {
local deadline=$(($(date +%s) + 30))
while [ "$(date +%s)" -lt "$deadline" ]; do
if grep -q "CTRL_PRIMARY" "$LOGDIR/ctrl-1.log" 2>/dev/null; then
return 0
fi
sleep 1
done
return 1
}
wait_for_standbys() {
# Both ctrl-2 and ctrl-3 must be in CTRL_STANDBY
local deadline=$(($(date +%s) + 30))
while [ "$(date +%s)" -lt "$deadline" ]; do
local c2 c3
c2=$(grep -c "CTRL_STANDBY" "$LOGDIR/ctrl-2.log" 2>/dev/null || echo 0)
c3=$(grep -c "CTRL_STANDBY" "$LOGDIR/ctrl-3.log" 2>/dev/null || echo 0)
if [ "$c2" -gt 0 ] && [ "$c3" -gt 0 ]; then
return 0
fi
sleep 1
done
return 1
}
ctrl1_pid() {
pgrep -f "xce_controller.*ctrl-1" 2>/dev/null | head -1
}
# ── setup ─────────────────────────────────────────────────────────────────────
if [ ! -d "$DEPLOY_DIR" ]; then
deploy_cluster
fi
echo ""
yellow "=== Task 4.5: Missed-Heartbeat Tolerance (SIGSTOP/SIGCONT) ==="
echo ""
# ── Scenario A: short pause — no failover ─────────────────────────────────────
echo "--- Scenario A: SIGSTOP for 1 × heartbeat_interval_ms (${HB_INTERVAL_MS}ms) ---"
echo " Expected: no failover fires on standbys"
echo ""
bash "$DEPLOY_DIR/scripts/cleanup.sh"
start_cluster
if ! wait_for_primary; then
fail "Scenario A: cluster did not elect a primary within 30s"
kill_cluster
exit 1
fi
if ! wait_for_standbys; then
fail "Scenario A: standbys did not reach CTRL_STANDBY within 30s"
kill_cluster
exit 1
fi
echo " Cluster stable. Primary: ctrl-1"
echo " SIGSTOP ctrl-1 for ${HB_INTERVAL_MS}ms ..."
PID=$(ctrl1_pid)
if [ -z "$PID" ]; then
fail "Scenario A: ctrl-1 process not found"
kill_cluster
exit 1
fi
# Snapshot log lengths before pause
PRE_RDHUP_2=$(grep -c "ha_on_primary_ctrl_fd_rdhup" "$LOGDIR/ctrl-2.log" 2>/dev/null || echo 0)
PRE_RDHUP_3=$(grep -c "ha_on_primary_ctrl_fd_rdhup" "$LOGDIR/ctrl-3.log" 2>/dev/null || echo 0)
kill -STOP "$PID"
sleep "$(echo "scale=1; $HB_INTERVAL_MS / 1000" | bc)s"
kill -CONT "$PID"
# Give the cluster a moment to react (if it mistakenly fires failover)
sleep 3
POST_RDHUP_2=$(grep -c "ha_on_primary_ctrl_fd_rdhup" "$LOGDIR/ctrl-2.log" 2>/dev/null || echo 0)
POST_RDHUP_3=$(grep -c "ha_on_primary_ctrl_fd_rdhup" "$LOGDIR/ctrl-3.log" 2>/dev/null || echo 0)
NEW_RDHUP_2=$((POST_RDHUP_2 - PRE_RDHUP_2))
NEW_RDHUP_3=$((POST_RDHUP_3 - PRE_RDHUP_3))
if [ "$NEW_RDHUP_2" -eq 0 ] && [ "$NEW_RDHUP_3" -eq 0 ]; then
pass "Scenario A: no RDHUP fired on ctrl-2 or ctrl-3 during short pause (Req 2.2)"
else
fail "Scenario A: unexpected RDHUP fires (ctrl-2: +${NEW_RDHUP_2}, ctrl-3: +${NEW_RDHUP_3})"
fi
# Verify cluster is still stable
sleep 2
if grep -q "CTRL_PRIMARY" "$LOGDIR/ctrl-1.log" && kill -0 "$PID" 2>/dev/null; then
pass "Scenario A: ctrl-1 still primary after SIGCONT"
else
fail "Scenario A: ctrl-1 lost primary role unexpectedly"
fi
kill_cluster
sleep 1
# ── Scenario B: long pause — failover fires exactly once ─────────────────────
LONG_PAUSE_MS=$((HB_TIMEOUT_MS + 500))
echo ""
echo "--- Scenario B: SIGSTOP for heartbeat_timeout_ms + 500ms (${LONG_PAUSE_MS}ms) ---"
echo " Expected: failover fires exactly once on each standby"
echo ""
bash "$DEPLOY_DIR/scripts/cleanup.sh"
start_cluster
if ! wait_for_primary; then
fail "Scenario B: cluster did not elect a primary within 30s"
kill_cluster
exit 1
fi
if ! wait_for_standbys; then
fail "Scenario B: standbys did not reach CTRL_STANDBY within 30s"
kill_cluster
exit 1
fi
# Extra settle time so heartbeat clocks are steady
sleep 5
echo " Cluster stable. Primary: ctrl-1"
echo " SIGSTOP ctrl-1 for ${LONG_PAUSE_MS}ms ..."
PID=$(ctrl1_pid)
if [ -z "$PID" ]; then
fail "Scenario B: ctrl-1 process not found"
kill_cluster
exit 1
fi
PRE_RDHUP_2=$(grep -c "ha_on_primary_ctrl_fd_rdhup" "$LOGDIR/ctrl-2.log" 2>/dev/null || echo 0)
PRE_RDHUP_3=$(grep -c "ha_on_primary_ctrl_fd_rdhup" "$LOGDIR/ctrl-3.log" 2>/dev/null || echo 0)
PRE_ELECTING_2=$(grep -c "CTRL_ELECTING" "$LOGDIR/ctrl-2.log" 2>/dev/null || echo 0)
PRE_ELECTING_3=$(grep -c "CTRL_ELECTING" "$LOGDIR/ctrl-3.log" 2>/dev/null || echo 0)
kill -STOP "$PID"
sleep "$(echo "scale=1; $LONG_PAUSE_MS / 1000" | bc)s"
# Do NOT SIGCONT: leave ctrl-1 stopped so it cannot win re-election.
# Standbys should detect the silence and start failover.
# Wait for failover to complete (timeout + election + committing + margin)
FAILOVER_WAIT=$(( (HB_TIMEOUT_MS + 5000 + 10000 + 5000) / 1000 ))
echo " Waiting up to ${FAILOVER_WAIT}s for failover ..."
deadline=$(($(date +%s) + FAILOVER_WAIT))
while [ "$(date +%s)" -lt "$deadline" ]; do
NEW_ELECTING=$(( \
$(grep -c "CTRL_ELECTING" "$LOGDIR/ctrl-2.log" 2>/dev/null || echo 0) \
- PRE_ELECTING_2 \
))
if [ "$NEW_ELECTING" -gt 0 ]; then
break
fi
sleep 1
done
# Now SIGCONT ctrl-1 (it should lose since a new primary was elected)
kill -CONT "$PID" 2>/dev/null || true
# Collect final counts
sleep 5
POST_RDHUP_2=$(grep -c "ha_on_primary_ctrl_fd_rdhup" "$LOGDIR/ctrl-2.log" 2>/dev/null || echo 0)
POST_RDHUP_3=$(grep -c "ha_on_primary_ctrl_fd_rdhup" "$LOGDIR/ctrl-3.log" 2>/dev/null || echo 0)
NEW_RDHUP_2=$((POST_RDHUP_2 - PRE_RDHUP_2))
NEW_RDHUP_3=$((POST_RDHUP_3 - PRE_RDHUP_3))
# Each standby should show exactly 1 new RDHUP (not 0, not >1)
if [ "$NEW_RDHUP_2" -eq 1 ]; then
pass "Scenario B: ctrl-2 fired exactly 1 RDHUP during long pause (Req 2.3)"
elif [ "$NEW_RDHUP_2" -eq 0 ]; then
fail "Scenario B: ctrl-2 fired 0 RDHUP — liveness poll did not trigger failover"
else
fail "Scenario B: ctrl-2 fired ${NEW_RDHUP_2} RDHUPs — duplicate failover loop (Req 5.4)"
fi
if [ "$NEW_RDHUP_3" -eq 1 ]; then
pass "Scenario B: ctrl-3 fired exactly 1 RDHUP during long pause (Req 2.3)"
elif [ "$NEW_RDHUP_3" -eq 0 ]; then
fail "Scenario B: ctrl-3 fired 0 RDHUP — liveness poll did not trigger failover"
else
fail "Scenario B: ctrl-3 fired ${NEW_RDHUP_3} RDHUPs — duplicate failover loop (Req 5.4)"
fi
# One of ctrl-2 or ctrl-3 must have become PRIMARY
NEW_PRIMARY=""
if grep -q "CTRL_PRIMARY" "$LOGDIR/ctrl-2.log" 2>/dev/null; then
NEW_PRIMARY="ctrl-2"
elif grep -q "CTRL_PRIMARY" "$LOGDIR/ctrl-3.log" 2>/dev/null; then
NEW_PRIMARY="ctrl-3"
fi
if [ -n "$NEW_PRIMARY" ]; then
pass "Scenario B: $NEW_PRIMARY promoted to CTRL_PRIMARY after failover"
else
fail "Scenario B: no standby reached CTRL_PRIMARY — election did not complete"
fi
kill_cluster
# ── summary ───────────────────────────────────────────────────────────────────
echo ""
echo "========================="
echo "Results: ${PASS} passed, ${FAIL} failed"
echo "========================="
[ "$FAIL" -eq 0 ] && exit 0 || exit 1