use std::collections::{BTreeMap, VecDeque};
use crafty_proto::NodeId;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum FailureDetectorKind {
#[default]
AckWindow,
PhiAccrual,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ReachabilityConfig {
pub window_ticks: Option<u64>,
pub hysteresis_ticks: u64,
pub detector: FailureDetectorKind,
pub phi_threshold: f64,
}
impl Default for ReachabilityConfig {
fn default() -> Self {
Self {
window_ticks: None,
hysteresis_ticks: 0,
detector: FailureDetectorKind::AckWindow,
phi_threshold: 8.0,
}
}
}
impl ReachabilityConfig {
#[must_use]
pub fn window(&self, election_timeout_max: u64) -> u64 {
self.window_ticks
.unwrap_or_else(|| election_timeout_max.saturating_mul(2))
}
#[must_use]
pub fn hysteresis(&self, election_timeout_min: u64) -> u64 {
if self.hysteresis_ticks > 0 {
self.hysteresis_ticks
} else {
election_timeout_min
}
}
}
#[derive(Debug, Clone, Default)]
pub struct AckWindowLiveness {
latched: BTreeMap<NodeId, bool>,
}
impl AckWindowLiveness {
pub fn update(
&mut self,
now: u64,
self_id: NodeId,
voters: &[NodeId],
last_ack: &BTreeMap<NodeId, u64>,
window: u64,
hysteresis: u64,
) {
let high = window;
let low = window.saturating_sub(hysteresis);
for &peer in voters {
if peer == self_id {
continue;
}
let silence = last_ack
.get(&peer)
.map_or(u64::MAX, |&t| now.saturating_sub(t));
let entry = self.latched.entry(peer).or_insert(true);
if *entry {
if silence > high {
*entry = false;
}
} else if silence <= low {
*entry = true;
}
}
}
#[must_use]
pub fn is_reachable(&self, peer: NodeId) -> bool {
self.latched.get(&peer).copied().unwrap_or(true)
}
pub fn clear(&mut self) {
self.latched.clear();
}
}
#[derive(Debug, Clone)]
pub struct PhiAccrualDetector {
history: VecDeque<u64>,
last_heartbeat: Option<u64>,
threshold: f64,
max_samples: usize,
}
impl PhiAccrualDetector {
#[must_use]
pub fn new(threshold: f64) -> Self {
Self {
history: VecDeque::new(),
last_heartbeat: None,
threshold,
max_samples: 1000,
}
}
pub fn record_heartbeat(&mut self, now: u64) {
if let Some(last) = self.last_heartbeat {
let interval = now.saturating_sub(last);
if interval > 0 {
if self.history.len() >= self.max_samples {
self.history.pop_front();
}
self.history.push_back(interval);
}
}
self.last_heartbeat = Some(now);
}
#[must_use]
#[allow(clippy::cast_precision_loss)] pub fn phi(&self, now: u64) -> f64 {
let Some(last) = self.last_heartbeat else {
return 0.0;
};
let time_since = now.saturating_sub(last) as f64;
if self.history.is_empty() {
return if time_since > 100.0 {
self.threshold + 1.0
} else {
0.0
};
}
let n = self.history.len() as f64;
let mean = self.history.iter().map(|&x| x as f64).sum::<f64>() / n;
let variance = self
.history
.iter()
.map(|&x| {
let d = x as f64 - mean;
d * d
})
.sum::<f64>()
/ n;
let std_dev = variance.sqrt().max(1.0);
let y = (time_since - mean) / std_dev;
let p = 1.0 - normal_cdf(y);
if p <= f64::MIN_POSITIVE {
return f64::MAX;
}
(-p.log10()).max(0.0)
}
#[must_use]
pub fn is_available(&self, now: u64) -> bool {
self.phi(now) < self.threshold
}
}
#[derive(Debug, Clone, Default)]
pub struct PhiAccrualLiveness {
detectors: BTreeMap<NodeId, PhiAccrualDetector>,
threshold: f64,
}
impl PhiAccrualLiveness {
#[must_use]
pub fn new(threshold: f64) -> Self {
Self {
detectors: BTreeMap::new(),
threshold,
}
}
pub fn record_heartbeat(&mut self, peer: NodeId, now: u64) {
self.detectors
.entry(peer)
.or_insert_with(|| PhiAccrualDetector::new(self.threshold))
.record_heartbeat(now);
}
#[must_use]
pub fn is_reachable(&self, peer: NodeId, now: u64) -> bool {
self.detectors
.get(&peer)
.is_none_or(|d| d.is_available(now))
}
pub fn clear(&mut self) {
self.detectors.clear();
}
}
fn normal_cdf(x: f64) -> f64 {
if x.is_nan() {
return 0.5;
}
let t = 1.0 / (1.0 + 0.231_641_9 * x.abs());
let poly = t
* (0.319_381_530
+ t * (-0.356_563_782
+ t * (1.781_477_937 + t * (-1.821_255_978 + t * 1.330_274_429))));
let pdf = (-0.5 * x * x).exp() / (2.0 * std::f64::consts::PI).sqrt();
let p = 1.0 - pdf * poly;
if x < 0.0 { 1.0 - p } else { p }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hysteresis_prevents_immediate_flap_back() {
let mut live = AckWindowLiveness::default();
let voters = [NodeId(1), NodeId(2), NodeId(3)];
let mut acks = BTreeMap::new();
acks.insert(NodeId(2), 0);
acks.insert(NodeId(3), 0);
live.update(50, NodeId(1), &voters, &acks, 40, 10);
assert!(!live.is_reachable(NodeId(2)));
acks.insert(NodeId(2), 25);
live.update(30, NodeId(1), &voters, &acks, 40, 10);
assert!(live.is_reachable(NodeId(2)));
}
#[test]
fn phi_rises_when_heartbeats_stop() {
let mut det = PhiAccrualDetector::new(8.0);
for t in (1..=20).map(|i| i * 5) {
det.record_heartbeat(t);
}
assert!(det.is_available(100));
assert!(!det.is_available(500));
assert!(det.phi(500) > det.phi(100));
}
}