Skip to main content

ipfrs_network/
peer_health.rs

1//! Peer Health Monitor
2//!
3//! Tracks health scores for peers based on recent ping successes, message delivery
4//! rates, and protocol compliance, with automatic degradation over time.
5//!
6//! # Examples
7//!
8//! ```rust
9//! use ipfrs_network::peer_health::{PeerHealthMonitor, MonitorConfig, HealthSample};
10//!
11//! let config = MonitorConfig::default();
12//! let mut monitor = PeerHealthMonitor::new(config);
13//!
14//! let sample = HealthSample {
15//!     timestamp_secs: 1_000_000,
16//!     ping_rtt_ms: Some(12.5),
17//!     messages_delivered: 100,
18//!     messages_failed: 0,
19//! };
20//! monitor.record_sample("peer-1", sample);
21//!
22//! if let Some(score) = monitor.score("peer-1") {
23//!     println!("Health score: {:.2}", score.score);
24//! }
25//! ```
26
27use std::collections::HashMap;
28
29// ---------------------------------------------------------------------------
30// HealthStatus
31// ---------------------------------------------------------------------------
32
33/// The health classification of a peer.
34#[derive(Clone, Debug, PartialEq)]
35pub enum HealthStatus {
36    /// Peer is operating normally (score >= healthy_threshold).
37    Healthy,
38    /// Peer is experiencing issues (degraded_threshold <= score < healthy_threshold).
39    Degraded { reason: String },
40    /// Peer is not functioning correctly (score < degraded_threshold).
41    Unhealthy { reason: String },
42    /// No data has been collected yet.
43    Unknown,
44}
45
46// ---------------------------------------------------------------------------
47// HealthSample
48// ---------------------------------------------------------------------------
49
50/// A single observation recorded for a peer at a point in time.
51#[derive(Clone, Debug)]
52pub struct HealthSample {
53    /// Unix timestamp (seconds) when this sample was recorded.
54    pub timestamp_secs: u64,
55    /// Round-trip time in milliseconds, or `None` if the ping failed.
56    pub ping_rtt_ms: Option<f64>,
57    /// Number of messages that were successfully delivered in this window.
58    pub messages_delivered: u64,
59    /// Number of messages that failed delivery in this window.
60    pub messages_failed: u64,
61}
62
63impl HealthSample {
64    /// Fraction of messages that were successfully delivered.
65    ///
66    /// Returns `delivered / (delivered + failed).max(1)`, so it is always in
67    /// the range `[0.0, 1.0]`.
68    pub fn delivery_rate(&self) -> f64 {
69        let total = (self.messages_delivered + self.messages_failed).max(1);
70        self.messages_delivered as f64 / total as f64
71    }
72
73    /// Returns `true` when the ping completed successfully (RTT is known).
74    pub fn ping_ok(&self) -> bool {
75        self.ping_rtt_ms.is_some()
76    }
77}
78
79// ---------------------------------------------------------------------------
80// HealthScore
81// ---------------------------------------------------------------------------
82
83/// Derived health information for a peer.
84#[derive(Clone, Debug)]
85pub struct HealthScore {
86    /// Composite health score in `[0.0, 1.0]`; 0.0 = dead, 1.0 = perfect.
87    pub score: f64,
88    /// Human-readable classification of the current score.
89    pub status: HealthStatus,
90    /// Total number of samples that have been incorporated.
91    pub sample_count: usize,
92    /// Unix timestamp (seconds) of the most recent update.
93    pub last_updated_secs: u64,
94}
95
96impl HealthScore {
97    /// Returns `true` when the score can be acted upon: the status is known
98    /// *and* at least 3 samples have been recorded.
99    pub fn is_actionable(&self) -> bool {
100        self.status != HealthStatus::Unknown && self.sample_count >= 3
101    }
102}
103
104impl Default for HealthScore {
105    fn default() -> Self {
106        Self {
107            score: 0.0,
108            status: HealthStatus::Unknown,
109            sample_count: 0,
110            last_updated_secs: 0,
111        }
112    }
113}
114
115// ---------------------------------------------------------------------------
116// MonitorConfig
117// ---------------------------------------------------------------------------
118
119/// Configuration for [`PeerHealthMonitor`].
120#[derive(Clone, Debug)]
121pub struct MonitorConfig {
122    /// Score at or above which a peer is considered `Healthy` (default 0.8).
123    pub healthy_threshold: f64,
124    /// Score at or above which a peer is considered `Degraded` (default 0.5).
125    pub degraded_threshold: f64,
126    /// Per-sample decay factor applied when the sample window is not fully
127    /// saturated (default 0.95: `score *= 0.95^missing_samples`).
128    pub decay_rate: f64,
129    /// Fraction of the raw score contributed by ping success (default 0.4).
130    pub ping_weight: f64,
131    /// Fraction of the raw score contributed by delivery rate (default 0.6).
132    pub delivery_weight: f64,
133    /// Maximum number of most-recent samples to retain per peer (default 10).
134    pub window_samples: usize,
135}
136
137impl Default for MonitorConfig {
138    fn default() -> Self {
139        Self {
140            healthy_threshold: 0.8,
141            degraded_threshold: 0.5,
142            decay_rate: 0.95,
143            ping_weight: 0.4,
144            delivery_weight: 0.6,
145            window_samples: 10,
146        }
147    }
148}
149
150// ---------------------------------------------------------------------------
151// PeerHealthMonitor
152// ---------------------------------------------------------------------------
153
154/// Tracks health scores for a set of peers.
155///
156/// For each peer the monitor maintains a sliding window of [`HealthSample`]s
157/// and derives a composite [`HealthScore`] every time a new sample is
158/// recorded.
159pub struct PeerHealthMonitor {
160    /// `peer_id -> (samples, score)`
161    peers: HashMap<String, (Vec<HealthSample>, HealthScore)>,
162    /// Configuration controlling thresholds, weights, and window size.
163    pub config: MonitorConfig,
164}
165
166impl PeerHealthMonitor {
167    /// Create a new monitor with the supplied configuration.
168    pub fn new(config: MonitorConfig) -> Self {
169        Self {
170            peers: HashMap::new(),
171            config,
172        }
173    }
174
175    // -----------------------------------------------------------------------
176    // Mutation
177    // -----------------------------------------------------------------------
178
179    /// Record a new health observation for `peer_id`.
180    ///
181    /// The sample is appended to the peer's sliding window (oldest entry
182    /// discarded when the window is full) and the [`HealthScore`] is
183    /// recomputed immediately.
184    pub fn record_sample(&mut self, peer_id: &str, sample: HealthSample) {
185        let timestamp = sample.timestamp_secs;
186        let entry = self
187            .peers
188            .entry(peer_id.to_owned())
189            .or_insert_with(|| (Vec::new(), HealthScore::default()));
190
191        // Maintain sliding window.
192        entry.0.push(sample);
193        if entry.0.len() > self.config.window_samples {
194            let excess = entry.0.len() - self.config.window_samples;
195            entry.0.drain(..excess);
196        }
197
198        let samples = &entry.0;
199        let n = samples.len();
200
201        // --- ping component ---------------------------------------------------
202        let ping_ok_count = samples.iter().filter(|s| s.ping_ok()).count();
203        let ping_component = (ping_ok_count as f64 / n as f64) * self.config.ping_weight;
204
205        // --- delivery component -----------------------------------------------
206        let avg_delivery: f64 = samples.iter().map(|s| s.delivery_rate()).sum::<f64>() / n as f64;
207        let delivery_component = avg_delivery * self.config.delivery_weight;
208
209        // --- raw score --------------------------------------------------------
210        let raw_score = ping_component + delivery_component;
211
212        // --- decay ------------------------------------------------------------
213        // Apply decay for each "missing" sample slot so that a small window is
214        // penalised relative to a fully-saturated window.
215        let missing = self.config.window_samples.saturating_sub(n);
216        let decay_factor = self.config.decay_rate.powi(missing as i32);
217        let decayed = (raw_score * decay_factor).clamp(0.0, 1.0);
218
219        // --- status -----------------------------------------------------------
220        let status = if decayed >= self.config.healthy_threshold {
221            HealthStatus::Healthy
222        } else if decayed >= self.config.degraded_threshold {
223            HealthStatus::Degraded {
224                reason: format!(
225                    "score {:.3} below healthy threshold {:.3}",
226                    decayed, self.config.healthy_threshold
227                ),
228            }
229        } else {
230            HealthStatus::Unhealthy {
231                reason: format!(
232                    "score {:.3} below degraded threshold {:.3}",
233                    decayed, self.config.degraded_threshold
234                ),
235            }
236        };
237
238        // --- update -----------------------------------------------------------
239        entry.1 = HealthScore {
240            score: decayed,
241            status,
242            sample_count: n,
243            last_updated_secs: timestamp,
244        };
245    }
246
247    /// Remove all data for `peer_id`.  Returns `true` if the peer existed.
248    pub fn remove_peer(&mut self, peer_id: &str) -> bool {
249        self.peers.remove(peer_id).is_some()
250    }
251
252    // -----------------------------------------------------------------------
253    // Query
254    // -----------------------------------------------------------------------
255
256    /// Return the current [`HealthScore`] for `peer_id`, or `None` if unknown.
257    pub fn score(&self, peer_id: &str) -> Option<&HealthScore> {
258        self.peers.get(peer_id).map(|(_, score)| score)
259    }
260
261    /// Return the IDs of all peers currently classified as [`HealthStatus::Healthy`].
262    pub fn healthy_peers(&self) -> Vec<&str> {
263        self.peers
264            .iter()
265            .filter_map(|(id, (_, score))| {
266                if score.status == HealthStatus::Healthy {
267                    Some(id.as_str())
268                } else {
269                    None
270                }
271            })
272            .collect()
273    }
274
275    /// Return the IDs of all peers currently classified as [`HealthStatus::Unhealthy`].
276    pub fn unhealthy_peers(&self) -> Vec<&str> {
277        self.peers
278            .iter()
279            .filter_map(|(id, (_, score))| {
280                if matches!(score.status, HealthStatus::Unhealthy { .. }) {
281                    Some(id.as_str())
282                } else {
283                    None
284                }
285            })
286            .collect()
287    }
288
289    /// Return the IDs of all peers currently classified as [`HealthStatus::Degraded`].
290    pub fn degraded_peers(&self) -> Vec<&str> {
291        self.peers
292            .iter()
293            .filter_map(|(id, (_, score))| {
294                if matches!(score.status, HealthStatus::Degraded { .. }) {
295                    Some(id.as_str())
296                } else {
297                    None
298                }
299            })
300            .collect()
301    }
302
303    /// Return the top-`n` peers by score, highest first.
304    ///
305    /// Each entry is `(peer_id, score)`.
306    pub fn top_peers(&self, n: usize) -> Vec<(&str, f64)> {
307        let mut pairs: Vec<(&str, f64)> = self
308            .peers
309            .iter()
310            .map(|(id, (_, score))| (id.as_str(), score.score))
311            .collect();
312
313        // Sort descending; use total_cmp for NaN-safety.
314        pairs.sort_by(|a, b| b.1.total_cmp(&a.1));
315        pairs.truncate(n);
316        pairs
317    }
318}
319
320// ---------------------------------------------------------------------------
321// Tests
322// ---------------------------------------------------------------------------
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327
328    // ------------------------------------------------------------------
329    // Helpers
330    // ------------------------------------------------------------------
331
332    fn good_sample(ts: u64) -> HealthSample {
333        HealthSample {
334            timestamp_secs: ts,
335            ping_rtt_ms: Some(10.0),
336            messages_delivered: 100,
337            messages_failed: 0,
338        }
339    }
340
341    fn bad_sample(ts: u64) -> HealthSample {
342        HealthSample {
343            timestamp_secs: ts,
344            ping_rtt_ms: None,
345            messages_delivered: 0,
346            messages_failed: 100,
347        }
348    }
349
350    fn mixed_sample(ts: u64) -> HealthSample {
351        HealthSample {
352            timestamp_secs: ts,
353            ping_rtt_ms: Some(50.0),
354            messages_delivered: 50,
355            messages_failed: 50,
356        }
357    }
358
359    // ------------------------------------------------------------------
360    // 1. new() produces an empty monitor
361    // ------------------------------------------------------------------
362    #[test]
363    fn test_new_empty() {
364        let monitor = PeerHealthMonitor::new(MonitorConfig::default());
365        assert!(monitor.peers.is_empty());
366    }
367
368    // ------------------------------------------------------------------
369    // 2. record_sample creates a peer entry
370    // ------------------------------------------------------------------
371    #[test]
372    fn test_record_sample_creates_entry() {
373        let mut monitor = PeerHealthMonitor::new(MonitorConfig::default());
374        monitor.record_sample("peer-A", good_sample(1000));
375        assert!(monitor.score("peer-A").is_some());
376    }
377
378    // ------------------------------------------------------------------
379    // 3. delivery_rate: all delivered → 1.0
380    // ------------------------------------------------------------------
381    #[test]
382    fn test_delivery_rate_all_delivered() {
383        let s = HealthSample {
384            timestamp_secs: 0,
385            ping_rtt_ms: None,
386            messages_delivered: 42,
387            messages_failed: 0,
388        };
389        assert!((s.delivery_rate() - 1.0).abs() < f64::EPSILON);
390    }
391
392    // ------------------------------------------------------------------
393    // 4. delivery_rate: all failed → 0.0
394    // ------------------------------------------------------------------
395    #[test]
396    fn test_delivery_rate_all_failed() {
397        let s = HealthSample {
398            timestamp_secs: 0,
399            ping_rtt_ms: None,
400            messages_delivered: 0,
401            messages_failed: 99,
402        };
403        assert!(s.delivery_rate() < f64::EPSILON);
404    }
405
406    // ------------------------------------------------------------------
407    // 5. ping_ok: Some vs None
408    // ------------------------------------------------------------------
409    #[test]
410    fn test_ping_ok() {
411        let ok = HealthSample {
412            timestamp_secs: 0,
413            ping_rtt_ms: Some(5.0),
414            messages_delivered: 0,
415            messages_failed: 0,
416        };
417        let fail = HealthSample {
418            timestamp_secs: 0,
419            ping_rtt_ms: None,
420            messages_delivered: 0,
421            messages_failed: 0,
422        };
423        assert!(ok.ping_ok());
424        assert!(!fail.ping_ok());
425    }
426
427    // ------------------------------------------------------------------
428    // 6. record_sample: healthy after enough good samples
429    // ------------------------------------------------------------------
430    #[test]
431    fn test_record_sample_healthy() {
432        let config = MonitorConfig {
433            window_samples: 5,
434            ..Default::default()
435        };
436        let mut monitor = PeerHealthMonitor::new(config);
437        for i in 0..5u64 {
438            monitor.record_sample("peer-X", good_sample(i));
439        }
440        let score = monitor.score("peer-X").expect("entry must exist");
441        assert_eq!(score.status, HealthStatus::Healthy);
442        assert!(score.score >= 0.8);
443    }
444
445    // ------------------------------------------------------------------
446    // 7. record_sample: unhealthy after enough bad samples
447    // ------------------------------------------------------------------
448    #[test]
449    fn test_record_sample_unhealthy() {
450        let config = MonitorConfig {
451            window_samples: 5,
452            ..Default::default()
453        };
454        let mut monitor = PeerHealthMonitor::new(config);
455        for i in 0..5u64 {
456            monitor.record_sample("peer-Y", bad_sample(i));
457        }
458        let score = monitor.score("peer-Y").expect("entry must exist");
459        assert!(matches!(score.status, HealthStatus::Unhealthy { .. }));
460        assert!(score.score < 0.5);
461    }
462
463    // ------------------------------------------------------------------
464    // 8. record_sample: degraded with mixed samples
465    // ------------------------------------------------------------------
466    #[test]
467    fn test_record_sample_degraded() {
468        let config = MonitorConfig {
469            window_samples: 10,
470            ..Default::default()
471        };
472        let mut monitor = PeerHealthMonitor::new(config);
473        // 5 samples with 50% ping success and 50% delivery → raw ~0.5,
474        // but decayed because only 5 of 10 slots filled.
475        for i in 0..5u64 {
476            monitor.record_sample("peer-Z", mixed_sample(i));
477        }
478        let score = monitor.score("peer-Z").expect("entry must exist");
479        // Score should be somewhere in the degraded range or unhealthy due to decay.
480        assert!(
481            score.score < 0.8,
482            "score should not be healthy: {}",
483            score.score
484        );
485    }
486
487    // ------------------------------------------------------------------
488    // 9. score: None for unknown peer
489    // ------------------------------------------------------------------
490    #[test]
491    fn test_score_none_unknown_peer() {
492        let monitor = PeerHealthMonitor::new(MonitorConfig::default());
493        assert!(monitor.score("no-such-peer").is_none());
494    }
495
496    // ------------------------------------------------------------------
497    // 10. score: sample_count increments
498    // ------------------------------------------------------------------
499    #[test]
500    fn test_sample_count_increments() {
501        let mut monitor = PeerHealthMonitor::new(MonitorConfig::default());
502        monitor.record_sample("peer-C", good_sample(1));
503        assert_eq!(
504            monitor
505                .score("peer-C")
506                .expect("test: peer-C score should exist")
507                .sample_count,
508            1
509        );
510        monitor.record_sample("peer-C", good_sample(2));
511        assert_eq!(
512            monitor
513                .score("peer-C")
514                .expect("test: peer-C score should exist")
515                .sample_count,
516            2
517        );
518        monitor.record_sample("peer-C", good_sample(3));
519        assert_eq!(
520            monitor
521                .score("peer-C")
522                .expect("test: peer-C score should exist")
523                .sample_count,
524            3
525        );
526    }
527
528    // ------------------------------------------------------------------
529    // 11. healthy_peers filtered correctly
530    // ------------------------------------------------------------------
531    #[test]
532    fn test_healthy_peers_filtered() {
533        let config = MonitorConfig {
534            window_samples: 5,
535            ..Default::default()
536        };
537        let mut monitor = PeerHealthMonitor::new(config);
538        for i in 0..5u64 {
539            monitor.record_sample("good-peer", good_sample(i));
540            monitor.record_sample("bad-peer", bad_sample(i));
541        }
542        let healthy = monitor.healthy_peers();
543        assert!(healthy.contains(&"good-peer"), "good-peer must be healthy");
544        assert!(
545            !healthy.contains(&"bad-peer"),
546            "bad-peer must not be healthy"
547        );
548    }
549
550    // ------------------------------------------------------------------
551    // 12. unhealthy_peers filtered correctly
552    // ------------------------------------------------------------------
553    #[test]
554    fn test_unhealthy_peers_filtered() {
555        let config = MonitorConfig {
556            window_samples: 5,
557            ..Default::default()
558        };
559        let mut monitor = PeerHealthMonitor::new(config);
560        for i in 0..5u64 {
561            monitor.record_sample("good-peer", good_sample(i));
562            monitor.record_sample("bad-peer", bad_sample(i));
563        }
564        let unhealthy = monitor.unhealthy_peers();
565        assert!(
566            unhealthy.contains(&"bad-peer"),
567            "bad-peer must be unhealthy"
568        );
569        assert!(
570            !unhealthy.contains(&"good-peer"),
571            "good-peer must not be unhealthy"
572        );
573    }
574
575    // ------------------------------------------------------------------
576    // 13. degraded_peers filtered correctly
577    // ------------------------------------------------------------------
578    #[test]
579    fn test_degraded_peers_filtered() {
580        let config = MonitorConfig {
581            healthy_threshold: 0.8,
582            degraded_threshold: 0.5,
583            window_samples: 5,
584            decay_rate: 1.0, // no decay so score is purely from samples
585            ping_weight: 0.4,
586            delivery_weight: 0.6,
587        };
588        let mut monitor = PeerHealthMonitor::new(config);
589
590        // Construct a sample that produces ~0.65 raw score (no decay):
591        //   ping_ok=true  → ping_component = 0.4
592        //   delivery_rate = 40/100 = 0.4 → delivery_component = 0.24
593        //   raw ≈ 0.64  →  Degraded
594        let degraded_sample = HealthSample {
595            timestamp_secs: 1,
596            ping_rtt_ms: Some(30.0),
597            messages_delivered: 40,
598            messages_failed: 60,
599        };
600        for i in 0..5u64 {
601            let mut s = degraded_sample.clone();
602            s.timestamp_secs = i;
603            monitor.record_sample("mid-peer", s);
604        }
605
606        let degraded = monitor.degraded_peers();
607        assert!(
608            degraded.contains(&"mid-peer"),
609            "mid-peer should be degraded, got status: {:?}",
610            monitor.score("mid-peer").map(|s| &s.status)
611        );
612    }
613
614    // ------------------------------------------------------------------
615    // 14. remove_peer: true when peer existed, false otherwise
616    // ------------------------------------------------------------------
617    #[test]
618    fn test_remove_peer() {
619        let mut monitor = PeerHealthMonitor::new(MonitorConfig::default());
620        monitor.record_sample("to-remove", good_sample(1));
621        assert!(monitor.remove_peer("to-remove"));
622        assert!(!monitor.remove_peer("to-remove"));
623        assert!(!monitor.remove_peer("never-existed"));
624    }
625
626    // ------------------------------------------------------------------
627    // 15. top_peers sorted descending
628    // ------------------------------------------------------------------
629    #[test]
630    fn test_top_peers_sorted_descending() {
631        let config = MonitorConfig {
632            window_samples: 5,
633            ..Default::default()
634        };
635        let mut monitor = PeerHealthMonitor::new(config);
636        for i in 0..5u64 {
637            monitor.record_sample("alpha", good_sample(i));
638            monitor.record_sample("beta", bad_sample(i));
639        }
640        let top = monitor.top_peers(2);
641        assert_eq!(top.len(), 2);
642        // First entry must have a higher or equal score than the second.
643        assert!(
644            top[0].1 >= top[1].1,
645            "expected descending order, got {:?}",
646            top
647        );
648        assert_eq!(top[0].0, "alpha", "alpha should rank first");
649    }
650
651    // ------------------------------------------------------------------
652    // 16. is_actionable: false when sample_count < 3
653    // ------------------------------------------------------------------
654    #[test]
655    fn test_is_actionable_false_below_three_samples() {
656        let config = MonitorConfig {
657            window_samples: 5,
658            ..Default::default()
659        };
660        let mut monitor = PeerHealthMonitor::new(config);
661        monitor.record_sample("peer-act", good_sample(1));
662        assert!(!monitor
663            .score("peer-act")
664            .expect("test: peer-act score should exist")
665            .is_actionable());
666        monitor.record_sample("peer-act", good_sample(2));
667        assert!(!monitor
668            .score("peer-act")
669            .expect("test: peer-act score should exist")
670            .is_actionable());
671        monitor.record_sample("peer-act", good_sample(3));
672        // Now sample_count == 3 and status should be non-Unknown.
673        assert!(monitor
674            .score("peer-act")
675            .expect("test: peer-act score should exist")
676            .is_actionable());
677    }
678
679    // ------------------------------------------------------------------
680    // 17. window_samples cap: keeps only last N
681    // ------------------------------------------------------------------
682    #[test]
683    fn test_window_samples_cap() {
684        let config = MonitorConfig {
685            window_samples: 3,
686            ..Default::default()
687        };
688        let mut monitor = PeerHealthMonitor::new(config);
689        // Insert 5 samples; the window should hold at most 3.
690        for i in 0..5u64 {
691            monitor.record_sample("peer-win", good_sample(i));
692        }
693        let score = monitor
694            .score("peer-win")
695            .expect("test: peer-win score should exist");
696        assert_eq!(
697            score.sample_count, 3,
698            "sample_count should be capped at window_samples"
699        );
700    }
701}