Skip to main content

crafty_core/
failure_detector.rs

1//! Leader-side failure detectors for reachability (liveness-vs-membership).
2//!
3//! The default [`FailureDetectorKind::AckWindow`] pairs a configurable silence
4//! window with hysteresis so a briefly slow follower does not flap the
5//! supervisor. [`FailureDetectorKind::PhiAccrual`] implements the phi-accrual
6//! detector from the Haystack paper as a documented alternative when network
7//! jitter is high.
8
9use std::collections::{BTreeMap, VecDeque};
10
11use crafty_proto::NodeId;
12
13/// Which algorithm derives per-peer reachability on the leader.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15pub enum FailureDetectorKind {
16    /// Silence longer than the reachability window marks a peer unreachable;
17    /// recovery requires a fresh ack within `window − hysteresis`.
18    #[default]
19    AckWindow,
20    /// Inter-arrival statistics; suspect when φ exceeds the configured threshold.
21    PhiAccrual,
22}
23
24/// Tunable reachability parameters (logical ticks).
25#[derive(Debug, Clone, Copy, PartialEq)]
26pub struct ReachabilityConfig {
27    /// Ticks of silence before a **reachable** peer is marked unreachable.
28    /// `None` ⇒ `2 × election_timeout_max` at runtime.
29    pub window_ticks: Option<u64>,
30    /// Hysteresis band: a peer marked unreachable needs an ack within
31    /// `window − hysteresis` to flip back (reduces reconcile flapping).
32    pub hysteresis_ticks: u64,
33    /// Detector algorithm.
34    pub detector: FailureDetectorKind,
35    /// φ threshold when [`FailureDetectorKind::PhiAccrual`] is active (typical 8–12).
36    pub phi_threshold: f64,
37}
38
39impl Default for ReachabilityConfig {
40    fn default() -> Self {
41        Self {
42            window_ticks: None,
43            hysteresis_ticks: 0,
44            detector: FailureDetectorKind::AckWindow,
45            phi_threshold: 8.0,
46        }
47    }
48}
49
50impl ReachabilityConfig {
51    /// Resolve the silence window from config and election timing.
52    #[must_use]
53    pub fn window(&self, election_timeout_max: u64) -> u64 {
54        self.window_ticks
55            .unwrap_or_else(|| election_timeout_max.saturating_mul(2))
56    }
57
58    /// Hysteresis band; when zero, callers fall back to `election_timeout_min`.
59    #[must_use]
60    pub fn hysteresis(&self, election_timeout_min: u64) -> u64 {
61        if self.hysteresis_ticks > 0 {
62            self.hysteresis_ticks
63        } else {
64            election_timeout_min
65        }
66    }
67}
68
69/// Per-peer latched reachability with ack-window + hysteresis.
70#[derive(Debug, Clone, Default)]
71pub struct AckWindowLiveness {
72    latched: BTreeMap<NodeId, bool>,
73}
74
75impl AckWindowLiveness {
76    /// Recompute latched reachability for every voter except `self_id`.
77    pub fn update(
78        &mut self,
79        now: u64,
80        self_id: NodeId,
81        voters: &[NodeId],
82        last_ack: &BTreeMap<NodeId, u64>,
83        window: u64,
84        hysteresis: u64,
85    ) {
86        let high = window;
87        let low = window.saturating_sub(hysteresis);
88        for &peer in voters {
89            if peer == self_id {
90                continue;
91            }
92            let silence = last_ack
93                .get(&peer)
94                .map_or(u64::MAX, |&t| now.saturating_sub(t));
95            let entry = self.latched.entry(peer).or_insert(true);
96            if *entry {
97                if silence > high {
98                    *entry = false;
99                }
100            } else if silence <= low {
101                *entry = true;
102            }
103        }
104    }
105
106    /// Whether `peer` is currently considered reachable (defaults to true).
107    #[must_use]
108    pub fn is_reachable(&self, peer: NodeId) -> bool {
109        self.latched.get(&peer).copied().unwrap_or(true)
110    }
111
112    /// Clear state on leadership change.
113    pub fn clear(&mut self) {
114        self.latched.clear();
115    }
116}
117
118/// Phi-accrual failure detector for one peer (Haystack-style, tick time base).
119#[derive(Debug, Clone)]
120pub struct PhiAccrualDetector {
121    history: VecDeque<u64>,
122    last_heartbeat: Option<u64>,
123    threshold: f64,
124    max_samples: usize,
125}
126
127impl PhiAccrualDetector {
128    /// Create a detector with the given φ suspect threshold.
129    #[must_use]
130    pub fn new(threshold: f64) -> Self {
131        Self {
132            history: VecDeque::new(),
133            last_heartbeat: None,
134            threshold,
135            max_samples: 1000,
136        }
137    }
138
139    /// Record a successful heartbeat ack at logical tick `now`.
140    pub fn record_heartbeat(&mut self, now: u64) {
141        if let Some(last) = self.last_heartbeat {
142            let interval = now.saturating_sub(last);
143            if interval > 0 {
144                if self.history.len() >= self.max_samples {
145                    self.history.pop_front();
146                }
147                self.history.push_back(interval);
148            }
149        }
150        self.last_heartbeat = Some(now);
151    }
152
153    /// φ value: higher ⇒ more likely the peer is down.
154    #[must_use]
155    #[allow(clippy::cast_precision_loss)] // heartbeat intervals are small; f64 stats are intentional
156    pub fn phi(&self, now: u64) -> f64 {
157        let Some(last) = self.last_heartbeat else {
158            return 0.0;
159        };
160        let time_since = now.saturating_sub(last) as f64;
161        if self.history.is_empty() {
162            // No samples yet — use a conservative pause before suspecting.
163            return if time_since > 100.0 {
164                self.threshold + 1.0
165            } else {
166                0.0
167            };
168        }
169        let n = self.history.len() as f64;
170        let mean = self.history.iter().map(|&x| x as f64).sum::<f64>() / n;
171        let variance = self
172            .history
173            .iter()
174            .map(|&x| {
175                let d = x as f64 - mean;
176                d * d
177            })
178            .sum::<f64>()
179            / n;
180        let std_dev = variance.sqrt().max(1.0);
181        let y = (time_since - mean) / std_dev;
182        let p = 1.0 - normal_cdf(y);
183        if p <= f64::MIN_POSITIVE {
184            return f64::MAX;
185        }
186        (-p.log10()).max(0.0)
187    }
188
189    /// Whether the peer is considered alive at `now`.
190    #[must_use]
191    pub fn is_available(&self, now: u64) -> bool {
192        self.phi(now) < self.threshold
193    }
194}
195
196/// Per-peer phi detectors for all voters.
197#[derive(Debug, Clone, Default)]
198pub struct PhiAccrualLiveness {
199    detectors: BTreeMap<NodeId, PhiAccrualDetector>,
200    threshold: f64,
201}
202
203impl PhiAccrualLiveness {
204    /// Create a bank of detectors sharing `threshold`.
205    #[must_use]
206    pub fn new(threshold: f64) -> Self {
207        Self {
208            detectors: BTreeMap::new(),
209            threshold,
210        }
211    }
212
213    /// Record an ack for `peer`.
214    pub fn record_heartbeat(&mut self, peer: NodeId, now: u64) {
215        self.detectors
216            .entry(peer)
217            .or_insert_with(|| PhiAccrualDetector::new(self.threshold))
218            .record_heartbeat(now);
219    }
220
221    /// Whether `peer` is reachable under phi-accrual.
222    #[must_use]
223    pub fn is_reachable(&self, peer: NodeId, now: u64) -> bool {
224        self.detectors
225            .get(&peer)
226            .is_none_or(|d| d.is_available(now))
227    }
228
229    /// Drop all per-peer phi-accrual state.
230    pub fn clear(&mut self) {
231        self.detectors.clear();
232    }
233}
234
235/// Standard-normal CDF approximation (Abramowitz & Stegun 26.2.17).
236fn normal_cdf(x: f64) -> f64 {
237    if x.is_nan() {
238        return 0.5;
239    }
240    let t = 1.0 / (1.0 + 0.231_641_9 * x.abs());
241    let poly = t
242        * (0.319_381_530
243            + t * (-0.356_563_782
244                + t * (1.781_477_937 + t * (-1.821_255_978 + t * 1.330_274_429))));
245    let pdf = (-0.5 * x * x).exp() / (2.0 * std::f64::consts::PI).sqrt();
246    let p = 1.0 - pdf * poly;
247    if x < 0.0 { 1.0 - p } else { p }
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    #[test]
255    fn hysteresis_prevents_immediate_flap_back() {
256        let mut live = AckWindowLiveness::default();
257        let voters = [NodeId(1), NodeId(2), NodeId(3)];
258        let mut acks = BTreeMap::new();
259        acks.insert(NodeId(2), 0);
260        acks.insert(NodeId(3), 0);
261
262        // Peer 2 silent past the high watermark → unreachable.
263        live.update(50, NodeId(1), &voters, &acks, 40, 10);
264        assert!(!live.is_reachable(NodeId(2)));
265
266        // Fresh ack within low band (30 ticks) → reachable again.
267        acks.insert(NodeId(2), 25);
268        live.update(30, NodeId(1), &voters, &acks, 40, 10);
269        assert!(live.is_reachable(NodeId(2)));
270    }
271
272    #[test]
273    fn phi_rises_when_heartbeats_stop() {
274        let mut det = PhiAccrualDetector::new(8.0);
275        for t in (1..=20).map(|i| i * 5) {
276            det.record_heartbeat(t);
277        }
278        assert!(det.is_available(100));
279        assert!(!det.is_available(500));
280        assert!(det.phi(500) > det.phi(100));
281    }
282}