Pre-flight Checks Before Decommissioning a Cassandra Node (4.x/5.x)
The most expensive decommission is the one you should never have started. This page is a dedicated validator — it does not remove anything. It runs a battery of read-only probes against a Cassandra 4.0, 4.1, or 5.0 cluster and returns a single go/no-go verdict, with the specific reason attached to every no-go, so you can wire it into a change-approval pipeline or run it manually five minutes before touching the ring. It is the safety half of decommissioning nodes safely: that guide (and its Python orchestrator) performs the drain; this one only decides whether the drain is safe to attempt. Because it mutates nothing, you can run it as often as you like — during planning, at the change window, and once more immediately before pulling the trigger.
Pre-conditions: what to validate and why
A decommission is safe only when the surviving topology can both accept the departing node’s data and keep serving quorum through the handover. Six conditions, all independently verifiable and all read-only, cover that:
- All nodes report
UN. ADNpeer means a range may already be atRF−1live replicas; starting a drain now can push it lower. AUJorULpeer means another lifecycle operation owns the streaming bandwidth. - Per-DC replication-factor headroom. For the target’s datacenter, the surviving
UNcount must be strictly greater than the largest keyspacereplication_factorfor that DC — strictly greater, not equal, so you keep quorum after the node leaves. - No active repair session. Anti-entropy validation and streaming compete with decommission for the same disk I/O; a repair mid-drain regenerates overlap and slows the exit.
- Pending compactions under a ceiling. A node already buried in compaction backlog will fall further behind while ingesting inbound streams on the survivors; gate the whole DC under a threshold.
- Disk headroom on every remaining node in the DC. Each survivor must hold its projected share of the target’s load plus compaction overhead, or streaming trips
OutOfSpaceException. - No in-progress bootstrap or leave. Overlapping lifecycle operations are the top cause of stuck decommissions.
You can eyeball each of these:
nodetool status # states + per-node load + DC
cqlsh -e "SELECT keyspace_name, replication FROM system_schema.keyspaces;"
nodetool netstats | grep -iE "repair|bootstrap" # active anti-entropy / joins
nodetool compactionstats | grep "pending tasks" # backlog per nodeExpected output of a healthy cluster: every nodetool status row starts UN, netstats shows no repair or bootstrap lines, and pending tasks sits in the low single digits on each node. Any deviation is a no-go the script will flag.
The verdict flow the validator implements:
Implementation
The validator collects state once, evaluates each gate against it, and returns a structured verdict. It never calls a mutating command — no decommission, no setstreamthroughput, nothing that changes the ring. Every gate contributes a boolean plus a human-readable reason, and the process exit code is 0 for GO and 1 for NO-GO so a pipeline can branch on it.
#!/usr/bin/env python3
# requirements: Python 3.10+, read-only nodetool + cqlsh access. Mutates nothing.
"""
Decommission pre-flight validator for Cassandra 4.x/5.x.
Runs six read-only gates and prints a GO/NO-GO verdict with reasons.
Exit code 0 = GO, 1 = NO-GO, 2 = could not evaluate (probe error).
Safe to run repeatedly; it changes no cluster state.
"""
from __future__ import annotations
import re
import subprocess
import sys
from dataclasses import dataclass
TARGET_HOST_ID_PREFIX = "9a1f" # first octet-group of the host id to retire
TARGET_DC = "dc-east"
PENDING_COMPACTION_CEILING = 25 # per-node pending tasks tolerated pre-drain
@dataclass
class GateResult:
name: str
ok: bool
reason: str
def probe(cmd: list[str], timeout: int = 20) -> str:
"""Run a read-only command; return stdout, or raise on failure."""
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
if r.returncode != 0:
raise RuntimeError(f"{cmd[:2]} failed: {r.stderr.strip()}")
return r.stdout
def parse_status(raw: str) -> list[dict[str, str]]:
nodes, dc = [], "unknown"
for line in raw.splitlines():
s = line.strip()
if s.startswith("Datacenter:"):
dc = s.split(":", 1)[1].strip()
continue
m = re.match(r"^([UD][NJL])\s+(\S+)\s+([\d.]+)\s*(\w+)\s+\d+\s+\S+\s+(\S+)", s)
if m:
st, addr, lv, lu, hid = m.groups()
nodes.append({"state": st, "addr": addr, "dc": dc,
"load_gib": f"{_gib(lv, lu):.2f}", "host_id": hid})
return nodes
def _gib(v: str, u: str) -> float:
return float(v) * {"KiB": 1/1024**2, "MiB": 1/1024, "GiB": 1.0, "TiB": 1024.0}.get(u, 1.0)
def _is_target(node: dict[str, str]) -> bool:
return node["host_id"].startswith(TARGET_HOST_ID_PREFIX)
# --- gates ---------------------------------------------------------------
def gate_all_un(nodes: list[dict[str, str]]) -> GateResult:
bad = [(n["addr"], n["state"]) for n in nodes if n["state"] != "UN"]
return GateResult("all-UN", not bad,
"all nodes UN" if not bad else f"non-UN nodes: {bad}")
def gate_rf_headroom(nodes: list[dict[str, str]], repl: str) -> GateResult:
survivors = sum(1 for n in nodes
if n["dc"] == TARGET_DC and n["state"] == "UN" and not _is_target(n))
rfs = [int(x) for x in re.findall(rf"'{re.escape(TARGET_DC)}'\s*:\s*'?(\d+)'?", repl)]
max_rf = max(rfs) if rfs else 99
ok = survivors > max_rf # strictly greater keeps quorum after removal
return GateResult("rf-headroom", ok,
f"{survivors} survivors vs RF {max_rf} in {TARGET_DC}")
def gate_no_repair(netstats: str) -> GateResult:
active = bool(re.search(r"(?i)repair", netstats)) and "Nothing" not in netstats
return GateResult("no-active-repair", not active,
"no repair streams" if not active else "active repair detected")
def gate_no_bootstrap(nodes: list[dict[str, str]], netstats: str) -> GateResult:
joining = [n["addr"] for n in nodes if n["state"] in ("UJ", "UL") and not _is_target(n)]
streaming = bool(re.search(r"(?i)bootstrap|rebuild", netstats)) and "Nothing" not in netstats
ok = not joining and not streaming
return GateResult("no-lifecycle-op", ok,
"no bootstrap/leave in flight" if ok else f"in-flight: {joining or 'stream'}")
def gate_pending_compactions(compactionstats: str) -> GateResult:
# `nodetool compactionstats` here is run per node; sum the pending line.
pend = [int(x) for x in re.findall(r"pending tasks:\s*(\d+)", compactionstats)]
worst = max(pend) if pend else 0
ok = worst <= PENDING_COMPACTION_CEILING
return GateResult("compaction-backlog", ok,
f"worst pending tasks {worst} (ceiling {PENDING_COMPACTION_CEILING})")
def gate_disk_headroom(nodes: list[dict[str, str]]) -> GateResult:
target = next((n for n in nodes if _is_target(n)), None)
survivors = [n for n in nodes if n["dc"] == TARGET_DC and n["state"] == "UN" and not _is_target(n)]
if not target or not survivors:
return GateResult("disk-headroom", False, "cannot project inbound load")
inbound = float(target["load_gib"]) / len(survivors)
# Read free space per host (df over the data dir); here we require the caller
# to supply it. We surface the projection so a per-host check can enforce it.
return GateResult("disk-headroom", True,
f"projected inbound per survivor {inbound:.1f} GiB "
f"(verify each keeps >= 30% free)")
def evaluate() -> tuple[bool, list[GateResult]]:
try:
status = probe(["nodetool", "status"])
repl = probe(["cqlsh", "-e",
"SELECT keyspace_name, replication FROM system_schema.keyspaces;"])
netstats = probe(["nodetool", "netstats"])
comp = probe(["nodetool", "compactionstats"])
except (subprocess.TimeoutExpired, RuntimeError) as e:
print(f"NO-GO (could not evaluate): {e}", file=sys.stderr)
sys.exit(2)
nodes = parse_status(status)
results = [
gate_all_un(nodes),
gate_rf_headroom(nodes, repl),
gate_no_repair(netstats),
gate_no_bootstrap(nodes, netstats),
gate_pending_compactions(comp),
gate_disk_headroom(nodes),
]
return all(g.ok for g in results), results
if __name__ == "__main__":
go, results = evaluate()
for g in results:
print(f"[{'PASS' if g.ok else 'FAIL'}] {g.name}: {g.reason}")
print("\nVERDICT:", "GO — clear to decommission" if go else "NO-GO — do not proceed")
sys.exit(0 if go else 1)Because the script only reads, running it twice in a row yields the same verdict on a stable cluster and never leaves side effects — the idempotency you want from a gate. A NO-GO prints exactly which gate failed and why, so the operator fixes the specific condition (add a node, wait for a repair, free disk) rather than guessing. Feed its exit code straight into a pipeline: preflight.py && orchestrate_decommission.py.
Verification
Confirm the validator agrees with reality before you trust it in automation. Force a known NO-GO and a known GO:
# Run it; every gate prints PASS/FAIL and the verdict follows.
python3 preflight.py; echo "exit=$?"On a healthy cluster you should see six PASS lines and exit=0. To prove the RF gate bites, point TARGET_DC at a datacenter that runs exactly RF nodes and confirm rf-headroom reports FAIL with exit=1. Cross-check each gate against the raw command it wraps — for example, the all-UN result must match what nodetool status shows by eye. A validator that disagrees with nodetool is worse than none, so calibrate it against a deployment whose state you already know.
Troubleshooting
- False-positive NO-GO from a gossip flap. The
all-UNgate reports aDNnode that is actually healthy — gossip briefly marked it down during a GC pause or a network hiccup, and your singlenodetool statussnapshot caught the flap. Root cause: a one-shot probe treats a transient down as a hard fail. Fix: samplenodetool statustwo or three times a few seconds apart and require theDNto persist across samples before failing the gate; corroborate withnodetool gossipinfoand the failure-detector view in node gossip and failure-detection protocols before trusting a lone snapshot. - Disk projection miscount understates inbound load. The
disk-headroomgate divides the target’s load evenly across survivors, but real token ownership is not uniform — withvnodesand rack-aware placement, one survivor can inherit a disproportionate share and fill up while the average looked safe. Root cause: an even-split model ignores per-range ownership. Fix: compute projected inbound from actual range ownership (nodetool ringor thesystem.size_estimatesper-range data) rather than dividing total load by node count, and always pad with compaction overhead. - Repair session detection gaps. The
no-active-repairgate passes, yet a repair is genuinely running —nodetool netstatsonly shows streaming sessions, and a validation-phase repair that has not yet begun streaming, or an incremental repair anticompaction, does not appear there. Root cause:netstatsis a streaming view, not a repair-session registry. Fix: also check the repair-specific surface — grepsystem.logforStarting repairwithout a matching completion, or query the active-repair MBean — so a repair in its validation phase is not mistaken for an idle cluster. The distinction between repair phases is covered in read repair vs anti-entropy repair.
Related
- Decommissioning nodes safely — the parent guide these gates protect, covering the UL state and outbound streaming.
- Safely decommissioning a node with Python — the orchestrator that runs the drain once this validator returns GO.
- Node gossip and failure-detection protocols — why a single DN snapshot can be a flap rather than a real outage.
- Compaction backlog analysis and alerting — the pending-compaction ceiling this validator enforces before a drain.