crafty_core/
failure_detector.rs1use std::collections::{BTreeMap, VecDeque};
10
11use crafty_proto::NodeId;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15pub enum FailureDetectorKind {
16 #[default]
19 AckWindow,
20 PhiAccrual,
22}
23
24#[derive(Debug, Clone, Copy, PartialEq)]
26pub struct ReachabilityConfig {
27 pub window_ticks: Option<u64>,
30 pub hysteresis_ticks: u64,
33 pub detector: FailureDetectorKind,
35 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 #[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 #[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#[derive(Debug, Clone, Default)]
71pub struct AckWindowLiveness {
72 latched: BTreeMap<NodeId, bool>,
73}
74
75impl AckWindowLiveness {
76 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 #[must_use]
108 pub fn is_reachable(&self, peer: NodeId) -> bool {
109 self.latched.get(&peer).copied().unwrap_or(true)
110 }
111
112 pub fn clear(&mut self) {
114 self.latched.clear();
115 }
116}
117
118#[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 #[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 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 #[must_use]
155 #[allow(clippy::cast_precision_loss)] 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 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 #[must_use]
191 pub fn is_available(&self, now: u64) -> bool {
192 self.phi(now) < self.threshold
193 }
194}
195
196#[derive(Debug, Clone, Default)]
198pub struct PhiAccrualLiveness {
199 detectors: BTreeMap<NodeId, PhiAccrualDetector>,
200 threshold: f64,
201}
202
203impl PhiAccrualLiveness {
204 #[must_use]
206 pub fn new(threshold: f64) -> Self {
207 Self {
208 detectors: BTreeMap::new(),
209 threshold,
210 }
211 }
212
213 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 #[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 pub fn clear(&mut self) {
231 self.detectors.clear();
232 }
233}
234
235fn 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 live.update(50, NodeId(1), &voters, &acks, 40, 10);
264 assert!(!live.is_reachable(NodeId(2)));
265
266 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}