Skip to content
About
Main content

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 of every request. That is , and it is worse than . 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 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

One of 200 nodes becomes 100× slower. What share of queries are slow?

Run it

  1. 1Install uv once. It fetches Python and the lab's packages for you.

    Terminal
    curl -LsSf https://astral.sh/uv/install.sh | sh
  2. 2Get the script. It is the whole lab: read it, then download it.

    gray-failure-dead-node-versus-slow.py54 linesDownload
    View 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)
    
  3. 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
Knobs
Knob · defaultWhat 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

Query latency, 10,000 queries
CaseMedian (ms)Over 50 ms
A: one node dead, skipped12.70%
B: one node 100x slower998100%
C: peer ejection (1 node ejected)12.70%
all nodes 5x slower: peer ejection fires on 063.5100%
Reference runApple M2 · 8 cores · 8 GB
Output · uv run gray-failure-dead-node-versus-slow.py
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 0
macOS 26.6.2 · Python 3.13.3 · numpy 2.5.4 · run 2026-10-20

What 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

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.