Skip to main content

ipfrs_storage/
health_monitor.rs

1//! Storage Health Monitor
2//!
3//! Provides configurable health monitoring for the storage subsystem.
4//! Tracks named health checks, evaluates utilization thresholds,
5//! and computes overall health status based on worst-case analysis.
6//!
7//! ## Example
8//! ```
9//! use ipfrs_storage::health_monitor::{
10//!     StorageHealthMonitor, HealthMonitorConfig, MonitorHealthStatus,
11//! };
12//!
13//! let config = HealthMonitorConfig::default();
14//! let mut monitor = StorageHealthMonitor::new(config);
15//! monitor.register_check("disk");
16//! monitor.update_check("disk", MonitorHealthStatus::Healthy, "OK").ok();
17//! assert_eq!(monitor.overall_health(), MonitorHealthStatus::Healthy);
18//! ```
19
20use std::collections::HashMap;
21
22/// Health status for the monitor subsystem.
23///
24/// Distinct from `crate::health::HealthStatus` — this enum adds an `Unknown`
25/// variant for checks that have not yet been evaluated.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum MonitorHealthStatus {
28    /// All metrics within normal range.
29    Healthy,
30    /// One or more metrics above the degraded threshold but below unhealthy.
31    Degraded,
32    /// One or more metrics above the unhealthy threshold.
33    Unhealthy,
34    /// Check has not been evaluated yet.
35    Unknown,
36}
37
38impl MonitorHealthStatus {
39    /// Numeric severity for ordering (higher = worse).
40    fn severity(self) -> u8 {
41        match self {
42            Self::Healthy => 0,
43            Self::Unknown => 1,
44            Self::Degraded => 2,
45            Self::Unhealthy => 3,
46        }
47    }
48
49    /// Return the worse of two statuses.
50    fn worst(self, other: Self) -> Self {
51        if self.severity() >= other.severity() {
52            self
53        } else {
54            other
55        }
56    }
57}
58
59/// A single named health check.
60#[derive(Debug, Clone)]
61pub struct MonitorHealthCheck {
62    /// Human-readable name of the check.
63    pub name: String,
64    /// Current status.
65    pub status: MonitorHealthStatus,
66    /// Descriptive message from the last evaluation.
67    pub message: String,
68    /// Tick at which this check was last updated.
69    pub last_check_tick: u64,
70}
71
72/// Configuration for [`StorageHealthMonitor`].
73#[derive(Debug, Clone)]
74pub struct HealthMonitorConfig {
75    /// Number of ticks between automatic check runs.
76    pub check_interval_ticks: u64,
77    /// Utilization ratio above which status becomes `Degraded`.
78    pub degraded_threshold: f64,
79    /// Utilization ratio above which status becomes `Unhealthy`.
80    pub unhealthy_threshold: f64,
81}
82
83impl Default for HealthMonitorConfig {
84    fn default() -> Self {
85        Self {
86            check_interval_ticks: 50,
87            degraded_threshold: 0.8,
88            unhealthy_threshold: 0.95,
89        }
90    }
91}
92
93/// Aggregate statistics snapshot from [`StorageHealthMonitor`].
94#[derive(Debug, Clone)]
95pub struct HealthMonitorStats {
96    /// Total number of times `run_checks` has been called.
97    pub check_count: u64,
98    /// Current overall status.
99    pub overall_status: MonitorHealthStatus,
100    /// Number of checks with `Healthy` status.
101    pub healthy: usize,
102    /// Number of checks with `Degraded` status.
103    pub degraded: usize,
104    /// Number of checks with `Unhealthy` status.
105    pub unhealthy: usize,
106}
107
108/// Monitors the health of storage subsystems.
109///
110/// Maintains a set of named health checks, supports tick-based scheduling,
111/// and computes an overall status from the worst individual check.
112pub struct StorageHealthMonitor {
113    config: HealthMonitorConfig,
114    checks: HashMap<String, MonitorHealthCheck>,
115    current_tick: u64,
116    last_check_tick: u64,
117    overall_status: MonitorHealthStatus,
118    check_count: u64,
119}
120
121impl StorageHealthMonitor {
122    /// Create a new monitor with the given configuration.
123    pub fn new(config: HealthMonitorConfig) -> Self {
124        Self {
125            config,
126            checks: HashMap::new(),
127            current_tick: 0,
128            last_check_tick: 0,
129            overall_status: MonitorHealthStatus::Healthy,
130            check_count: 0,
131        }
132    }
133
134    /// Register a named health check with `Unknown` status.
135    pub fn register_check(&mut self, name: &str) {
136        self.checks.insert(
137            name.to_string(),
138            MonitorHealthCheck {
139                name: name.to_string(),
140                status: MonitorHealthStatus::Unknown,
141                message: String::new(),
142                last_check_tick: 0,
143            },
144        );
145    }
146
147    /// Update the status and message of an existing check.
148    ///
149    /// Returns `Err` if no check with the given name is registered.
150    pub fn update_check(
151        &mut self,
152        name: &str,
153        status: MonitorHealthStatus,
154        message: &str,
155    ) -> Result<(), String> {
156        let check = self
157            .checks
158            .get_mut(name)
159            .ok_or_else(|| format!("check '{}' not found", name))?;
160        check.status = status;
161        check.message = message.to_string();
162        check.last_check_tick = self.current_tick;
163        Ok(())
164    }
165
166    /// Evaluate a utilization ratio against the configured thresholds.
167    ///
168    /// `capacity` of zero is treated as `Unhealthy`.
169    pub fn evaluate_utilization(&self, current: u64, capacity: u64) -> MonitorHealthStatus {
170        if capacity == 0 {
171            return MonitorHealthStatus::Unhealthy;
172        }
173        let ratio = current as f64 / capacity as f64;
174        if ratio >= self.config.unhealthy_threshold {
175            MonitorHealthStatus::Unhealthy
176        } else if ratio >= self.config.degraded_threshold {
177            MonitorHealthStatus::Degraded
178        } else {
179            MonitorHealthStatus::Healthy
180        }
181    }
182
183    /// Compute the overall health as the worst status among all checks.
184    ///
185    /// Returns `Healthy` when there are no registered checks.
186    pub fn overall_health(&self) -> MonitorHealthStatus {
187        self.checks
188            .values()
189            .fold(MonitorHealthStatus::Healthy, |acc, c| acc.worst(c.status))
190    }
191
192    /// Retrieve a reference to a named check, if it exists.
193    pub fn get_check(&self, name: &str) -> Option<&MonitorHealthCheck> {
194        self.checks.get(name)
195    }
196
197    /// Returns `true` when enough ticks have elapsed since the last check run.
198    pub fn should_check(&self) -> bool {
199        self.current_tick.saturating_sub(self.last_check_tick) >= self.config.check_interval_ticks
200    }
201
202    /// Execute a check cycle: update bookkeeping and recompute overall status.
203    pub fn run_checks(&mut self) {
204        self.last_check_tick = self.current_tick;
205        self.check_count += 1;
206        self.overall_status = self.overall_health();
207    }
208
209    /// Count of checks currently `Healthy`.
210    pub fn healthy_count(&self) -> usize {
211        self.checks
212            .values()
213            .filter(|c| c.status == MonitorHealthStatus::Healthy)
214            .count()
215    }
216
217    /// Count of checks currently `Degraded`.
218    pub fn degraded_count(&self) -> usize {
219        self.checks
220            .values()
221            .filter(|c| c.status == MonitorHealthStatus::Degraded)
222            .count()
223    }
224
225    /// Count of checks currently `Unhealthy`.
226    pub fn unhealthy_count(&self) -> usize {
227        self.checks
228            .values()
229            .filter(|c| c.status == MonitorHealthStatus::Unhealthy)
230            .count()
231    }
232
233    /// Advance the internal tick counter by one.
234    pub fn tick(&mut self) {
235        self.current_tick += 1;
236    }
237
238    /// Return references to all registered checks.
239    pub fn all_checks(&self) -> Vec<&MonitorHealthCheck> {
240        self.checks.values().collect()
241    }
242
243    /// Produce a statistics snapshot.
244    pub fn stats(&self) -> HealthMonitorStats {
245        HealthMonitorStats {
246            check_count: self.check_count,
247            overall_status: self.overall_status,
248            healthy: self.healthy_count(),
249            degraded: self.degraded_count(),
250            unhealthy: self.unhealthy_count(),
251        }
252    }
253}
254
255// ---------------------------------------------------------------------------
256// Tests
257// ---------------------------------------------------------------------------
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262
263    fn default_monitor() -> StorageHealthMonitor {
264        StorageHealthMonitor::new(HealthMonitorConfig::default())
265    }
266
267    // -- construction & defaults -------------------------------------------
268
269    #[test]
270    fn test_default_config_values() {
271        let cfg = HealthMonitorConfig::default();
272        assert_eq!(cfg.check_interval_ticks, 50);
273        assert!((cfg.degraded_threshold - 0.8).abs() < f64::EPSILON);
274        assert!((cfg.unhealthy_threshold - 0.95).abs() < f64::EPSILON);
275    }
276
277    #[test]
278    fn test_new_monitor_is_healthy() {
279        let m = default_monitor();
280        assert_eq!(m.overall_health(), MonitorHealthStatus::Healthy);
281        assert_eq!(m.overall_status, MonitorHealthStatus::Healthy);
282    }
283
284    #[test]
285    fn test_new_monitor_empty_checks() {
286        let m = default_monitor();
287        assert!(m.all_checks().is_empty());
288        assert_eq!(m.healthy_count(), 0);
289        assert_eq!(m.degraded_count(), 0);
290        assert_eq!(m.unhealthy_count(), 0);
291    }
292
293    // -- register / get ----------------------------------------------------
294
295    #[test]
296    fn test_register_check_unknown() {
297        let mut m = default_monitor();
298        m.register_check("disk");
299        let c = m.get_check("disk").expect("should exist");
300        assert_eq!(c.status, MonitorHealthStatus::Unknown);
301        assert_eq!(c.name, "disk");
302        assert!(c.message.is_empty());
303    }
304
305    #[test]
306    fn test_register_multiple_checks() {
307        let mut m = default_monitor();
308        m.register_check("disk");
309        m.register_check("memory");
310        m.register_check("network");
311        assert_eq!(m.all_checks().len(), 3);
312    }
313
314    #[test]
315    fn test_get_check_nonexistent() {
316        let m = default_monitor();
317        assert!(m.get_check("nope").is_none());
318    }
319
320    // -- update ------------------------------------------------------------
321
322    #[test]
323    fn test_update_check_success() {
324        let mut m = default_monitor();
325        m.register_check("disk");
326        let res = m.update_check("disk", MonitorHealthStatus::Healthy, "all good");
327        assert!(res.is_ok());
328        let c = m.get_check("disk").expect("should exist");
329        assert_eq!(c.status, MonitorHealthStatus::Healthy);
330        assert_eq!(c.message, "all good");
331    }
332
333    #[test]
334    fn test_update_check_not_found() {
335        let mut m = default_monitor();
336        let res = m.update_check("missing", MonitorHealthStatus::Healthy, "");
337        assert!(res.is_err());
338    }
339
340    #[test]
341    fn test_update_check_records_tick() {
342        let mut m = default_monitor();
343        m.register_check("disk");
344        for _ in 0..10 {
345            m.tick();
346        }
347        m.update_check("disk", MonitorHealthStatus::Healthy, "ok")
348            .expect("update should succeed");
349        let c = m.get_check("disk").expect("should exist");
350        assert_eq!(c.last_check_tick, 10);
351    }
352
353    // -- evaluate_utilization ----------------------------------------------
354
355    #[test]
356    fn test_evaluate_healthy() {
357        let m = default_monitor();
358        assert_eq!(
359            m.evaluate_utilization(50, 100),
360            MonitorHealthStatus::Healthy
361        );
362    }
363
364    #[test]
365    fn test_evaluate_degraded() {
366        let m = default_monitor();
367        assert_eq!(
368            m.evaluate_utilization(85, 100),
369            MonitorHealthStatus::Degraded
370        );
371    }
372
373    #[test]
374    fn test_evaluate_unhealthy() {
375        let m = default_monitor();
376        assert_eq!(
377            m.evaluate_utilization(96, 100),
378            MonitorHealthStatus::Unhealthy
379        );
380    }
381
382    #[test]
383    fn test_evaluate_zero_capacity() {
384        let m = default_monitor();
385        assert_eq!(m.evaluate_utilization(0, 0), MonitorHealthStatus::Unhealthy);
386    }
387
388    #[test]
389    fn test_evaluate_exact_degraded_boundary() {
390        let m = default_monitor();
391        // 80/100 = 0.80 — exactly at the threshold => Degraded
392        assert_eq!(
393            m.evaluate_utilization(80, 100),
394            MonitorHealthStatus::Degraded
395        );
396    }
397
398    #[test]
399    fn test_evaluate_just_below_degraded() {
400        let m = default_monitor();
401        assert_eq!(
402            m.evaluate_utilization(79, 100),
403            MonitorHealthStatus::Healthy
404        );
405    }
406
407    #[test]
408    fn test_evaluate_exact_unhealthy_boundary() {
409        let m = default_monitor();
410        // 95/100 = 0.95
411        assert_eq!(
412            m.evaluate_utilization(95, 100),
413            MonitorHealthStatus::Unhealthy
414        );
415    }
416
417    #[test]
418    fn test_evaluate_just_below_unhealthy() {
419        let m = default_monitor();
420        // 94/100 = 0.94
421        assert_eq!(
422            m.evaluate_utilization(94, 100),
423            MonitorHealthStatus::Degraded
424        );
425    }
426
427    #[test]
428    fn test_evaluate_full_capacity() {
429        let m = default_monitor();
430        assert_eq!(
431            m.evaluate_utilization(100, 100),
432            MonitorHealthStatus::Unhealthy
433        );
434    }
435
436    #[test]
437    fn test_evaluate_custom_thresholds() {
438        let cfg = HealthMonitorConfig {
439            check_interval_ticks: 10,
440            degraded_threshold: 0.5,
441            unhealthy_threshold: 0.7,
442        };
443        let m = StorageHealthMonitor::new(cfg);
444        assert_eq!(
445            m.evaluate_utilization(40, 100),
446            MonitorHealthStatus::Healthy
447        );
448        assert_eq!(
449            m.evaluate_utilization(60, 100),
450            MonitorHealthStatus::Degraded
451        );
452        assert_eq!(
453            m.evaluate_utilization(75, 100),
454            MonitorHealthStatus::Unhealthy
455        );
456    }
457
458    // -- overall_health ----------------------------------------------------
459
460    #[test]
461    fn test_overall_health_all_healthy() {
462        let mut m = default_monitor();
463        m.register_check("a");
464        m.register_check("b");
465        m.update_check("a", MonitorHealthStatus::Healthy, "").ok();
466        m.update_check("b", MonitorHealthStatus::Healthy, "").ok();
467        assert_eq!(m.overall_health(), MonitorHealthStatus::Healthy);
468    }
469
470    #[test]
471    fn test_overall_health_one_degraded() {
472        let mut m = default_monitor();
473        m.register_check("a");
474        m.register_check("b");
475        m.update_check("a", MonitorHealthStatus::Healthy, "").ok();
476        m.update_check("b", MonitorHealthStatus::Degraded, "").ok();
477        assert_eq!(m.overall_health(), MonitorHealthStatus::Degraded);
478    }
479
480    #[test]
481    fn test_overall_health_one_unhealthy() {
482        let mut m = default_monitor();
483        m.register_check("a");
484        m.register_check("b");
485        m.update_check("a", MonitorHealthStatus::Healthy, "").ok();
486        m.update_check("b", MonitorHealthStatus::Unhealthy, "").ok();
487        assert_eq!(m.overall_health(), MonitorHealthStatus::Unhealthy);
488    }
489
490    #[test]
491    fn test_overall_health_unknown_worse_than_healthy() {
492        let mut m = default_monitor();
493        m.register_check("a");
494        m.register_check("b");
495        m.update_check("a", MonitorHealthStatus::Healthy, "").ok();
496        // b stays Unknown
497        assert_eq!(m.overall_health(), MonitorHealthStatus::Unknown);
498    }
499
500    #[test]
501    fn test_overall_health_unhealthy_worst() {
502        let mut m = default_monitor();
503        m.register_check("a");
504        m.register_check("b");
505        m.register_check("c");
506        m.update_check("a", MonitorHealthStatus::Healthy, "").ok();
507        m.update_check("b", MonitorHealthStatus::Degraded, "").ok();
508        m.update_check("c", MonitorHealthStatus::Unhealthy, "").ok();
509        assert_eq!(m.overall_health(), MonitorHealthStatus::Unhealthy);
510    }
511
512    // -- should_check / tick -----------------------------------------------
513
514    #[test]
515    fn test_should_check_initially_true() {
516        // current_tick == 0, last_check_tick == 0, interval == 50
517        // 0 - 0 = 0 < 50 => false at start with default config
518        let m = default_monitor();
519        assert!(!m.should_check());
520    }
521
522    #[test]
523    fn test_should_check_after_enough_ticks() {
524        let mut m = default_monitor();
525        for _ in 0..50 {
526            m.tick();
527        }
528        assert!(m.should_check());
529    }
530
531    #[test]
532    fn test_should_check_resets_after_run() {
533        let mut m = default_monitor();
534        for _ in 0..50 {
535            m.tick();
536        }
537        assert!(m.should_check());
538        m.run_checks();
539        assert!(!m.should_check());
540    }
541
542    #[test]
543    fn test_tick_increments() {
544        let mut m = default_monitor();
545        m.tick();
546        m.tick();
547        m.tick();
548        assert_eq!(m.current_tick, 3);
549    }
550
551    // -- run_checks --------------------------------------------------------
552
553    #[test]
554    fn test_run_checks_increments_count() {
555        let mut m = default_monitor();
556        assert_eq!(m.check_count, 0);
557        m.run_checks();
558        assert_eq!(m.check_count, 1);
559        m.run_checks();
560        assert_eq!(m.check_count, 2);
561    }
562
563    #[test]
564    fn test_run_checks_updates_overall() {
565        let mut m = default_monitor();
566        m.register_check("a");
567        m.update_check("a", MonitorHealthStatus::Degraded, "warn")
568            .ok();
569        m.run_checks();
570        assert_eq!(m.overall_status, MonitorHealthStatus::Degraded);
571    }
572
573    // -- counts ------------------------------------------------------------
574
575    #[test]
576    fn test_healthy_count() {
577        let mut m = default_monitor();
578        m.register_check("a");
579        m.register_check("b");
580        m.update_check("a", MonitorHealthStatus::Healthy, "").ok();
581        m.update_check("b", MonitorHealthStatus::Healthy, "").ok();
582        assert_eq!(m.healthy_count(), 2);
583    }
584
585    #[test]
586    fn test_degraded_count() {
587        let mut m = default_monitor();
588        m.register_check("a");
589        m.register_check("b");
590        m.update_check("a", MonitorHealthStatus::Degraded, "").ok();
591        m.update_check("b", MonitorHealthStatus::Degraded, "").ok();
592        assert_eq!(m.degraded_count(), 2);
593    }
594
595    #[test]
596    fn test_unhealthy_count() {
597        let mut m = default_monitor();
598        m.register_check("a");
599        m.update_check("a", MonitorHealthStatus::Unhealthy, "").ok();
600        assert_eq!(m.unhealthy_count(), 1);
601    }
602
603    #[test]
604    fn test_mixed_counts() {
605        let mut m = default_monitor();
606        m.register_check("a");
607        m.register_check("b");
608        m.register_check("c");
609        m.register_check("d");
610        m.update_check("a", MonitorHealthStatus::Healthy, "").ok();
611        m.update_check("b", MonitorHealthStatus::Degraded, "").ok();
612        m.update_check("c", MonitorHealthStatus::Unhealthy, "").ok();
613        // d stays Unknown
614        assert_eq!(m.healthy_count(), 1);
615        assert_eq!(m.degraded_count(), 1);
616        assert_eq!(m.unhealthy_count(), 1);
617    }
618
619    // -- stats -------------------------------------------------------------
620
621    #[test]
622    fn test_stats_snapshot() {
623        let mut m = default_monitor();
624        m.register_check("a");
625        m.register_check("b");
626        m.update_check("a", MonitorHealthStatus::Healthy, "").ok();
627        m.update_check("b", MonitorHealthStatus::Degraded, "").ok();
628        m.run_checks();
629        let s = m.stats();
630        assert_eq!(s.check_count, 1);
631        assert_eq!(s.overall_status, MonitorHealthStatus::Degraded);
632        assert_eq!(s.healthy, 1);
633        assert_eq!(s.degraded, 1);
634        assert_eq!(s.unhealthy, 0);
635    }
636
637    #[test]
638    fn test_stats_empty_monitor() {
639        let m = default_monitor();
640        let s = m.stats();
641        assert_eq!(s.check_count, 0);
642        assert_eq!(s.overall_status, MonitorHealthStatus::Healthy);
643        assert_eq!(s.healthy, 0);
644        assert_eq!(s.degraded, 0);
645        assert_eq!(s.unhealthy, 0);
646    }
647
648    // -- severity ordering -------------------------------------------------
649
650    #[test]
651    fn test_severity_ordering() {
652        assert!(MonitorHealthStatus::Healthy.severity() < MonitorHealthStatus::Unknown.severity());
653        assert!(MonitorHealthStatus::Unknown.severity() < MonitorHealthStatus::Degraded.severity());
654        assert!(
655            MonitorHealthStatus::Degraded.severity() < MonitorHealthStatus::Unhealthy.severity()
656        );
657    }
658
659    #[test]
660    fn test_worst_picks_higher_severity() {
661        assert_eq!(
662            MonitorHealthStatus::Healthy.worst(MonitorHealthStatus::Degraded),
663            MonitorHealthStatus::Degraded
664        );
665        assert_eq!(
666            MonitorHealthStatus::Unhealthy.worst(MonitorHealthStatus::Healthy),
667            MonitorHealthStatus::Unhealthy
668        );
669    }
670
671    // -- re-registration overwrites ----------------------------------------
672
673    #[test]
674    fn test_register_overwrites() {
675        let mut m = default_monitor();
676        m.register_check("a");
677        m.update_check("a", MonitorHealthStatus::Unhealthy, "bad")
678            .ok();
679        m.register_check("a"); // re-register resets
680        let c = m.get_check("a").expect("should exist");
681        assert_eq!(c.status, MonitorHealthStatus::Unknown);
682        assert!(c.message.is_empty());
683    }
684
685    // -- should_check with small interval ----------------------------------
686
687    #[test]
688    fn test_should_check_interval_one() {
689        let cfg = HealthMonitorConfig {
690            check_interval_ticks: 1,
691            ..HealthMonitorConfig::default()
692        };
693        let mut m = StorageHealthMonitor::new(cfg);
694        assert!(!m.should_check()); // 0 - 0 = 0 < 1
695        m.tick();
696        assert!(m.should_check()); // 1 - 0 = 1 >= 1
697        m.run_checks();
698        assert!(!m.should_check()); // 1 - 1 = 0 < 1
699        m.tick();
700        assert!(m.should_check()); // 2 - 1 = 1 >= 1
701    }
702
703    // -- all_checks --------------------------------------------------------
704
705    #[test]
706    fn test_all_checks_returns_all() {
707        let mut m = default_monitor();
708        m.register_check("x");
709        m.register_check("y");
710        let all = m.all_checks();
711        assert_eq!(all.len(), 2);
712        let names: Vec<&str> = all.iter().map(|c| c.name.as_str()).collect();
713        assert!(names.contains(&"x"));
714        assert!(names.contains(&"y"));
715    }
716}