use std::collections::HashMap;
use std::time::{Duration, Instant};
#[allow(dead_code)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NodeLiveness {
Alive,
Suspected,
Dead,
}
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub struct HeartbeatConfig {
pub heartbeat_interval: Duration,
pub suspect_timeout: Duration,
pub dead_timeout: Duration,
}
impl Default for HeartbeatConfig {
fn default() -> Self {
Self {
heartbeat_interval: Duration::from_secs(5),
suspect_timeout: Duration::from_secs(15),
dead_timeout: Duration::from_secs(30),
}
}
}
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub struct NodeHeartbeatRecord {
pub node_id: String,
pub registered_at: Instant,
pub last_heartbeat: Instant,
pub missed_count: u32,
pub liveness: NodeLiveness,
}
impl NodeHeartbeatRecord {
#[allow(dead_code)]
pub fn new(node_id: impl Into<String>) -> Self {
let now = Instant::now();
Self {
node_id: node_id.into(),
registered_at: now,
last_heartbeat: now,
missed_count: 0,
liveness: NodeLiveness::Alive,
}
}
#[allow(dead_code)]
pub fn record_heartbeat(&mut self) {
self.last_heartbeat = Instant::now();
self.missed_count = 0;
self.liveness = NodeLiveness::Alive;
}
#[allow(dead_code)]
#[must_use]
pub fn elapsed_since_heartbeat(&self) -> Duration {
self.last_heartbeat.elapsed()
}
}
#[allow(dead_code)]
pub struct HeartbeatTracker {
config: HeartbeatConfig,
records: HashMap<String, NodeHeartbeatRecord>,
}
impl HeartbeatTracker {
#[allow(dead_code)]
#[must_use]
pub fn new(config: HeartbeatConfig) -> Self {
Self {
config,
records: HashMap::new(),
}
}
#[allow(dead_code)]
pub fn register(&mut self, node_id: impl Into<String>) {
let id = node_id.into();
self.records
.entry(id.clone())
.or_insert_with(|| NodeHeartbeatRecord::new(id));
}
#[allow(dead_code)]
pub fn deregister(&mut self, node_id: &str) {
self.records.remove(node_id);
}
#[allow(dead_code)]
pub fn heartbeat(&mut self, node_id: &str) -> bool {
if let Some(record) = self.records.get_mut(node_id) {
record.record_heartbeat();
true
} else {
false
}
}
#[allow(dead_code)]
pub fn check(&mut self) -> Vec<(String, NodeLiveness)> {
let suspect = self.config.suspect_timeout;
let dead = self.config.dead_timeout;
let mut changes = Vec::new();
for record in self.records.values_mut() {
let elapsed = record.elapsed_since_heartbeat();
let new_liveness = if elapsed >= dead {
NodeLiveness::Dead
} else if elapsed >= suspect {
NodeLiveness::Suspected
} else {
NodeLiveness::Alive
};
if new_liveness != record.liveness {
if new_liveness != NodeLiveness::Alive {
record.missed_count += 1;
}
record.liveness = new_liveness;
changes.push((record.node_id.clone(), new_liveness));
}
}
changes
}
#[allow(dead_code)]
#[must_use]
pub fn liveness(&self, node_id: &str) -> Option<NodeLiveness> {
self.records.get(node_id).map(|r| r.liveness)
}
#[allow(dead_code)]
#[must_use]
pub fn alive_nodes(&self) -> Vec<&str> {
self.records
.values()
.filter(|r| r.liveness == NodeLiveness::Alive)
.map(|r| r.node_id.as_str())
.collect()
}
#[allow(dead_code)]
#[must_use]
pub fn dead_nodes(&self) -> Vec<&str> {
self.records
.values()
.filter(|r| r.liveness == NodeLiveness::Dead)
.map(|r| r.node_id.as_str())
.collect()
}
#[allow(dead_code)]
#[must_use]
pub fn node_count(&self) -> usize {
self.records.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn default_tracker() -> HeartbeatTracker {
HeartbeatTracker::new(HeartbeatConfig::default())
}
#[test]
fn test_register_adds_node() {
let mut tracker = default_tracker();
tracker.register("node-0");
assert_eq!(tracker.node_count(), 1);
}
#[test]
fn test_deregister_removes_node() {
let mut tracker = default_tracker();
tracker.register("node-0");
tracker.deregister("node-0");
assert_eq!(tracker.node_count(), 0);
}
#[test]
fn test_heartbeat_unknown_node_returns_false() {
let mut tracker = default_tracker();
assert!(!tracker.heartbeat("ghost"));
}
#[test]
fn test_heartbeat_known_node_returns_true() {
let mut tracker = default_tracker();
tracker.register("node-1");
assert!(tracker.heartbeat("node-1"));
}
#[test]
fn test_initial_liveness_is_alive() {
let mut tracker = default_tracker();
tracker.register("node-2");
assert_eq!(tracker.liveness("node-2"), Some(NodeLiveness::Alive));
}
#[test]
fn test_liveness_unknown_node_is_none() {
let tracker = default_tracker();
assert!(tracker.liveness("missing").is_none());
}
#[test]
fn test_alive_nodes_list() {
let mut tracker = default_tracker();
tracker.register("n0");
tracker.register("n1");
let alive = tracker.alive_nodes();
assert_eq!(alive.len(), 2);
}
#[test]
fn test_dead_nodes_initially_empty() {
let mut tracker = default_tracker();
tracker.register("n0");
assert!(tracker.dead_nodes().is_empty());
}
#[test]
fn test_node_heartbeat_record_new() {
let record = NodeHeartbeatRecord::new("node-x");
assert_eq!(record.liveness, NodeLiveness::Alive);
assert_eq!(record.missed_count, 0);
}
#[test]
fn test_record_heartbeat_resets_missed_count() {
let mut record = NodeHeartbeatRecord::new("node-y");
record.missed_count = 5;
record.liveness = NodeLiveness::Suspected;
record.record_heartbeat();
assert_eq!(record.missed_count, 0);
assert_eq!(record.liveness, NodeLiveness::Alive);
}
#[test]
fn test_elapsed_since_heartbeat_is_small() {
let record = NodeHeartbeatRecord::new("node-z");
assert!(record.elapsed_since_heartbeat() < Duration::from_millis(100));
}
#[test]
fn test_check_no_changes_for_fresh_nodes() {
let mut tracker = default_tracker();
tracker.register("n0");
tracker.register("n1");
let changes = tracker.check();
assert!(changes.is_empty());
}
#[test]
fn test_heartbeat_config_default_values() {
let cfg = HeartbeatConfig::default();
assert_eq!(cfg.heartbeat_interval, Duration::from_secs(5));
assert_eq!(cfg.suspect_timeout, Duration::from_secs(15));
assert_eq!(cfg.dead_timeout, Duration::from_secs(30));
}
#[test]
fn test_suspect_before_dead_threshold() {
let cfg = HeartbeatConfig {
suspect_timeout: Duration::from_secs(5),
dead_timeout: Duration::from_secs(30),
..HeartbeatConfig::default()
};
assert!(cfg.suspect_timeout < cfg.dead_timeout);
}
}