recon_protocols/eventually_perfect_failure_detector.rs
1//! Eventually perfect failure detection — ◇P.
2//!
3//! **Status: implementation. Space: bounded by membership.** One entry per peer and nothing per
4//! message, as the perfect detector has, plus one adaptive delay.
5//!
6//! Cachin, Guerraoui & Rodrigues, Module 2.8 and Algorithm 2.7 ("Increasing Timeout"), quoted from
7//! the book:
8//!
9//! ```text
10//! Algorithm 2.7: Increasing Timeout
11//! Implements: EventuallyPerfectFailureDetector, instance ◇P.
12//! Uses: PerfectPointToPointLinks, instance pl.
13//!
14//! upon event ⟨ ◇P, Init ⟩ do
15//! alive := Π; suspected := ∅; delay := Δ;
16//! starttimer(delay);
17//!
18//! upon event ⟨ Timeout ⟩ do
19//! if alive ∩ suspected ≠ ∅ then
20//! delay := delay + Δ;
21//! forall p ∈ Π do
22//! if (p ∉ alive) ∧ (p ∉ suspected) then
23//! suspected := suspected ∪ {p};
24//! trigger ⟨ ◇P, Suspect | p ⟩;
25//! else if (p ∈ alive) ∧ (p ∈ suspected) then
26//! suspected := suspected \ {p};
27//! trigger ⟨ ◇P, Restore | p ⟩;
28//! trigger ⟨ pl, Send | p, [HEARTBEATREQUEST] ⟩;
29//! alive := ∅;
30//! starttimer(delay);
31//! ```
32//!
33//! # What ◇P is for, and why this repository now needs it
34//!
35//! [`crate::perfect_failure_detector`] promises *if a process is detected, it has crashed* — never
36//! wrong, never retracted — and is implementable only where a delivery bound Δ is **known in
37//! advance**. ◇P weakens that to *eventually, no correct process is suspected*, and therefore must
38//! be able to take a suspicion back. `Restore` is the whole difference.
39//!
40//! It matters here because a crashed process now **comes back**. Under `P` a suspicion is permanent
41//! by construction, so Ω's `suspected` set only grows, `maxrank` only ever walks downward through
42//! the membership, and a recovered process can never lead again.
43//!
44//! # `alive ∩ suspected ≠ ∅` is the algorithm noticing it was wrong
45//!
46//! The guard reads: *someone I suspected has been heard from this round*. That is a false suspicion
47//! caught in the act, and the book's response is to wait longer next time. Read the negations
48//! carefully — an OCR of the page drops them from `if (p ∉ alive) ∧ (p ∉ suspected)`, and taking it
49//! as written inverts the algorithm into one that suspects whoever it just heard from.
50//!
51//! # Departure: the delay comes down again
52//!
53//! Algorithm 2.7 adds `Δ` on every false suspicion and **never subtracts**. That is a ratchet, and
54//! its cost is not the unboundedness but the irreversibility: one bad period leaves detection
55//! permanently slower for the rest of the run, long after the network recovered, with nothing
56//! reporting that it has.
57//!
58//! So after [`Config::quiet_rounds`] consecutive rounds in which **nothing at all was suspected**,
59//! the delay comes down by one `step` — never below [`Config::min_delay`]. **Down slowly, up fast**:
60//! the increase is immediate and the decrease waits, and that asymmetry is what damps the
61//! oscillation a symmetric rule would produce around the true bound.
62//!
63//! **"Nothing suspected", not "nothing withdrawn", and the difference is the whole rule.** The first
64//! draft eased off after rounds in which no suspicion was *taken back*, which is wrong in the worst
65//! case: a network bad enough that suspicions are never withdrawn produces no withdrawals at all, so
66//! the delay came down while the detector was being consistently wrong. Measured: against a network
67//! twelve times the initial delay, the delay drifted back to the floor instead of pinning at the
68//! cap. With nothing suspected there is no outstanding claim that could be wrong and the network is
69//! evidently keeping up, which is the only situation in which easing off is defensible.
70//!
71//! The price is that a genuinely crashed peer, permanently suspected, **freezes** the delay wherever
72//! it had reached. That is deliberate: with a crashed process in the membership there is no clean
73//! signal to ease off on, and freezing is strictly better than the ratchet — which grows — and than
74//! decreasing blindly, which gets more wrong. A detector that could tell the two apart would be
75//! measuring the observed silence of the peers it is *not* suspecting, which is an accrual detector,
76//! and that is the next change rather than this one.
77//!
78//! # What drives the increase, which is not what you would guess
79//!
80//! `alive ∩ suspected ≠ ∅` fires when a suspected process is heard from — a false suspicion caught
81//! **in the act of being corrected**. So the delay climbs with the rate at which the detector is
82//! *caught out*, not the rate at which it is wrong. A detector that is consistently wrong, because
83//! every peer is beyond the delay every round, is never corrected and never climbs. That is
84//! Algorithm 2.7's own behaviour rather than anything added here, and it is why the increase alone
85//! does not converge on a bad network — the cap is what makes the failure bounded and stated.
86//!
87//! What it costs: *strict* eventual accuracy under partial synchrony, where the delay bound is
88//! merely finite and unknown. Under a bound that never settles, a detector that can come down can be
89//! wrong for ever. What it buys back is accuracy under a weaker and more realistic assumption — that
90//! the true delay eventually stops **changing** — under which the estimate converges and the ratchet
91//! does not.
92//!
93//! # Departure: the delay is capped
94//!
95//! `delay` never exceeds [`Config::max_delay`]. Unconditional eventual accuracy needs unbounded
96//! growth, because partial synchrony refuses to let you assume any bound in advance — but that is a
97//! property of the model rather than of networks, and an operator knows their delay distribution to
98//! within orders of magnitude.
99//!
100//! **Why capping is the right loss.** Ask what a wrong ◇P breaks. Ω trusts the wrong leader, which
101//! costs an epoch change and an abort — liveness, never safety; `leader_driven_consensus` states
102//! agreement as `[always]` and termination as already conditional on the detector settling. So a cap
103//! that is occasionally too small costs progress during a network episode and clears when the
104//! episode does. The uncapped ratchet costs progress *permanently*, and silently. Both are liveness
105//! failures; only one recovers.
106//!
107//! Exceeding the cap is the stated condition failing rather than the implementation, exactly as Δ
108//! is for the perfect detector — and `tests/eventually_perfect_failure_detector.rs` makes it happen
109//! rather than describing it.
110//!
111//! # Departure: heartbeats are unsolicited, and the period is not the timeout
112//!
113//! Both inherited from [`crate::perfect_failure_detector`], for the reasons its documentation gives:
114//! one unsolicited beat per round distinguishes the same failures in half the messages of a
115//! request/reply exchange, and separating the beat period from the silence a peer is allowed stops a
116//! single missed round being fatal. Here the *timeout* is what adapts; the period does not.
117//!
118//! ```text
119//! ◇P1 [always] Strong completeness — every crashed process is eventually permanently
120//! suspected by every correct process
121//! ◇P2 [Δ ≤ max_delay ∧ Δ eventually stable]
122//! Eventual strong accuracy — eventually no correct process is suspected
123//! ```
124//!
125//! `◇P2`'s scope is the two departures above, written down. The same shape as `PB2 [window]` and
126//! `SL1 [session]`: the guarantee is conditional and the condition is named, rather than the
127//! guarantee being quietly weaker than the page's.
128
129use core::time::Duration;
130use recon_core::{NodeId, ProtoCx, Protocol, Time, TimerId};
131use std::collections::{BTreeMap, BTreeSet};
132
133use crate::detector::{Detector, DetectorInd};
134use crate::perfect_failure_detector::Heartbeat;
135
136/// Requests from the layer above: none, as Module 2.8 has it.
137pub type Cmd = core::convert::Infallible;
138
139/// Indications to the layer above.
140#[derive(Debug, Clone, Copy, PartialEq, Eq)]
141pub enum Ind {
142 /// `⟨ ◇P, Suspect | p ⟩` — `node` is suspected of having crashed. May be wrong, and may be
143 /// followed by [`Ind::Restore`].
144 Suspect { node: NodeId },
145 /// `⟨ ◇P, Restore | p ⟩` — `node` is no longer suspected. The indication `P` does not have.
146 Restore { node: NodeId },
147}
148
149/// How this detector beats, waits and adapts.
150#[derive(Debug, Clone, Copy, PartialEq, Eq)]
151pub struct Config {
152 /// How often this process announces itself. Fixed; only the timeout adapts.
153 pub period: Duration,
154 /// The silence a peer is allowed before it is suspected, to begin with. The book's `Δ`.
155 pub initial_delay: Duration,
156 /// The book's `Δ` again, as the amount the delay moves by in either direction.
157 pub step: Duration,
158 /// The delay never falls below this.
159 pub min_delay: Duration,
160 /// The delay never rises above this. See the module note on what the cap costs.
161 pub max_delay: Duration,
162 /// How many consecutive rounds with **nothing suspected** before the delay comes down. Up
163 /// fast, down slow: this is the asymmetry that damps the oscillation. See the module note on
164 /// why the condition is "nothing suspected" rather than "nothing withdrawn".
165 pub quiet_rounds: u32,
166}
167
168impl Config {
169 /// A configuration that beats every `period` and starts by allowing `initial_delay` of silence,
170 /// stepping by the period, never below it and never above `max_delay`, easing off after four
171 /// quiet rounds.
172 pub fn new(period: Duration, initial_delay: Duration, max_delay: Duration) -> Self {
173 Config {
174 period,
175 initial_delay,
176 step: period,
177 min_delay: initial_delay,
178 max_delay,
179 quiet_rounds: 4,
180 }
181 }
182}
183
184/// Detects crashes by heartbeat timeout, and changes its mind.
185#[derive(Debug)]
186pub struct EventuallyPerfectFailureDetector {
187 me: NodeId,
188 peers: BTreeSet<NodeId>,
189 /// When each peer was last heard from. `alive` in the book is *this round's* arrivals; a
190 /// timestamp says the same thing and survives the period and timeout being separate.
191 last_heard: BTreeMap<NodeId, Time>,
192 /// `suspected`. Grows and shrinks, which is the point.
193 suspected: BTreeSet<NodeId>,
194 config: Config,
195 /// `delay`.
196 delay: Duration,
197 /// Consecutive rounds with nothing suspected at all.
198 quiet: u32,
199 /// The tick outstanding. A handle, so an expiry this detector has superseded accuses nobody.
200 tick: Option<TimerId>,
201}
202
203impl EventuallyPerfectFailureDetector {
204 /// Detect among `peers`, adapting as `config` says.
205 pub fn new(me: NodeId, peers: impl IntoIterator<Item = NodeId>, config: Config) -> Self {
206 debug_assert!(
207 config.initial_delay > config.period,
208 "a delay no longer than the heartbeat period suspects live processes every round"
209 );
210 debug_assert!(config.min_delay <= config.max_delay, "an empty range for the delay");
211 let mut peers: BTreeSet<NodeId> = peers.into_iter().collect();
212 peers.remove(&me);
213 EventuallyPerfectFailureDetector {
214 me,
215 peers,
216 last_heard: BTreeMap::new(),
217 suspected: BTreeSet::new(),
218 config,
219 delay: config.initial_delay,
220 quiet: 0,
221 tick: None,
222 }
223 }
224
225 /// The processes currently suspected. May shrink.
226 pub fn suspected(&self) -> impl Iterator<Item = NodeId> + '_ {
227 self.suspected.iter().copied()
228 }
229
230 /// Whether `node` is currently suspected.
231 pub fn suspects(&self, node: NodeId) -> bool {
232 self.suspected.contains(&node)
233 }
234
235 /// The processes not currently suspected, this one included.
236 pub fn correct(&self) -> impl Iterator<Item = NodeId> + '_ {
237 core::iter::once(self.me)
238 .chain(self.peers.iter().copied().filter(|p| !self.suspected.contains(p)))
239 }
240
241 /// The silence a peer is currently allowed. Adapts; see the module's two departures.
242 pub fn delay(&self) -> Duration {
243 self.delay
244 }
245
246 /// How often this process announces itself.
247 pub fn period(&self) -> Duration {
248 self.config.period
249 }
250
251 fn beat(&mut self, cx: &mut ProtoCx<'_, Self>) {
252 for &p in &self.peers {
253 cx.send(p, Heartbeat);
254 }
255 }
256}
257
258impl Detector for EventuallyPerfectFailureDetector {
259 fn classify(ind: Ind) -> DetectorInd {
260 match ind {
261 Ind::Suspect { node } => DetectorInd::Suspect { node },
262 Ind::Restore { node } => DetectorInd::Restore { node },
263 }
264 }
265}
266
267impl Protocol for EventuallyPerfectFailureDetector {
268 type Cmd = Cmd;
269 type Ind = Ind;
270 type Msg = Heartbeat;
271 /// No scope conditions of its own: `◇P2`'s conditions are on the network, not on a scope this
272 /// protocol is told about.
273 type Scope = core::convert::Infallible;
274 type Note = crate::Note;
275 /// Keeps nothing durably. A restarted detector suspects nobody and learns again.
276 type Meta = core::convert::Infallible;
277 type Entry = core::convert::Infallible;
278
279 /// `⟨ ◇P, Init ⟩ do alive := Π; suspected := ∅; delay := Δ; starttimer(delay)`.
280 fn on_init(&mut self, cx: &mut ProtoCx<'_, Self>) {
281 if self.tick.is_some() {
282 return;
283 }
284 let now = cx.now();
285 for &p in &self.peers {
286 self.last_heard.insert(p, now);
287 }
288 self.beat(cx);
289 self.tick = Some(cx.set_timer(self.config.period));
290 }
291
292 fn on_cmd(&mut self, cmd: Cmd, _cx: &mut ProtoCx<'_, Self>) {
293 match cmd {}
294 }
295
296 /// A heartbeat is `alive := alive ∪ {p}`. Note what is *not* here: no check against
297 /// `suspected`. Hearing from a suspected process is exactly the case `Restore` exists for, and
298 /// the round's own pass is where it is noticed.
299 fn on_msg(&mut self, from: NodeId, Heartbeat: Heartbeat, cx: &mut ProtoCx<'_, Self>) {
300 if self.peers.contains(&from) {
301 self.last_heard.insert(from, cx.now());
302 }
303 }
304
305 fn on_timer(&mut self, id: TimerId, cx: &mut ProtoCx<'_, Self>) {
306 if self.tick != Some(id) {
307 return;
308 }
309 let now = cx.now();
310 let heard_from = |d: &Self, p: NodeId| {
311 let last = d.last_heard.get(&p).copied().unwrap_or(Time::ZERO);
312 now.saturating_since(last) <= d.delay
313 };
314
315 // `if alive ∩ suspected ≠ ∅ then delay := delay + Δ` — a suspicion caught being corrected.
316 let was_wrong =
317 self.peers.iter().any(|p| self.suspected.contains(p) && heard_from(self, *p));
318 // Departure: down one step after `quiet_rounds` rounds with nothing suspected, never below
319 // the floor. Anything outstanding — right or wrong — holds the delay where it is.
320 let nothing_suspected =
321 self.suspected.is_empty() && self.peers.iter().all(|p| heard_from(self, *p));
322 if was_wrong {
323 self.delay = (self.delay + self.config.step).min(self.config.max_delay);
324 self.quiet = 0;
325 } else if nothing_suspected {
326 self.quiet += 1;
327 if self.quiet >= self.config.quiet_rounds {
328 self.quiet = 0;
329 self.delay = self.delay.saturating_sub(self.config.step).max(self.config.min_delay);
330 }
331 } else {
332 self.quiet = 0;
333 }
334
335 // `forall p ∈ Π do if (p ∉ alive) ∧ (p ∉ suspected) … else if (p ∈ alive) ∧ (p ∈ suspected)`
336 let changes: Vec<Ind> = self
337 .peers
338 .iter()
339 .copied()
340 .filter_map(|p| match (heard_from(self, p), self.suspected.contains(&p)) {
341 (false, false) => Some(Ind::Suspect { node: p }),
342 (true, true) => Some(Ind::Restore { node: p }),
343 _ => None,
344 })
345 .collect();
346 for change in changes {
347 match change {
348 Ind::Suspect { node } => {
349 self.suspected.insert(node);
350 }
351 Ind::Restore { node } => {
352 self.suspected.remove(&node);
353 }
354 }
355 cx.indicate(change);
356 }
357
358 self.beat(cx);
359 self.tick = Some(cx.set_timer(self.config.period));
360 }
361}