Skip to main content

ipfrs_network/
health_checker.rs

1//! Peer Health Checker
2//!
3//! Tracks peer health through periodic heartbeat monitoring, computes health scores,
4//! and manages peer promotion/demotion between health tiers.
5//!
6//! # Examples
7//!
8//! ```rust
9//! use ipfrs_network::health_checker::{PeerHealthChecker, PeerHealthCheckerConfig, HealthTier};
10//!
11//! let config = PeerHealthCheckerConfig::default();
12//! let mut checker = PeerHealthChecker::new(config);
13//!
14//! checker.record_heartbeat("peer-1", 100);
15//! assert_eq!(checker.tier("peer-1"), HealthTier::Healthy);
16//!
17//! checker.record_miss("peer-1");
18//! checker.record_miss("peer-1");
19//! assert_eq!(checker.tier("peer-1"), HealthTier::Degraded);
20//! ```
21
22use std::collections::HashMap;
23
24// ---------------------------------------------------------------------------
25// HealthTier
26// ---------------------------------------------------------------------------
27
28/// Classification of a peer's health based on consecutive missed heartbeats.
29#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
30pub enum HealthTier {
31    /// Peer is responding normally.
32    Healthy = 0,
33    /// Peer has missed a small number of heartbeats.
34    Degraded = 1,
35    /// Peer has missed many heartbeats but is not yet considered dead.
36    Unhealthy = 2,
37    /// Peer has not sent a heartbeat for too long; assumed offline.
38    Dead = 3,
39}
40
41// ---------------------------------------------------------------------------
42// HeartbeatRecord
43// ---------------------------------------------------------------------------
44
45/// Accumulated heartbeat statistics for a single peer.
46#[derive(Clone, Debug)]
47pub struct HeartbeatRecord {
48    /// Peer identifier.
49    pub peer_id: String,
50    /// Logical tick at which the most recent heartbeat was received.
51    pub last_seen_tick: u64,
52    /// Number of consecutive heartbeat periods with no response.
53    pub consecutive_misses: u32,
54    /// Total heartbeats successfully recorded.
55    pub total_heartbeats: u64,
56    /// Total heartbeat periods with no response.
57    pub total_misses: u64,
58}
59
60impl HeartbeatRecord {
61    /// Creates a new record for `peer_id` with zero statistics.
62    fn new(peer_id: &str) -> Self {
63        Self {
64            peer_id: peer_id.to_owned(),
65            last_seen_tick: 0,
66            consecutive_misses: 0,
67            total_heartbeats: 0,
68            total_misses: 0,
69        }
70    }
71
72    /// Fraction of periods in which no heartbeat was observed.
73    ///
74    /// Returns `0.0` when no data has been collected yet.
75    pub fn miss_rate(&self) -> f64 {
76        let total = self.total_heartbeats + self.total_misses;
77        if total == 0 {
78            0.0
79        } else {
80            self.total_misses as f64 / total as f64
81        }
82    }
83
84    /// Composite health score in `[0.0, 1.0]`; higher means healthier.
85    ///
86    /// Defined as `1.0 - miss_rate()`.
87    pub fn health_score(&self) -> f64 {
88        1.0 - self.miss_rate()
89    }
90}
91
92// ---------------------------------------------------------------------------
93// PeerHealthCheckerConfig
94// ---------------------------------------------------------------------------
95
96/// Configuration for the [`PeerHealthChecker`].
97#[derive(Clone, Debug)]
98pub struct PeerHealthCheckerConfig {
99    /// Expected number of ticks between successive heartbeats from a peer.
100    pub heartbeat_interval_ticks: u64,
101    /// Consecutive misses threshold for transitioning to [`HealthTier::Degraded`].
102    pub degraded_miss_threshold: u32,
103    /// Consecutive misses threshold for transitioning to [`HealthTier::Unhealthy`].
104    pub unhealthy_miss_threshold: u32,
105    /// Consecutive misses threshold for transitioning to [`HealthTier::Dead`].
106    pub dead_miss_threshold: u32,
107}
108
109impl Default for PeerHealthCheckerConfig {
110    fn default() -> Self {
111        Self {
112            heartbeat_interval_ticks: 30,
113            degraded_miss_threshold: 2,
114            unhealthy_miss_threshold: 5,
115            dead_miss_threshold: 10,
116        }
117    }
118}
119
120// ---------------------------------------------------------------------------
121// HealthCheckerStats
122// ---------------------------------------------------------------------------
123
124/// Aggregate statistics reported by [`PeerHealthChecker::stats`].
125#[derive(Clone, Debug)]
126pub struct HealthCheckerStats {
127    /// Total number of tracked peers.
128    pub total_peers: usize,
129    /// Number of peers in each health tier.
130    pub by_tier: HashMap<HealthTier, usize>,
131    /// Cumulative heartbeats received across all peers.
132    pub total_heartbeats_received: u64,
133    /// Cumulative missed heartbeat periods across all peers.
134    pub total_misses_recorded: u64,
135}
136
137// ---------------------------------------------------------------------------
138// PeerHealthChecker
139// ---------------------------------------------------------------------------
140
141/// Tracks peer health through periodic heartbeat monitoring.
142///
143/// Maintains a [`HeartbeatRecord`] for every known peer and classifies each
144/// peer into a [`HealthTier`] based on the number of consecutive missed
145/// heartbeats.  The [`tick_check`](PeerHealthChecker::tick_check) method
146/// should be called once per tick to automatically promote peers that have
147/// gone silent.
148pub struct PeerHealthChecker {
149    /// Heartbeat records keyed by peer identifier.
150    pub records: HashMap<String, HeartbeatRecord>,
151    /// Checker configuration.
152    pub config: PeerHealthCheckerConfig,
153}
154
155impl PeerHealthChecker {
156    /// Creates a new checker with the supplied `config`.
157    pub fn new(config: PeerHealthCheckerConfig) -> Self {
158        Self {
159            records: HashMap::new(),
160            config,
161        }
162    }
163
164    /// Records a successful heartbeat from `peer_id` at `current_tick`.
165    ///
166    /// If no record exists for the peer, one is created automatically.  The
167    /// record's `last_seen_tick` is updated to `current_tick`,
168    /// `consecutive_misses` is reset to `0`, and `total_heartbeats` is
169    /// incremented.
170    pub fn record_heartbeat(&mut self, peer_id: &str, current_tick: u64) {
171        let record = self
172            .records
173            .entry(peer_id.to_owned())
174            .or_insert_with(|| HeartbeatRecord::new(peer_id));
175        record.last_seen_tick = current_tick;
176        record.consecutive_misses = 0;
177        record.total_heartbeats += 1;
178    }
179
180    /// Records a missed heartbeat period for `peer_id`.
181    ///
182    /// If no record exists for the peer, one is created automatically.
183    /// `consecutive_misses` and `total_misses` are both incremented.
184    pub fn record_miss(&mut self, peer_id: &str) {
185        let record = self
186            .records
187            .entry(peer_id.to_owned())
188            .or_insert_with(|| HeartbeatRecord::new(peer_id));
189        record.consecutive_misses += 1;
190        record.total_misses += 1;
191    }
192
193    /// Returns the current [`HealthTier`] for `peer_id`.
194    ///
195    /// A peer that has never been seen (i.e. has no record) is considered
196    /// [`HealthTier::Dead`].
197    pub fn tier(&self, peer_id: &str) -> HealthTier {
198        match self.records.get(peer_id) {
199            None => HealthTier::Dead,
200            Some(rec) => {
201                if rec.consecutive_misses >= self.config.dead_miss_threshold {
202                    HealthTier::Dead
203                } else if rec.consecutive_misses >= self.config.unhealthy_miss_threshold {
204                    HealthTier::Unhealthy
205                } else if rec.consecutive_misses >= self.config.degraded_miss_threshold {
206                    HealthTier::Degraded
207                } else {
208                    HealthTier::Healthy
209                }
210            }
211        }
212    }
213
214    /// Advances the internal clock to `current_tick` and records a miss for
215    /// every peer whose `last_seen_tick` is more than
216    /// `heartbeat_interval_ticks` ticks in the past.
217    ///
218    /// Returns the peer IDs that had a miss recorded, sorted alphabetically.
219    pub fn tick_check(&mut self, current_tick: u64) -> Vec<String> {
220        let interval = self.config.heartbeat_interval_ticks;
221
222        // Collect the IDs of peers that are stale.
223        let stale: Vec<String> = self
224            .records
225            .values()
226            .filter(|rec| current_tick.saturating_sub(rec.last_seen_tick) > interval)
227            .map(|rec| rec.peer_id.clone())
228            .collect();
229
230        for peer_id in &stale {
231            // SAFETY: key was collected from self.records so it exists.
232            if let Some(rec) = self.records.get_mut(peer_id) {
233                rec.consecutive_misses += 1;
234                rec.total_misses += 1;
235            }
236        }
237
238        let mut sorted = stale;
239        sorted.sort_unstable();
240        sorted
241    }
242
243    /// Removes the record for `peer_id`.
244    ///
245    /// Returns `true` if a record existed and was removed, `false` otherwise.
246    pub fn remove_peer(&mut self, peer_id: &str) -> bool {
247        self.records.remove(peer_id).is_some()
248    }
249
250    /// Returns the health score for `peer_id`, or `0.0` if the peer is
251    /// unknown.
252    pub fn health_score(&self, peer_id: &str) -> f64 {
253        self.records
254            .get(peer_id)
255            .map(|rec| rec.health_score())
256            .unwrap_or(0.0)
257    }
258
259    /// Returns the IDs of all peers currently in the [`HealthTier::Healthy`]
260    /// tier, sorted alphabetically.
261    pub fn healthy_peers(&self) -> Vec<&str> {
262        let mut peers: Vec<&str> = self
263            .records
264            .keys()
265            .filter(|id| self.tier(id) == HealthTier::Healthy)
266            .map(|s| s.as_str())
267            .collect();
268        peers.sort_unstable();
269        peers
270    }
271
272    /// Returns aggregate statistics for the checker.
273    pub fn stats(&self) -> HealthCheckerStats {
274        let mut by_tier: HashMap<HealthTier, usize> = HashMap::new();
275        let mut total_heartbeats_received: u64 = 0;
276        let mut total_misses_recorded: u64 = 0;
277
278        for rec in self.records.values() {
279            *by_tier.entry(self.tier(&rec.peer_id)).or_insert(0) += 1;
280            total_heartbeats_received += rec.total_heartbeats;
281            total_misses_recorded += rec.total_misses;
282        }
283
284        HealthCheckerStats {
285            total_peers: self.records.len(),
286            by_tier,
287            total_heartbeats_received,
288            total_misses_recorded,
289        }
290    }
291}
292
293// ---------------------------------------------------------------------------
294// Tests
295// ---------------------------------------------------------------------------
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300
301    fn default_checker() -> PeerHealthChecker {
302        PeerHealthChecker::new(PeerHealthCheckerConfig::default())
303    }
304
305    // --- HeartbeatRecord ---
306
307    #[test]
308    fn miss_rate_zero_when_no_data() {
309        let rec = HeartbeatRecord::new("p");
310        assert_eq!(rec.miss_rate(), 0.0);
311    }
312
313    #[test]
314    fn miss_rate_calculation() {
315        let mut rec = HeartbeatRecord::new("p");
316        rec.total_heartbeats = 3;
317        rec.total_misses = 1;
318        // 1 / (3 + 1) = 0.25
319        let diff = (rec.miss_rate() - 0.25_f64).abs();
320        assert!(
321            diff < 1e-10,
322            "miss_rate should be 0.25, got {}",
323            rec.miss_rate()
324        );
325    }
326
327    #[test]
328    fn health_score_equals_one_minus_miss_rate() {
329        let mut rec = HeartbeatRecord::new("p");
330        rec.total_heartbeats = 3;
331        rec.total_misses = 1;
332        let expected = 1.0 - rec.miss_rate();
333        let diff = (rec.health_score() - expected).abs();
334        assert!(diff < 1e-10);
335    }
336
337    #[test]
338    fn health_score_is_one_when_no_misses() {
339        let mut rec = HeartbeatRecord::new("p");
340        rec.total_heartbeats = 10;
341        let diff = (rec.health_score() - 1.0_f64).abs();
342        assert!(diff < 1e-10);
343    }
344
345    #[test]
346    fn health_score_is_one_when_no_data() {
347        let rec = HeartbeatRecord::new("p");
348        let diff = (rec.health_score() - 1.0_f64).abs();
349        assert!(
350            diff < 1e-10,
351            "empty record should have score 1.0, got {}",
352            rec.health_score()
353        );
354    }
355
356    // --- record_heartbeat ---
357
358    #[test]
359    fn record_heartbeat_creates_record() {
360        let mut checker = default_checker();
361        checker.record_heartbeat("peer-1", 50);
362        assert!(checker.records.contains_key("peer-1"));
363    }
364
365    #[test]
366    fn record_heartbeat_updates_last_seen_tick() {
367        let mut checker = default_checker();
368        checker.record_heartbeat("peer-1", 42);
369        assert_eq!(checker.records["peer-1"].last_seen_tick, 42);
370    }
371
372    #[test]
373    fn record_heartbeat_resets_consecutive_misses() {
374        let mut checker = default_checker();
375        checker.record_miss("peer-1");
376        checker.record_miss("peer-1");
377        checker.record_heartbeat("peer-1", 100);
378        assert_eq!(checker.records["peer-1"].consecutive_misses, 0);
379    }
380
381    #[test]
382    fn record_heartbeat_increments_total_heartbeats() {
383        let mut checker = default_checker();
384        checker.record_heartbeat("peer-1", 10);
385        checker.record_heartbeat("peer-1", 20);
386        assert_eq!(checker.records["peer-1"].total_heartbeats, 2);
387    }
388
389    // --- record_miss ---
390
391    #[test]
392    fn record_miss_creates_record() {
393        let mut checker = default_checker();
394        checker.record_miss("peer-2");
395        assert!(checker.records.contains_key("peer-2"));
396    }
397
398    #[test]
399    fn record_miss_increments_consecutive_misses() {
400        let mut checker = default_checker();
401        checker.record_miss("peer-2");
402        checker.record_miss("peer-2");
403        assert_eq!(checker.records["peer-2"].consecutive_misses, 2);
404    }
405
406    #[test]
407    fn record_miss_increments_total_misses() {
408        let mut checker = default_checker();
409        checker.record_miss("peer-2");
410        checker.record_miss("peer-2");
411        assert_eq!(checker.records["peer-2"].total_misses, 2);
412    }
413
414    // --- tier ---
415
416    #[test]
417    fn tier_dead_for_missing_peer() {
418        let checker = default_checker();
419        assert_eq!(checker.tier("unknown"), HealthTier::Dead);
420    }
421
422    #[test]
423    fn tier_healthy_on_zero_misses() {
424        let mut checker = default_checker();
425        checker.record_heartbeat("peer-1", 10);
426        assert_eq!(checker.tier("peer-1"), HealthTier::Healthy);
427    }
428
429    #[test]
430    fn tier_degraded_at_threshold() {
431        let mut checker = default_checker();
432        checker.record_heartbeat("peer-1", 10);
433        // default degraded_miss_threshold = 2
434        checker.record_miss("peer-1");
435        checker.record_miss("peer-1");
436        assert_eq!(checker.tier("peer-1"), HealthTier::Degraded);
437    }
438
439    #[test]
440    fn tier_unhealthy_at_threshold() {
441        let mut checker = default_checker();
442        checker.record_heartbeat("peer-1", 10);
443        // default unhealthy_miss_threshold = 5
444        for _ in 0..5 {
445            checker.record_miss("peer-1");
446        }
447        assert_eq!(checker.tier("peer-1"), HealthTier::Unhealthy);
448    }
449
450    #[test]
451    fn tier_dead_at_threshold() {
452        let mut checker = default_checker();
453        checker.record_heartbeat("peer-1", 10);
454        // default dead_miss_threshold = 10
455        for _ in 0..10 {
456            checker.record_miss("peer-1");
457        }
458        assert_eq!(checker.tier("peer-1"), HealthTier::Dead);
459    }
460
461    #[test]
462    fn tier_healthy_after_heartbeat_resets_misses() {
463        let mut checker = default_checker();
464        for _ in 0..8 {
465            checker.record_miss("peer-1");
466        }
467        checker.record_heartbeat("peer-1", 500);
468        assert_eq!(checker.tier("peer-1"), HealthTier::Healthy);
469    }
470
471    // --- tick_check ---
472
473    #[test]
474    fn tick_check_detects_stale_peers() {
475        let mut checker = default_checker();
476        checker.record_heartbeat("peer-a", 0);
477        // tick 0 + interval(30) + 1 = 31 → stale
478        let missed = checker.tick_check(31);
479        assert!(missed.contains(&"peer-a".to_owned()));
480    }
481
482    #[test]
483    fn tick_check_does_not_flag_recent_peers() {
484        let mut checker = default_checker();
485        checker.record_heartbeat("peer-b", 20);
486        // Only 10 ticks elapsed, interval is 30 → not stale
487        let missed = checker.tick_check(30);
488        assert!(!missed.contains(&"peer-b".to_owned()));
489    }
490
491    #[test]
492    fn tick_check_returns_sorted_alphabetically() {
493        let mut checker = default_checker();
494        checker.record_heartbeat("peer-z", 0);
495        checker.record_heartbeat("peer-a", 0);
496        checker.record_heartbeat("peer-m", 0);
497        let missed = checker.tick_check(100);
498        let mut expected = missed.clone();
499        expected.sort();
500        assert_eq!(missed, expected);
501    }
502
503    #[test]
504    fn tick_check_increments_consecutive_misses_for_stale_peer() {
505        let mut checker = default_checker();
506        checker.record_heartbeat("peer-c", 0);
507        checker.tick_check(100);
508        assert!(checker.records["peer-c"].consecutive_misses > 0);
509    }
510
511    #[test]
512    fn tick_check_returns_empty_when_no_peers_are_stale() {
513        let mut checker = default_checker();
514        checker.record_heartbeat("peer-d", 100);
515        let missed = checker.tick_check(110);
516        assert!(missed.is_empty());
517    }
518
519    // --- remove_peer ---
520
521    #[test]
522    fn remove_peer_returns_true_when_present() {
523        let mut checker = default_checker();
524        checker.record_heartbeat("peer-1", 10);
525        assert!(checker.remove_peer("peer-1"));
526    }
527
528    #[test]
529    fn remove_peer_returns_false_when_absent() {
530        let mut checker = default_checker();
531        assert!(!checker.remove_peer("ghost"));
532    }
533
534    #[test]
535    fn remove_peer_actually_removes_record() {
536        let mut checker = default_checker();
537        checker.record_heartbeat("peer-1", 10);
538        checker.remove_peer("peer-1");
539        assert!(!checker.records.contains_key("peer-1"));
540    }
541
542    // --- health_score ---
543
544    #[test]
545    fn health_score_returns_zero_for_unknown_peer() {
546        let checker = default_checker();
547        let diff = checker.health_score("unknown").abs();
548        assert!(diff < 1e-10);
549    }
550
551    #[test]
552    fn health_score_returns_record_score() {
553        let mut checker = default_checker();
554        checker.record_heartbeat("peer-1", 10);
555        checker.record_heartbeat("peer-1", 20);
556        checker.record_miss("peer-1");
557        // 1 miss out of 3 total → miss_rate = 1/3 → score ≈ 0.667
558        let score = checker.health_score("peer-1");
559        let expected = checker.records["peer-1"].health_score();
560        let diff = (score - expected).abs();
561        assert!(diff < 1e-10);
562    }
563
564    // --- healthy_peers ---
565
566    #[test]
567    fn healthy_peers_sorted_alphabetically() {
568        let mut checker = default_checker();
569        checker.record_heartbeat("peer-z", 10);
570        checker.record_heartbeat("peer-a", 10);
571        checker.record_heartbeat("peer-m", 10);
572        let peers = checker.healthy_peers();
573        let mut expected = peers.clone();
574        expected.sort_unstable();
575        assert_eq!(peers, expected);
576    }
577
578    #[test]
579    fn healthy_peers_excludes_degraded_peers() {
580        let mut checker = default_checker();
581        checker.record_heartbeat("peer-good", 10);
582        checker.record_heartbeat("peer-bad", 10);
583        checker.record_miss("peer-bad");
584        checker.record_miss("peer-bad");
585        let peers = checker.healthy_peers();
586        assert!(peers.contains(&"peer-good"));
587        assert!(!peers.contains(&"peer-bad"));
588    }
589
590    // --- stats ---
591
592    #[test]
593    fn stats_total_peers_count() {
594        let mut checker = default_checker();
595        checker.record_heartbeat("p1", 1);
596        checker.record_heartbeat("p2", 1);
597        let s = checker.stats();
598        assert_eq!(s.total_peers, 2);
599    }
600
601    #[test]
602    fn stats_by_tier_counts_healthy_and_degraded() {
603        let mut checker = default_checker();
604        checker.record_heartbeat("healthy", 10);
605        checker.record_heartbeat("degraded", 10);
606        checker.record_miss("degraded");
607        checker.record_miss("degraded");
608        let s = checker.stats();
609        assert_eq!(s.by_tier.get(&HealthTier::Healthy).copied().unwrap_or(0), 1);
610        assert_eq!(
611            s.by_tier.get(&HealthTier::Degraded).copied().unwrap_or(0),
612            1
613        );
614    }
615
616    #[test]
617    fn stats_total_heartbeats_received() {
618        let mut checker = default_checker();
619        checker.record_heartbeat("p1", 1);
620        checker.record_heartbeat("p1", 2);
621        checker.record_heartbeat("p2", 1);
622        let s = checker.stats();
623        assert_eq!(s.total_heartbeats_received, 3);
624    }
625
626    #[test]
627    fn stats_total_misses_recorded() {
628        let mut checker = default_checker();
629        checker.record_heartbeat("p1", 1);
630        checker.record_miss("p1");
631        checker.record_miss("p1");
632        checker.record_miss("p2");
633        let s = checker.stats();
634        assert_eq!(s.total_misses_recorded, 3);
635    }
636
637    #[test]
638    fn stats_by_tier_dead_count() {
639        let mut checker = default_checker();
640        checker.record_heartbeat("p1", 10);
641        for _ in 0..10 {
642            checker.record_miss("p1");
643        }
644        let s = checker.stats();
645        assert_eq!(s.by_tier.get(&HealthTier::Dead).copied().unwrap_or(0), 1);
646    }
647}