cld-toys › Toys › failure-detector

Commentary: failure-detector

Two ways to decide a node is dead, over the same heartbeats: a constant timeout, and Hayashibara's phi-accrual detector, which fits a model of the network and convicts when the silence gets improbable. The adaptive one convicts a healthy node on the single heartbeat where the network changes, and then goes on to tolerate a longer silence than the one it just killed for. A study guide for detector.py.

failure-detector/ on GitHub·the source with line numbers, for readers who'd rather not download the .py files
How to read this This is the only documentation the toy has — read it with detector.py open beside you. detector.py is the toy itself (134 lines, two detector classes and a trace generator); demo.py runs the whole comparison; test_detector.py pins all 17 claims on this page. Nothing sleeps, no thread is started, and every trace is seeded — the demo prints byte-identical output on every run. No dependencies, stdlib only. Every transcript below was captured on macOS 26.5.2 (Darwin 25.5.0, arm64 Apple Silicon), Python 3.15.0a8 (main, Apr 14 2026, 14:20:41) [Clang 22.1.3].
cd failure-detector
python3 demo.py           # the aha (§6), about 20 seconds
python3 test_detector.py  # pins every number this page claims
Contents
  1. Orientation
  2. The problem this mechanism exists to solve
  3. Background you need
  4. The mental model
  5. Reading the source
  6. The demo, and what it proves
  7. Design decisions and roads not taken
  8. What's simplified vs. the real thing
  9. Check yourself
  10. Further reading

1. Orientation

This toy is a failure detector: the component that turns "I haven't heard from that node lately" into "that node is dead," which is the input every leader election, every failover, and every membership protocol is built on top of.

It ships two of them over the identical heartbeat stream:

The shortest version of the result:

from detector import PhiDetector, heartbeat_trace

arrivals, _ = heartbeat_trace(4, [(40, 10.0, 3.0), (30, 80.0, 40.0), (30, 10.0, 3.0)])
d = PhiDetector()                    # threshold 8, the shipped default
d.heartbeat(arrivals[0])
for i in range(1, len(arrivals)):
    if d.suspects(arrivals[i]):      # the node is alive; it just sent this
        print(f"convicted at beat {i} on a "
              f"{arrivals[i] - arrivals[i-1]:.1f} ms gap")
    d.heartbeat(arrivals[i])

By the end you should be able to say why an adaptive detector is at its worst precisely when the thing it adapts to changes, why the fix that production ships is a hard-coded constant, and why "just tune the timeout" is a better answer than its reputation suggests.

2. The problem this mechanism exists to solve

A failure detector has exactly one kind of evidence — heartbeats that stopped arriving — and it is being asked a question that evidence cannot answer. A dead node and a slow network are indistinguishable from the outside. In an asynchronous network there is no silence long enough to be proof, because there is no bound on message delay to compare it against.

So every detector is choosing a point on a trade-off, and the two ends are both real costs:

The phi detector's pitch is that this trade-off shouldn't be resolved by a constant chosen at deploy time by someone guessing at the network. Instead it should be learned: watch the inter-arrival times, and let the threshold follow the network's actual behaviour. On a calm network, be aggressive. On a congested one, be patient. One knob (threshold), expressed in units that mean something — a probability — rather than milliseconds that mean nothing without knowing the network.

That pitch is good enough that it is the default in Akka and Cassandra. This toy is about what it costs.

3. Background you need

ConceptWhere it's used in the toyLink
Heartbeat / inter-arrival timeheartbeat_trace emits a beat every 100 ms; the gap between arrivals is what both detectors actually seeHeartbeat (computing)
Normal distribution & the tail probabilityPhiDetector.phi fits Normal(mu, sigma) to the window and asks P(X > elapsed)Normal distribution
erfc, the complementary error functionhow that tail probability is computed in one line, without a stats packagemath.erfc
Standard deviation as a fitted quantitystatistics.pstdev(self.intervals) — the number that collapses on a calm network and causes the entire resultStandard deviation
Sliding windowdeque(maxlen=window) keeps the last 100 intervals and silently forgets older onescollections.deque
Pareto dominance§6's table: one config beats another only if it wins on both false alarms and detection timePareto efficiency

The two starred rows carry the result. Everything on this page follows from the fact that sigma is estimated from recent data and therefore describes the network you had, not the network you have.

4. The mental model

Both detectors watch the same thing — elapsed silence — and differ only in what they compare it against.

heartbeats arrive the sender dies | | | | | x ---+----+----+----+----+--------------------------> time \______________________/ elapsed silence FixedDetector: elapsed > 300ms ? a line drawn once |------------------| 0 300 PhiDetector: how weird is `elapsed`, given the last 100 gaps I saw? calm network congested network mu=100 sigma=3.6 mu=101 sigma=9.0 | | __|__ __|__ / | \ / | \ <- the fitted / | \ / | \ model ---------+----+----+------ -----+----+-------+------ 100 132.6 101 142.8 ^ ^ 9.1 sigma out 4.6 sigma out phi = 19.5 -> DEAD phi = 5.8 -> alive

That is the whole toy. The same detector, four heartbeats apart, convicts a 132.6 ms silence and acquits a 142.8 ms one — because between the two, the window learned that the network had got noisier, and sigma grew from 3.58 to 9.04. The bell curve got wider, so the same distance from the mean stopped being remarkable.

Adaptivity means the verdict depends on the history, not just the evidence.

5. Reading the source

The fixed detector is the whole of the baseline, and it is four lines:

detector.py · lines 37–45
    def __init__(self, timeout_ms: float):
        self.timeout_ms = timeout_ms
        self.last_arrival = 0.0

    def heartbeat(self, arrival_ms: float) -> None:
        self.last_arrival = arrival_ms

    def suspects(self, now_ms: float) -> bool:
        return now_ms - self.last_arrival > self.timeout_ms

detector.py · lines 37–45

It has no memory beyond the last arrival and no model at all. That is worth holding onto, because it is exactly why it cannot be surprised: there is no belief about the network for the network to violate.

Now phi. This is the method the entire page is about:

detector.py · lines 89–99
    def phi(self, now_ms: float) -> float:
        if self.last_arrival is None:
            return 0.0
        elapsed = now_ms - self.last_arrival
        mu = statistics.fmean(self.intervals)
        sigma = max(statistics.pstdev(self.intervals), self.min_stddev_ms)
        # P(X > elapsed) for X ~ Normal(mu, sigma), via the complementary
        # error function. Clamped because the tail underflows to 0.0 long
        # before the silence gets interesting.
        p_later = 0.5 * math.erfc((elapsed - mu) / (sigma * math.sqrt(2.0)))
        return -math.log10(max(p_later, 1e-300))

detector.py · lines 89–99

Read the middle two lines as a pair. mu and sigma are re-fitted on every call from the last 100 intervals. The detector does not have a threshold; it has a procedure for computing one, and the procedure's inputs are the recent past.

max(..., self.min_stddev_ms) is the load-bearing line in this file. It looks like defensive programming — avoid dividing by zero when every interval is identical — and it is also the only thing standing between the detector and the failure mode in §6. §6.3 turns it into the difference between 384 false alarms and 5.

The 1e-300 clamp is a smaller decision with a consequence worth knowing: phi saturates at 300. Once a silence is that improbable, phi stops distinguishing 200 ms from four seconds. That never changes a convict/acquit decision at threshold 8, but it does mean phi is not a distance — you cannot subtract two phis and get anything meaningful. test_detector.py::phi_saturates_at_300_because_the_normal_tail_underflows pins it.

One detail in heartbeat that is easy to get wrong:

detector.py · lines 80–87
    def heartbeat(self, arrival_ms: float) -> None:
        # The first heartbeat establishes a reference point and nothing else:
        # there is no *interval* until two have arrived. Recording one here
        # would put the node's join time into the window as if it were a
        # network delay.
        if self.last_arrival is not None:
            self.intervals.append(arrival_ms - self.last_arrival)
        self.last_arrival = arrival_ms

detector.py · lines 80–87

Without the guard, a node joining at wall-clock 09:41:23 contributes an "interval" of about 35 billion milliseconds to the window, and the detector spends the next 100 heartbeats believing the network is unimaginably slow. This is the sort of bug that only shows up in production, on restart.

6. The demo, and what it proves

The scenario: a sender emits a heartbeat every 100 ms and never varies. Only the network delay changes — calm N(10, 3) ms for 40 beats, congested N(80, 40) ms for 30, then calm again for 30. After beat 100 the sender dies. 200 seeded traces.

6.1 The moment the network changes

beat gap ms window mu window sd phi verdict 37 94.6 100.07 3.27 0.02 alive 38 106.4 99.95 3.33 1.59 alive 39 91.8 100.09 3.42 0.00 alive 40 132.6 99.92 3.58 19.48 DEAD 41 139.1 100.57 5.79 10.85 DEAD 42 67.6 101.33 7.84 0.00 alive 43 109.8 100.68 9.04 0.81 alive 44 142.8 100.85 9.04 5.76 alive 45 110.9 101.63 10.59 0.72 alive 46 75.6 101.80 10.57 0.00 alive

Beat 40 is the first congested heartbeat, and the node is alive — it just sent the packet being measured. Derive the verdict:

z = (132.6 - 99.92) / 3.58 = 9.13 sigma p = 0.5 * erfc(9.13 / sqrt(2)) = 3.5e-20 phi = -log10(3.5e-20) = 19.46 > 8 -> DEAD

(That reproduces the table's 19.48 to within the rounding the table itself does — the demo computes phi from unrounded mu and sigma, which are 99.9174 and 3.5816.)

Threshold 8 means "convict when this silence is a 1-in-10^8 event." A 132.6 ms gap qualifies, because the model was fitted to a network whose gaps had a standard deviation of 3.58 ms. Under that network, it is.

Now beat 44, four beats later, on a longer gap:

z = (142.8 - 100.85) / 9.04 = 4.64 sigma p = 0.5 * erfc(4.64 / sqrt(2)) = 1.7e-06 phi = -log10(1.7e-06) = 5.76 < 8 -> alive

The gap grew by 10.2 ms and the verdict flipped from guilty to innocent, because sigma grew 2.5× in the meantime. The detector needed to be wrong once in order to learn.

6.2 It is not a fluke of one seed

traces where phi convicted a live node: 200/200 ...and later tolerated a LONGER silence: 161/200 = 80.5% mean silence it convicted on: 158.7 ms mean longest silence it then tolerated: 199.0 ms

Every one of the 200 traces produces at least one false conviction, and in four out of five the detector goes on to shrug at a silence 40 ms longer than the one it convicted on. 173 of the 200 fire on beat 40 specifically — 45% of all 384 false alarms land on the single heartbeat where the regime changes.

And the tuning does not rescue it:

detector false alarms detect ms status phi threshold 2 1655 80.2 on the frontier phi threshold 3 1051 103.4 on the frontier phi threshold 5 593 138.9 on the frontier phi threshold 8 384 179.7 dominated by fixed 250ms phi threshold 10 335 202.4 dominated by fixed 250ms phi threshold 12 290 222.8 dominated by fixed 250ms phi threshold 16 240 258.8 dominated by fixed 250ms phi threshold 20 205 290.3 dominated by fixed 250ms fixed 250ms 20 159.8 on the frontier fixed 275ms 2 184.8 on the frontier fixed 300ms 0 209.8 on the frontier fixed 350ms 0 259.8 dominated by fixed 300ms fixed 400ms 0 309.8 dominated by fixed 300ms

A fixed 250 ms timeout raises 20 false alarms and detects in 159.8 ms. Phi at its standard threshold of 8 raises 384 and detects in 179.7 ms — worse on both axes at once, which is what "dominated" means in that column. Raising the threshold trades false alarms for latency along phi's own curve and never gets it back onto the frontier; test_detector.py::raising_the_threshold_never_lets_phi_escape_domination pins that for thresholds 8 through 20.

6.3 The load-bearing line

min_stddev_ms false alarms detect ms 1 384 179.7 5 328 179.7 10 245 179.7 20 63 179.7 30 5 187.8 50 0 290.4

One constant, changed from 1 ms to 30 ms, takes 384 false alarms to 5 — and costs 8 ms of detection latency to do it. At 50 ms it reaches zero, and now costs 110 ms.

Read that table twice. The adaptive detector is rescued by hard-coding a floor that tells it to distrust its own variance estimate. The fix for "too adaptive" is "be less adaptive," and the knob is measured in milliseconds — the same unit the phi detector was supposed to free you from.

6.4 It is not about the change being sudden

The obvious explanation is that a step change is unfair, and a real network would degrade gradually. It doesn't help:

ramp phi false alarms fixed 300ms 1 beats 384 0 3 beats 487 0 5 beats 491 0 10 beats 456 0 20 beats 391 0 30 beats 322 0

Ramping the congestion in over 30 heartbeats still leaves 322 false alarms, and short ramps are worse than the step. A gradual ramp keeps the mean creeping upward while the 100-sample window stays dominated by calm data, so sigma stays small for longer. Smearing the change out gives the detector more opportunities to be surprised, not fewer.

6.5 The boundary: where the effect vanishes

It doesn't vanish, and that is worth being honest about. Removing the regime change entirely still leaves phi convicting live nodes (this table comes from cf_detector.py CF4, not from demo.py):

trace phi FA fixed-300 FA all calm N(10,3) 47 0 all congested N(80,40) 168 0 calm/congested/calm (shipped) 384 0

A regime change roughly doubles the damage and concentrates it at one instant, but a stationary network still produces false alarms, because a normal model of a right-skewed delay distribution underestimates its own tail. The effect this page is about — convicting at the transition — is what the change adds; the baseline is phi's model being wrong about the shape.

The place the comparison genuinely reverses is not in this toy. What does narrow with a stationary network is the margin: on all-congested traffic phi takes 374.6 ms to detect against fixed-300's 278.9 ms, so phi is still slower and still less accurate, but the fixed timeout there is a number someone had to know in advance. That is the honest case for phi: not that it wins on these axes, but that it gets to a defensible operating point without anyone having measured the network first. This toy's scenario is one where somebody did measure it.

7. Design decisions and roads not taken

Arrival times computed up front, never slept. heartbeat_trace returns a list of floats and every detector is a pure function of (history, now). The demo runs 200 traces in about 20 seconds instead of 20 seconds per trace, the output is byte-identical every run, and — the part that actually matters — §6.1's table can ask what the detector believed at one exact instant. A threads-and-time.monotonic() version of this toy could not have produced that table at all.

Suspicion is sampled at the next arrival, not on a polling grid. Both detectors are monotonic in elapsed time: silence never becomes less alarming while it continues. So the most alarming instant in any gap is the moment just before the next heartbeat lands, and checking exactly there is exact rather than a sample. The same monotonicity is what makes time_to_suspect's bisection valid, and it's stated in that function's docstring for the reader who wonders why a binary search is allowed.

The normal distribution, not the exponential. Hayashibara's paper fits a normal, and so does Akka; Cassandra's implementation does not. Fitting a distribution with the wrong shape is part of why §6.5's stationary traces still produce false alarms. A version of this toy that fitted an exponential would be a different, also-interesting toy — this one is about what happens when sigma moves, which is a property of any fitted scale parameter.

Detection latency measured from when the heartbeat was due, not from the last one that arrived. A detector should not be credited for network delay it already absorbed; measuring from the last arrival would flatter whichever detector happened to get a fast final packet.

No suspicion levels, no unreachable state, no gossip. Real membership systems don't act on one node's opinion — they disseminate suspicion and require corroboration, which is precisely a defence against the failure mode on this page. That's SWIM's design, and it is out of scope here for the same reason the toy has one node watching one other node: adding a quorum would make the false alarms mostly disappear and hide the mechanism that causes them.

8. What's simplified vs. the real thing

9. Check yourself

Q1. At beat 40 the window's sigma is 3.58 ms. If the floor min_stddev_ms had been set to 30 ms, what would phi have been on that same 132.6 ms gap — and would the node have survived?

Answer

sigma becomes max(3.58, 30) = 30, so z = (132.6 - 99.92) / 30 = 1.09, p = 0.5 * erfc(1.09/sqrt(2)) = 0.138, and phi = -log10(0.138) = 0.86. Comfortably under 8: the node survives. That single substitution is what takes the census from 384 false alarms to 5 in §6.3.

Q2. The demo reports that a fixed 300 ms timeout detects a real crash in 209.8 ms — less than its own timeout. How is that possible?

Answer

Latency is measured from when the missing heartbeat was due, not from the last one that arrived. The last heartbeat to arrive was sent 100 ms before the one that never came, and took about 10 ms to cross a calm network. So the detector's 300 ms of silence starts roughly 90 ms before the deadline it is being scored against: 300 - (100 - 10) = 210. The 209.8 ms in the table is that number, averaged over 200 traces' worth of final-delay jitter.

This is also why the fixed rows in §6.2 are spaced exactly 50 ms apart as the timeout rises in 50 ms steps — the offset is constant.

Q3. Both detectors are monotonic in now, which the source uses to justify bisection in time_to_suspect. Is PhiDetector monotonic in elapsed silence if the window is still changing?

Answer

Monotonic in now between heartbeats, which is all bisection needs: no heartbeat arrives during the search, so self.intervals is frozen and phi is a strictly increasing function of elapsed (the normal tail decreases, and -log10 of a decreasing positive function increases).

Across heartbeats it is emphatically not monotonic — that is the entire subject of this page. Beat 40's 132.6 ms scores 19.48 and beat 44's 142.8 ms scores 5.76. test_detector.py::phi_is_monotonic_in_now_which_is_what_makes_bisection_valid pins the first claim; §6.1 is the counterexample to the second.

Q4. You run this detector in a cluster where nodes are evicted on conviction, and eviction triggers a shard rebalance that saturates the network for a few seconds. What happens, and which number in §6 predicts it?

Answer

A feedback loop. The rebalance is a regime change of exactly the kind §6.1 describes: delays jump, and every other node's phi detector is still fitted to the pre-rebalance calm. 173 of 200 traces convict on the first heartbeat of the new regime, so a large fraction of the surviving nodes convict each other, triggering more evictions and more rebalancing.

This is why real systems don't act on a single detector's verdict (§8), and why the acceptable-heartbeat-pause margin is measured in seconds rather than the tens of milliseconds this toy's traffic would suggest.

Q5. Someone proposes shrinking the window from 100 to 10, arguing the detector will adapt to the new regime faster. Does it reduce false alarms?

Answer

Barely, and it changes something else entirely. Running the census at window=10 gives 259 false alarms against 100's 384 — an improvement, but nowhere near the floor's 5 — while mean detection time drops from 179.7 ms to 32.8 ms.

The reason detection gets so much faster is not that the detector got smarter: with a 10-sample window, by the time the sender dies the window holds only calm-phase intervals, so sigma is tiny and a short silence is already improbable. That is the same mechanism as the false alarms, pointed at a node that really is dead. The window size trades the two failure modes against each other rather than fixing either — which is why the floor, not the window, is the load-bearing line.

(Reproduce with cf_detector.py CF1, or census(lambda: PhiDetector(window=10)) in a REPL.)

10. Further reading