Run a 3-node meta-service Raft cluster failover drill for RobustMQ. Use when the user wants to verify cluster startup, node removal/recovery, leader election, snapshot replication, or graceful shutdown — e.g. "演练 raft 集群", "test cluster failover", "验证节点删除/恢复", "run the raft drill".
1,797 stars
0 votes
0 copies
0 views
Added August 31, 2026
ai-agentspythonrustgobashnodegitapi
Works with
api
Security analysis
B76/100
mediumUses curl or wget to download content
highPerforms destructive filesystem operations
criticalDownloads and executes remote scripts — classic supply chain attack
Installs into .claude/skills of the current project.
Are you the author of Raft Cluster Drill?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/robustmq-raft-cluster-drill)
---
name: raft-cluster-drill
description: Run a 3-node meta-service Raft cluster failover drill for RobustMQ. Use when the user wants to verify cluster startup, node removal/recovery, leader election, snapshot replication, or graceful shutdown — e.g. "演练 raft 集群", "test cluster failover", "验证节点删除/恢复", "run the raft drill".
---
# RobustMQ Raft Cluster Drill
A reproducible drill to verify the meta-service Raft cluster behaves correctly
on RobustMQ's openraft-based multi-runtime broker. It exercises bootstrap, join,
leader election, node recovery, snapshot replication, and graceful shutdown.
## When to Use
- Verify a fresh 3-node cluster starts and forms membership `{1,2,3}`
- Confirm killing a node does NOT change membership and (if it was leader) triggers re-election
- Confirm restarting a node recovers automatically (no re-join needed)
- Confirm `Ctrl+C` / `kill -INT` exits each node gracefully
- Verify **state-machine data correctness** under repeated kill/restart: data written
through Raft survives restarts, all 3 nodes converge, `last_applied` is monotonic
(write→restart→read-back; Drill F)
- Regression-check the openraft fixes: signal handling, snapshot send/receive,
no leave-on-stop, total_order_seek prefix reads
## Prerequisites
- Build the binary first (drills must run the compiled binary, NOT `cargo run` —
`cargo run` intercepts SIGINT via its parent process and masks real shutdown behavior):
```bash
cargo build --package cmd --bin broker-server
```
- Cluster configs live in `config/cluster/server-{1,2,3}.toml`
(broker_id 1/2/3, grpc 1228/2228/3228, admin http 58080/58082/58083,
meta_addrs = all three). Data dirs: `data/broker-{1,2,3}`.
- Admin list endpoints (`/api/cluster/*/list`) default to `limit=10`. When a drill
accumulates >10 rows, ALWAYS pass an explicit `?limit=100000`, or the
lexicographically-last rows are paginated out and look "missing". Trust
`total_count` over the returned row count.
- Run drill scripts with **bash, not zsh** (zsh's 1-based arrays + no `$var`
word-splitting silently break the kill order and port lookups).
## Key Semantics (openraft) — what "correct" looks like
| Action | Expected behavior |
|--------|-------------------|
| First node, no peer reachable | single-node `bootstrap` → becomes Leader |
| Fresh node, peer reachable | `join` (add_learner + change_membership), snapshot streamed if lagging |
| Node restart (has persisted state) | `recover` — openraft re-establishes replication, **no** re-join, membership unchanged |
| Stop a node | membership **unchanged** `{1,2,3}` (stop ≠ scale-in); if it was leader, remaining quorum re-elects |
| Graceful stop | SIGINT/SIGTERM → optional drain → process exits (libc handler + watchdog) |
Quorum rule: 3 nodes → need ≥2 alive to elect/serve. Never kill down to <quorum
while a node is still starting, or that node hangs in `check_meta_service_status`.
## Drill Steps
### 0. Always clean state first (mandatory)
Every drill run starts from a clean slate — kill any leftover process and wipe
all node data. Stale Raft state from a previous run will otherwise interfere
(wrong membership, leftover leader, etc.):
```bash
pkill -9 -f "broker-server --conf" 2>/dev/null; sleep 1
rm -rf data/broker-1 data/broker-2 data/broker-3 data/logs
```
### 1. Start the 3-node cluster (staggered so node1 bootstraps first)
```bash
./target/debug/broker-server --conf config/cluster/server-1.toml > /tmp/n1.log 2>&1 &
sleep 7
./target/debug/broker-server --conf config/cluster/server-2.toml > /tmp/n2.log 2>&1 &
sleep 9
./target/debug/broker-server --conf config/cluster/server-3.toml > /tmp/n3.log 2>&1 &
sleep 12
```
Verify in logs:
- node1: `No reachable peers, bootstrapping single-node cluster (node 1)`
- node2/node3: `Successfully joined cluster via peer 127.0.0.1:1228` (NO `snapshot not found`)
### 2. Query Raft state via admin API (`/api/info`)
Each admin port returns `data.meta` with the three state machines
(`metadata_0`, `offset_0`, `data_0`). Cross-check all nodes agree:
```bash
for p in 58080 58082 58083; do
echo "--- port $p ---"
curl -s http://localhost:$p/api/info | python3 -c "
import sys,json
d=json.load(sys.stdin).get('data',{})
for n,rs in sorted(d.get('meta',{}).items()):
cfg=rs.get('membership_config',{}).get('membership',{}).get('configs',[])
la=(rs.get('last_applied') or {}).get('index')
print(f' [{n}] state={rs.get(\"state\")} leader={rs.get(\"current_leader\")} term={rs.get(\"current_term\")} last_log_index={rs.get(\"last_log_index\")} last_applied={la} members={cfg}')
"
done
```
Expect: all three nodes report the same leader/term per shard, members `[[1,2,3]]`,
and identical `last_applied` / `last_log_index` per shard (consensus converged).
> Drills A–F are historical names; run them in the section order below
> (A remove → B rejoin → D rolling → E sustained stress → C graceful shutdown →
> F state-machine correctness). Each is self-contained given a running cluster.
### 3. Drill A — remove a node (membership + leader check)
Kill a node, wait for graceful exit, re-query:
```bash
kill -INT $(pgrep -f "server-3.toml") # or server-1 to test leader failover
# wait until the process exits, then re-run step 2
```
Expect:
- Killed node exits gracefully in ~5–7s.
- Membership stays `[[1,2,3]]` (stop does not remove the node).
- If you killed the **leader**, remaining nodes re-elect (new `current_leader`, higher `term`).
- If you killed a **follower**, the leader is unchanged.
### 4. Drill B — rejoin the node (recovery)
Restart the killed node; it has persisted state so it recovers:
```bash
./target/debug/broker-server --conf config/cluster/server-3.toml > /tmp/n3.log 2>&1 &
sleep 12
```
Expect in log: `Node 3 has persisted state, recovering existing cluster` and
`Meta Service cluster is ready`. Re-run step 2 — all nodes back, consensus consistent.
### 5. Drill D — rolling kill + restart of every node (failover stress)
Kill each node in turn, observe leader failover, then restart it and observe it
rejoin — going around all three nodes. **Always keep ≥2 nodes alive** (kill one,
restart it before killing the next), otherwise quorum is lost.
```bash
for c in 1 2 3; do
echo "===== rolling: node$c ====="
# leader before kill (ask a node that stays up)
ask=$([ "$c" = 1 ] && echo 58082 || echo 58080)
echo "leader before: $(curl -s http://localhost:$ask/api/info | python3 -c "import sys,json;m=json.load(sys.stdin).get('data',{}).get('meta',{});print({n:rs.get('current_leader') for n,rs in m.items()})")"
# kill node$c, wait for graceful exit
kill -INT $(pgrep -f "server-$c.toml")
while pgrep -f "server-$c.toml" >/dev/null; do sleep 1; done
sleep 4
echo "leader after kill: $(curl -s http://localhost:$ask/api/info | python3 -c "import sys,json;m=json.load(sys.stdin).get('data',{}).get('meta',{});print({n:rs.get('current_leader') for n,rs in m.items()})")"
# restart node$c, wait to rejoin
./target/debug/broker-server --conf config/cluster/server-$c.toml > /tmp/n$c.log 2>&1 &
sleep 14
grep -oE "recovering existing cluster|Meta Service cluster is ready" /tmp/n$c.log | sort -u
echo "members after rejoin: $(curl -s http://localhost:$ask/api/info | python3 -c "import sys,json;m=json.load(sys.stdin).get('data',{}).get('meta',{});print([rs.get('membership_config',{}).get('membership',{}).get('configs',[]) for rs in m.values()][:1])")"
done
```
Expect each iteration:
- Killing a **leader** → remaining quorum re-elects (leader changes, term +1).
Killing a **follower** → leader unchanged.
- Membership stays `[[1,2,3]]` throughout (kill ≠ scale-in).
- Restarted node logs `recovering existing cluster` + `Meta Service cluster is ready`,
rejoins automatically, all nodes converge on the same leader/term.
### 5b. Drill E — sustained rolling kill+restart stress (graceful-exit watch)
A longer stress variant of Drill D focused on **graceful shutdown under churn**:
across many rounds, kill+restart **every** node, plus one extra operation per
round (so each round performs **4 kill+restart cycles**, e.g. nodes
`1 → 2 → 3 → 1`). The wrap-around (operating node 1 again) deliberately hits a
node that was *just* restarted in the same round, exercising
kill-during-recovery. **Always keep ≥2 nodes alive** — restart each node and
let it fully rejoin before killing the next.
For each kill, this drill asserts the node **exits gracefully** (prints
`Termination signal received` and exits on its own within the watchdog window —
NO `kill -9` fallback, NO 20s force-exit). For each restart, it asserts the node
**rejoins** (`recovering existing cluster` + `Meta Service cluster is ready`).
```bash
GRACE_TIMEOUT=25 # seconds to wait for self-exit before declaring a hang
graceful_kill() { # $1 = node id
local c=$1 t0
kill -INT "$(pgrep -f "server-$c.toml")"
t0=$(date +%s)
while pgrep -f "server-$c.toml" >/dev/null; do
sleep 1
if [ $(($(date +%s) - t0)) -gt "$GRACE_TIMEOUT" ]; then
echo " !!! node$c DID NOT EXIT in ${GRACE_TIMEOUT}s — HANG, forcing kill -9"
kill -9 "$(pgrep -f "server-$c.toml")"; return 1
fi
done
local secs=$(($(date +%s) - t0))
# graceful = printed the shutdown banner AND did not hit the watchdog force-exit
if grep -q "Termination signal received" /tmp/n$c.log && \
! grep -q "forcing exit" /tmp/n$c.log; then
echo " node$c exited GRACEFULLY in ${secs}s"
else
echo " !!! node$c exit was NOT graceful (no banner or watchdog force-exit) in ${secs}s"; return 1
fi
}
restart_node() { # $1 = node id
local c=$1
./target/debug/broker-server --conf config/cluster/server-$c.toml > /tmp/n$c.log 2>&1 &
sleep 14
if grep -q "Meta Service cluster is ready" /tmp/n$c.log; then
echo " node$c REJOINED ($(grep -oE 'recovering existing cluster' /tmp/n$c.log | head -1 | sed 's/.*/recovering/'))"
else
echo " !!! node$c did NOT report cluster ready after restart"
fi
}
for round in $(seq 1 10); do
echo "========== Drill E round $round =========="
for c in 1 2 3 1; do # 3 nodes + 1 wrap-around = 4 ops/round
echo "--- op: node$c (kill+restart) ---"
graceful_kill "$c"
restart_node "$c"
done
# consensus + error scan after each round
ask=58080
echo "members: $(curl -s http://localhost:$ask/api/info | python3 -c "import sys,json;m=json.load(sys.stdin).get('data',{}).get('meta',{});print([rs.get('membership_config',{}).get('membership',{}).get('configs',[]) for rs in m.values()][:1])" 2>/dev/null)"
PANIC=$(( $(grep -c 'invalid state: expect' /tmp/n1.log)+$(grep -c 'invalid state: expect' /tmp/n2.log)+$(grep -c 'invalid state: expect' /tmp/n3.log) ))
NONE=$(( $(grep -c 'last_log_id=None' /tmp/n1.log)+$(grep -c 'last_log_id=None' /tmp/n2.log)+$(grep -c 'last_log_id=None' /tmp/n3.log) ))
ERR=$(( $(grep -c ' ERROR' /tmp/n1.log)+$(grep -c ' ERROR' /tmp/n2.log)+$(grep -c ' ERROR' /tmp/n3.log) ))
FORCE=$(( $(grep -c 'forcing exit' /tmp/n1.log)+$(grep -c 'forcing exit' /tmp/n2.log)+$(grep -c 'forcing exit' /tmp/n3.log) ))
echo "round $round: raft_panic=$PANIC last_log_id_None=$NONE ERROR=$ERR watchdog_force_exit=$FORCE"
done
```
Watch for (any of these = a problem to investigate):
- **Hang**: a node not exiting within `GRACE_TIMEOUT` on `kill -INT` (only `kill -9` works).
- **Non-graceful exit**: watchdog `forcing exit` fired, or no `Termination signal received` banner.
- **`raft_panic` / `last_log_id_None` / `clean_hole` > 0**: the storage regression resurfaced.
- **Membership drifted** off `[[1,2,3]]`, or a node failed to report `Meta Service cluster is ready` after restart.
- **`ERROR` lines** that are not the known-benign startup `Connection refused` (peer still starting).
### 6. Drill C — graceful shutdown
Kill nodes one by one, each waiting for the previous to exit
(so quorum is never lost while a node is still starting):
```bash
for c in 3 2 1; do
kill -INT $(pgrep -f "server-$c.toml")
while pgrep -f "server-$c.toml" >/dev/null; do sleep 1; done
echo "node$c exited"
done
```
Expect: each node prints `Termination signal received, the service starts to stop`
then exits. The leader/data-leader node may take longer (waits on replication to
downed peers) but still exits within the watchdog window.
### 7. Drill F — state-machine correctness under repeated kill/restart
This is the deepest drill: it verifies the **state machine itself stays correct**
across repeated kill/restart, not just that nodes come back up. Each round writes
data through Raft, kills+restarts every node (leader first, on purpose), then
proves the data survived and the three nodes agree.
Verifies four properties:
1. **Write→restart→read-back** — data committed before a restart is still readable
on every node afterward (the restarted node must rebuild its cache from the
persisted state machine). Uses tenants: `POST /api/cluster/tenant/create`
(Raft-committed, auto-forwarded to leader) and `GET /api/cluster/tenant/list`
(served from each node's local cache, which Raft apply maintains). No auth.
2. **Three-node consensus convergence** — every node's `data.meta.<group>`
reports the same `last_applied.index` / `current_term` / `current_leader` /
members per Raft group.
3. **`last_applied` monotonicity** — `last_applied.index` per group never goes
backward across rounds (apply progress is durably persisted).
4. **Targeted leader kill** — each round kills the *current metadata leader*
first, so failover + graceful-exit-of-leader is exercised every round.
> **Run with bash, NOT zsh.** Save as a script with `#!/bin/bash` and run
> `bash drill_f.sh`. zsh's 1-based arrays and lack of `$var` word-splitting
> silently break the port lookup and the kill order (the cluster never gets
> killed and writes go to an empty port) — the drill then *looks* like it passes
> while doing nothing.
```bash
#!/bin/bash
set -u
GRACE_TIMEOUT=25
port_of() { case "$1" in 1) echo 58080;; 2) echo 58082;; 3) echo 58083;; esac; }
graceful_kill() { # $1 = node id; asserts self-exit + graceful, no kill -9
local c=$1 t0 pid; pid=$(pgrep -f "server-$c.toml")
[ -z "$pid" ] && { echo " !!! node$c not running before kill"; return 1; }
kill -INT "$pid"; t0=$(date +%s)
while pgrep -f "server-$c.toml" >/dev/null; do sleep 1
[ $(($(date +%s)-t0)) -gt "$GRACE_TIMEOUT" ] && { echo " !!! node$c HANG -> kill -9"; kill -9 "$(pgrep -f "server-$c.toml")"; return 1; }
done
grep -q "Termination signal received" /tmp/n$c.log && ! grep -q "forcing exit" /tmp/n$c.log \
&& echo " node$c GRACEFUL ($(($(date +%s)-t0))s)" || { echo " !!! node$c NOT graceful"; return 1; }
}
restart_node() { ./target/debug/broker-server --conf config/cluster/server-$1.toml > /tmp/n$1.log 2>&1 & sleep 14; }
# find the metadata leader node id by asking any live node
meta_leader() {
local p L
for p in 58080 58082 58083; do
L=$(curl -s "http://127.0.0.1:$p/api/info" | python3 -c "import sys,json;m=json.load(sys.stdin).get('data',{}).get('meta',{});print(m.get('metadata_0',{}).get('current_leader') or '')" 2>/dev/null)
[ -n "$L" ] && { echo "$L"; return; }
done
}
EXPECT=() # accumulated tenant names that must always read back
for round in $(seq 1 10); do
echo "========== Drill F round $round =========="
# (1) WRITE a unique tenant through Raft, then remember it
TN="drillf-r${round}"
RESP=$(curl -s -X POST "http://127.0.0.1:$(port_of 1)/api/cluster/tenant/create" \
-H 'Content-Type: application/json' -d "{\"tenant_name\":\"$TN\",\"desc\":\"r$round\"}")
CODE=$(echo "$RESP" | python3 -c "import sys,json;print(json.load(sys.stdin).get('code'))" 2>/dev/null)
[ "$CODE" = "0" ] && { EXPECT+=("$TN"); echo " wrote tenant $TN (code=0)"; } || echo " !!! write $TN failed: $RESP"
# (4) kill the LEADER first, then the other two — build a SPACE-separated order
LEADER=$(meta_leader); echo " metadata leader = node$LEADER (killed first)"
ORDER="$LEADER"
for n in 1 2 3; do case " $ORDER " in *" $n "*) ;; *) ORDER="$ORDER $n";; esac; done
for c in $ORDER; do
echo "--- op: node$c ---"; graceful_kill "$c"; restart_node "$c"
done
# let caches/consensus settle after the last restart
sleep 4
# (2)+(3) consensus convergence + monotonicity, read from all 3 nodes
python3 - "$round" <<'PY'
import sys,json,urllib.request
rnd=sys.argv[1]; ports=[58080,58082,58083]; snaps={}
for p in ports:
try: snaps[p]=json.load(urllib.request.urlopen(f"http://127.0.0.1:{p}/api/info",timeout=5)).get('data',{}).get('meta',{})
except Exception as e: snaps[p]=None; print(f" !!! node@{p} /api/info failed: {e}")
groups=sorted({g for m in snaps.values() if m for g in m})
ok=True
for g in groups:
rows={p:(m.get(g) if m else None) for p,m in snaps.items()}
la={p:(r.get('last_applied') or {}).get('index') for p,r in rows.items() if r}
tm={p:r.get('current_term') for p,r in rows.items() if r}
ld={p:r.get('current_leader') for p,r in rows.items() if r}
mb={p:r.get('membership_config',{}).get('membership',{}).get('configs') for p,r in rows.items() if r}
conv = len(set(la.values()))<=1 and len(set(tm.values()))<=1 and len(set(ld.values()))<=1
print(f" [{g}] last_applied={la} term={tm} leader={ld} members={list(mb.values())[0] if mb else None} converged={'YES' if conv else 'NO <<<'}")
if not conv: ok=False
print(" CONSENSUS:", "CONVERGED" if ok else "DIVERGED <<<<<")
PY
# (1) read-back: every accumulated tenant must be present on ALL 3 nodes.
# NOTE: pass a large limit — list endpoints default to limit=10 (see
# parse_limit in admin-server/src/tool/query.rs). Without it, the 11th+ row
# (e.g. the lexicographically-last "drillf-r9" among r1..r10) is silently
# paginated out and read-back falsely reports it MISSING.
MISS=0
for p in 58080 58082 58083; do
HAVE=$(curl -s "http://127.0.0.1:$p/api/cluster/tenant/list?limit=100000" | python3 -c "import sys,json;print(' '.join(r['tenant_name'] for r in json.load(sys.stdin).get('data',{}).get('data',[])))" 2>/dev/null)
for t in "${EXPECT[@]}"; do case " $HAVE " in *" $t "*) ;; *) echo " !!! node@$p MISSING tenant $t"; MISS=$((MISS+1));; esac; done
done
[ "$MISS" = 0 ] && echo " READ-BACK: all ${#EXPECT[@]} tenants present on all 3 nodes" || echo " READ-BACK: $MISS MISSING <<<<<"
# error/panic scan for the round
PANIC=$(( $(grep -c 'invalid state: expect' /tmp/n1.log)+$(grep -c 'invalid state: expect' /tmp/n2.log)+$(grep -c 'invalid state: expect' /tmp/n3.log) ))
NONE=$(( $(grep -c 'last_log_id=None' /tmp/n1.log)+$(grep -c 'last_log_id=None' /tmp/n2.log)+$(grep -c 'last_log_id=None' /tmp/n3.log) ))
echo " >>> round $round: raft_panic=$PANIC last_log_id_None=$NONE"
done
```
Expect every round: `CONSENSUS: CONVERGED`, `READ-BACK: all N tenants present`,
`last_applied` non-decreasing per group, leader killed exits gracefully, and
new leader elected. Then run the WARN/ERROR analysis below.
## Log Analysis — run after every drill step
After each step, scan all node logs for ERROR/WARN and judge whether each is
expected. This is how a real regression was caught (a busy-loop that left a node
at 99% CPU while the cluster still looked "healthy").
```bash
# ERROR count per node — should be 0
for c in 1 2 3; do echo "node$c ERROR: $(grep -c ERROR /tmp/n$c.log 2>/dev/null)"; done
# WARN types per node
for c in 1 2 3; do
echo "--- node$c WARN ---"
grep WARN /tmp/n$c.log 2>/dev/null | sed 's/\x1b\[[0-9;]*m//g' | grep -oE "WARN [a-z_:]+" | sort | uniq -c | sort -rn
done
# broker_node_list must list all live nodes (a missing live node = heartbeat/registration bug)
curl -s http://localhost:58080/api/info | python3 -c "import sys,json;d=json.load(sys.stdin).get('data',{});print('node_ids:', sorted(n.get('node_id') for n in d.get('broker_node_list',[])))"
# CPU per node — a node stuck near 100% = busy loop (RN state), not normal
for c in 1 2 3; do P=$(pgrep -f "server-$c.toml"); [ -n "$P" ] && ps -p $P -o pid,stat,%cpu | tail -1; done
```
### Which WARNs are benign vs. a real problem
| Log | Verdict |
|-----|---------|
| `openraft::core::raft_core: membership_log_id changed ... ignore` | **Benign** — concurrent membership change dedup during join |
| `meta_service::raft::manager: Peer N not reachable: Connection refused` (at startup) | **Benign** — peers not up yet; first node bootstraps |
| `openraft::replication: ... Unreachable node` ERROR/WARN (+ `heartbeat error` / `error replication to target`) **clustered around a kill** | **Benign & transient** — the leader briefly fails to replicate/heartbeat to the node you just killed; stops the moment that node restarts. Expected to appear only on the current leader, only at kill times, and NOT keep growing. A real problem only if it persists after the target is back up, or targets a node that is actually alive. |
| `mqtt_broker::system_topic: Failed to write ... Connection refused` that **keeps growing** | **PROBLEM** — a system-topic shard replica points at an unreachable/removed node. Check `broker_node_list` for a missing live node. |
| Any `ERROR` | **PROBLEM** — investigate. |
| A node at ~99% CPU / `RN` state | **PROBLEM** — busy loop (e.g. retry_call spinning). |
| `broker_node_list` missing a live node | **PROBLEM** — that node's heartbeat/registration failed (often a knock-on effect of a busy loop). |
## Cleanup
```bash
pkill -9 -f "broker-server --conf"
```
## Troubleshooting
| Symptom | Cause |
|---------|-------|
| `cargo run` + Ctrl+C exits instantly with no shutdown logs | Expected — cargo kills the child. Use the compiled binary. |
| Node hangs in `Waiting for Meta Service cluster to be ready` | Quorum lost (too many nodes down/starting). Keep ≥2 nodes healthy. |
| `snapshot not found` on join | Regression in `get_current_snapshot` / `begin_receiving_snapshot` (state.rs). |
| Nodes fail to start after a few restart rounds; `RaftCore exited ... invalid state: purge_upto > snapshot_last_log_id` | Logs are read back empty on restart. Confirm by grepping the startup logs for `get_initial_state ... last_log_id=None` and `Clean the hole`. Root cause was `get_log_state`/`try_get_log_entries` iterating without `total_order_seek` while the DB has a 10-byte fixed-prefix extractor (log keys are 17 bytes) — the reverse seek finds nothing and openraft thinks the whole log was lost, then purges past the snapshot. Fixed in `raft/store/log.rs` by setting `ReadOptions::set_total_order_seek(true)` on both iterators (regression test: `test_get_log_state_survives_reopen_with_prefix_extractor`). |
| Drill F read-back reports a tenant MISSING but point-get / `list?limit=big` / `total_count` show it IS present | **Test artifact, NOT a bug.** List endpoints default to `limit=10` (`parse_limit`, admin-server/src/tool/query.rs). With >10 rows the lexicographically-last name is paginated out. Always query lists with an explicit large `?limit=`. Verify with `/api/cluster/tenant/list?limit=100000` and the `total_count` field before suspecting data loss. |
| `console subscriber server failed: Address already in use` panic at startup | **Benign** for co-located multi-node drills — all three nodes bind the same tokio-console port. Does not affect Raft or shutdown. Do NOT count it as a Raft panic; grep `invalid state: expect` to isolate the real Raft panic. |
## Run Report (generate after completing all drills in a session)
After all drills finish (or fail), emit a structured summary. Gather facts from
test output, admin API responses, and node logs, then print:
```
=== RAFT CLUSTER DRILL RUN REPORT ===
Date: <YYYY-MM-DD>
Code commit: <git rev-parse --short HEAD>
Drill A — 3-node bootstrap + leader election
Result: PASS / FAIL
Details: <raft leader elected, all nodes visible in broker_node_list>
Drill B — snapshot install on fresh joiner
Result: PASS / FAIL
Details: <snapshot applied, data readable on new member>
Drill C — rolling restart (quorum never broken)
Result: PASS / FAIL
Details: <N rounds, cluster recovered each time, no invalid-state panic>
Drill D — leader kill + re-election
Result: PASS / FAIL
Details: <new leader elected in <T>s, committed data preserved>
Drill E — partition simulation (quorum loss + recovery)
Result: PASS / FAIL
Details: <cluster stalled during partition, recovered after heal>
Drill F — data integrity (read-back all written records)
Result: PASS / FAIL
Details: <N records written, N read back, no missing/duplicate>
Log errors:
node1: <N> ERRORs (grep -c ' ERROR' /tmp/n1.log)
node2: <N> ERRORs
node3: <N> ERRORs
Raft panics: <N> (grep -c 'invalid state: expect' /tmp/n*.log)
Overall: PASS / FAIL
======================================
```
Gather inputs with:
```bash
git rev-parse --short HEAD
for c in 1 2 3; do echo "node$c ERROR: $(grep -c ' ERROR' /tmp/n$c.log)"; done
for c in 1 2 3; do echo "node$c RAFT_PANIC: $(grep -c 'invalid state: expect' /tmp/n$c.log)"; done
```
| Process won't exit on `kill -INT`, only `kill -9` works | Regression in signal handling (libc handler in daemon.rs) or a `leave_cluster` re-introduced into shutdown. |