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. A DN peer means a range may already be at RF−1 live replicas; starting a drain now can push it lower. A UJ or UL peer means another lifecycle operation owns the streaming bandwidth.
  • Per-DC replication-factor headroom. For the target’s datacenter, the surviving UN count must be strictly greater than the largest keyspace replication_factor for 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 node

Expected 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:

Read-only pre-flight go/no-go decision A top-to-bottom decision flow. Read-only probes collect ring state, replication factor, repair state, pending compactions, and disk headroom. They feed a single decision: do all six gates pass. If yes, the flow branches right to a GO verdict that clears the decommission. If no, the flow branches down to a NO-GO verdict that lists the failing reasons and blocks the operation. yes no Read-only probes ring · RF · repair · compaction · disk All six gates pass? GO clear to decommission NO-GO block · list failing reasons
The validator is a pure function of cluster state: a single failing gate yields NO-GO with reasons, and only a clean sweep of all six returns GO.

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-UN gate reports a DN node that is actually healthy — gossip briefly marked it down during a GC pause or a network hiccup, and your single nodetool status snapshot caught the flap. Root cause: a one-shot probe treats a transient down as a hard fail. Fix: sample nodetool status two or three times a few seconds apart and require the DN to persist across samples before failing the gate; corroborate with nodetool gossipinfo and the failure-detector view in node gossip and failure-detection protocols before trusting a lone snapshot.
  • Disk projection miscount understates inbound load. The disk-headroom gate divides the target’s load evenly across survivors, but real token ownership is not uniform — with vnodes and 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 ring or the system.size_estimates per-range data) rather than dividing total load by node count, and always pad with compaction overhead.
  • Repair session detection gaps. The no-active-repair gate passes, yet a repair is genuinely running — nodetool netstats only 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: netstats is a streaming view, not a repair-session registry. Fix: also check the repair-specific surface — grep system.log for Starting repair without 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.