Gray failure: dead node versus slow node
Simulate a modelNeeds Python
Problem
A dead node is easy: health checks fail, the node is removed, and the system works around it. A slow node passes its health checks, keeps receiving traffic, and in a scatter-gather query that waits for every node it sets the latency of every request. That is gray failure, and it is worse than fail-stop. The usual fix compares each node with its peers and ejects outliers; it works only if most peers are healthy. And health checks deep enough to catch a broken dependency can fail on every node at once, so an eviction cap stops a shared failure from evicting the whole fleet.
Simulate 200 search nodes. A query asks all 200 nodes and waits for all answers. Normal node latency is 10 ms with small random variation. Case A: one node is dead and the client skips it after a 20 ms health check. Case B: one node is 100 times slower (1,000 ms). Case C: apply peer-relative ejection (eject a node whose p99 is above 3 times the median of its peers), then make all 200 nodes 5 times slower. Finally add an eviction cap of 20% and make a deep check fail on all nodes.
The question. What share of queries does a dead node slow down, compared with a slow one, and when does peer ejection stop working?
Predict
In case A about 0% of queries are slow, because the dead node is removed. In case B 100% of queries wait for the slow node, so the latency is about 1,000 ms for every query. In case C latency returns to about 10 ms. The all-slow case defeats peer comparison, which is why an absolute alert is also needed.
Run it
1Install uv once. It fetches Python and the lab's packages for you.
Terminal curl -LsSf https://astral.sh/uv/install.sh | sh2Get the script. It is the whole lab: read it, then download it.
View the codeHide the code
gray-failure-dead-node-versus-slow.py # /// script # requires-python = ">=3.10" # dependencies = ["numpy"] # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """Gray failure: a dead node against a slow one. A query fans out to 200 search nodes and waits for all. Case A: one node is dead and skipped after a health check. Case B: one node is 100 times slower. Case C: peer-relative ejection (p99 above 3 x the peers' median); then all nodes 5 times slower; then an eviction cap when a deep check fails everywhere. """ import argparse, json import numpy as np p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--nodes", type=int, default=200, help="Nodes.") p.add_argument("--queries", type=int, default=10_000, help="Queries.") p.add_argument("--cap", type=float, default=0.20, help="Largest share of nodes that may be evicted.") p.add_argument("--seed", type=int, default=0, help="Random seed.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() rng = np.random.default_rng(args.seed); N, Q = args.nodes, args.queries def latencies(slow_factor=1.0, slow_node=None, scale=1.0): lat = rng.normal(10, 1, (Q, N)).clip(5) * scale if slow_node is not None: lat[:, slow_node] *= slow_factor return lat def eject(lat): p99 = np.percentile(lat, 99, axis=0); med = np.median(p99) return np.where(p99 > 3 * med)[0] rows = [] lat = latencies(); lat = np.delete(lat, 0, axis=1) # A: dead node skipped q = lat.max(axis=1); rows.append({"case": "A: one node dead, skipped", "median_ms": float(np.median(q)), "slow_pct": float(np.mean(q > 50) * 100)}) lat = latencies(100, 0); q = lat.max(axis=1) # B: one node 100x slower rows.append({"case": "B: one node 100x slower", "median_ms": float(np.median(q)), "slow_pct": float(np.mean(q > 50) * 100)}) ej = eject(lat); q = np.delete(lat, ej, axis=1).max(axis=1) # C: peer-relative ejection rows.append({"case": f"C: peer ejection ({len(ej)} node ejected)", "median_ms": float(np.median(q)), "slow_pct": float(np.mean(q > 50) * 100)}) lat = latencies(scale=5); ej2 = eject(lat); q = np.delete(lat, ej2, axis=1).max(axis=1) rows.append({"case": f"all nodes 5x slower: peer ejection fires on {len(ej2)}", "median_ms": float(np.median(q)), "slow_pct": float(np.mean(q > 50) * 100)}) failing = N # a deep check (the database) fails everywhere evicted = min(failing, int(args.cap * N)) if failing <= args.cap * N else 0 rows.append({"case": f"deep check fails on all {N}: evicted with a {args.cap:.0%} cap", "median_ms": float("nan"), "slow_pct": float("nan"), "evicted": evicted}) for r in rows: if "evicted" in r: print(f"{r['case']:<52} evicted {r['evicted']}") else: print(f"{r['case']:<52} median query {r['median_ms']:7.1f} ms queries over 50 ms {r['slow_pct']:5.1f}%") if args.json: with open(args.json, "w") as f: json.dump({"tables": {"cases": [r for r in rows if "evicted" not in r], "summary": [{"evicted": rows[-1]["evicted"], "all_slow_ejected": len(ej2)}]}}, f)3Run it in the folder you saved it to, with the defaults first. Then change a knob from the table below and run it again.
Terminal uv run gray-failure-dead-node-versus-slow.py uv run gray-failure-dead-node-versus-slow.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --nodes200 | Nodes. |
| --queries10,000 | Queries. |
| --cap0.2 | Largest share of nodes that may be evicted. |
| --seed0 | Random seed. |
Our run
Query latency, 10,000 queries
| Case | Median (ms) | Over 50 ms |
|---|---|---|
| A: one node dead, skipped | 12.7 | 0% |
| B: one node 100x slower | 998 | 100% |
| C: peer ejection (1 node ejected) | 12.7 | 0% |
| all nodes 5x slower: peer ejection fires on 0 | 63.5 | 100% |
A: one node dead, skipped median query 12.7 ms queries over 50 ms 0.0%
B: one node 100x slower median query 998.0 ms queries over 50 ms 100.0%
C: peer ejection (1 node ejected) median query 12.7 ms queries over 50 ms 0.0%
all nodes 5x slower: peer ejection fires on 0 median query 63.5 ms queries over 50 ms 100.0%
deep check fails on all 200: evicted with a 20% cap evicted 0What we found
The dead node cost nothing: median 12.7 ms, 0% of queries slow. The slow node made 100% of queries slow, with a median of 998 ms. Peer ejection removed it and brought the median back to 12.7 ms. With every node 5 times slower it ejected 0 nodes while every query was slow. With the deep check failing everywhere, the 20% cap evicted 0. The median under case A is above 10 ms because a query waits for the slowest of 200 normal nodes.
Read it
Fail-slow is worse than fail-stop because nothing removes the node, and in a fan-out every request touches it. Relative checks catch the one bad node and are blind to a fleet-wide slowdown; absolute checks catch the fleet-wide case and cannot tell which node is bad. You need both, and an eviction cap so that a check that fails for a shared reason does not take everything down with it.