Skip to main content

ipfrs_storage/
storage_health_monitor.rs

1//! Storage Health Monitor
2//!
3//! Production-grade health monitoring system for IPFRS storage subsystems.
4//! Tracks probes across multiple storage categories (BlockStore, WalLog, Cache, etc.),
5//! computes EWMA success rates and latencies, fires severity-graded alerts,
6//! maintains a bounded rolling history of snapshots, and provides actionable
7//! recovery suggestions per probe.
8//!
9//! # Example
10//! ```rust
11//! use ipfrs_storage::storage_health_monitor::{
12//!     StorageHealthMonitor, ShmMonitorConfig, ShmCategory,
13//! };
14//!
15//! let cfg = ShmMonitorConfig::default();
16//! let mut monitor = StorageHealthMonitor::new(cfg);
17//! let id = monitor.register_probe("wal-primary", ShmCategory::WalLog, Default::default());
18//! monitor.record_check(id, true, 1.2).ok();
19//! let snap = monitor.run_health_check();
20//! assert!(snap.score >= 0.0 && snap.score <= 1.0);
21//! ```
22
23use std::collections::{HashMap, VecDeque};
24
25// ─── deterministic pseudo-random (xorshift64) ────────────────────────────────
26
27#[inline]
28fn xorshift64(state: &mut u64) -> u64 {
29    let mut x = *state;
30    x ^= x << 13;
31    x ^= x >> 7;
32    x ^= x << 17;
33    *state = x;
34    x
35}
36
37// ─── type aliases ────────────────────────────────────────────────────────────
38
39/// Opaque probe identifier.
40pub type ShmProbeId = u32;
41
42/// Type alias for the monitor (public surface).
43pub type ShmStorageHealthMonitor = StorageHealthMonitor;
44
45// ─── enumerations ────────────────────────────────────────────────────────────
46
47/// Storage subsystem category tracked by a probe.
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
49pub enum ShmCategory {
50    BlockStore,
51    IndexStore,
52    WalLog,
53    Cache,
54    Network,
55    Encryption,
56    Compression,
57}
58
59impl ShmCategory {
60    /// Human-readable label.
61    pub fn label(self) -> &'static str {
62        match self {
63            Self::BlockStore => "block-store",
64            Self::IndexStore => "index-store",
65            Self::WalLog => "wal-log",
66            Self::Cache => "cache",
67            Self::Network => "network",
68            Self::Encryption => "encryption",
69            Self::Compression => "compression",
70        }
71    }
72}
73
74/// Operational status of a single probe.
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
76pub enum ShmStatus {
77    Healthy,
78    Degraded,
79    Critical,
80    Unknown,
81    Recovering,
82}
83
84impl ShmStatus {
85    /// Numeric weight for aggregate scoring (0 = best, 3 = worst).
86    pub fn weight(self) -> f64 {
87        match self {
88            Self::Healthy => 0.0,
89            Self::Recovering => 0.5,
90            Self::Unknown => 1.0,
91            Self::Degraded => 2.0,
92            Self::Critical => 3.0,
93        }
94    }
95}
96
97/// Alert severity levels.
98#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
99pub enum ShmSeverity {
100    Info,
101    Warning,
102    Error,
103    Critical,
104}
105
106// ─── configuration ───────────────────────────────────────────────────────────
107
108/// Configuration for [`StorageHealthMonitor`].
109#[derive(Debug, Clone)]
110pub struct ShmMonitorConfig {
111    /// Seconds between automatic health-check cycles (informational; caller drives timing).
112    pub check_interval_secs: u64,
113    /// Success-rate threshold below which a probe becomes `Degraded` (default 0.80).
114    pub alert_threshold: f64,
115    /// Success-rate threshold above which a probe transitions from `Critical/Degraded`
116    /// to `Recovering` (default 0.90).
117    pub recovery_threshold: f64,
118    /// Number of consecutive failures before a probe becomes `Critical`.
119    pub max_consecutive_failures: u32,
120    /// EWMA smoothing factor α for success_rate (0 < α ≤ 1; default 0.1).
121    pub ewma_alpha: f64,
122    /// EWMA smoothing factor α for latency_ms (0 < α ≤ 1; default 0.2).
123    pub ewma_latency_alpha: f64,
124    /// TTL seconds for auto-resolvable alerts (0 = no auto-resolve).
125    pub alert_auto_resolve_secs: u64,
126}
127
128impl Default for ShmMonitorConfig {
129    fn default() -> Self {
130        Self {
131            check_interval_secs: 30,
132            alert_threshold: 0.80,
133            recovery_threshold: 0.90,
134            max_consecutive_failures: 5,
135            ewma_alpha: 0.10,
136            ewma_latency_alpha: 0.20,
137            alert_auto_resolve_secs: 300,
138        }
139    }
140}
141
142// ─── probe ───────────────────────────────────────────────────────────────────
143
144/// A named health-check probe for one storage component.
145#[derive(Debug, Clone)]
146pub struct ShmProbe {
147    pub id: ShmProbeId,
148    pub name: String,
149    pub category: ShmCategory,
150    pub status: ShmStatus,
151    /// Unix-epoch timestamp of the most recent check (seconds).
152    pub last_check_ts: u64,
153    pub consecutive_failures: u32,
154    /// EWMA-smoothed success rate (0.0–1.0).
155    pub success_rate: f64,
156    /// EWMA-smoothed latency in milliseconds.
157    pub latency_ms: f64,
158    /// Freeform key/value metadata attached at registration.
159    pub metadata: HashMap<String, String>,
160    /// Total checks recorded.
161    pub total_checks: u64,
162    /// Total successful checks.
163    pub total_successes: u64,
164}
165
166impl ShmProbe {
167    fn new(
168        id: ShmProbeId,
169        name: impl Into<String>,
170        category: ShmCategory,
171        metadata: HashMap<String, String>,
172    ) -> Self {
173        Self {
174            id,
175            name: name.into(),
176            category,
177            status: ShmStatus::Unknown,
178            last_check_ts: 0,
179            consecutive_failures: 0,
180            success_rate: 1.0, // optimistic initial value
181            latency_ms: 0.0,
182            metadata,
183            total_checks: 0,
184            total_successes: 0,
185        }
186    }
187}
188
189// ─── snapshot / alert ────────────────────────────────────────────────────────
190
191/// Point-in-time health snapshot across all probes.
192#[derive(Debug, Clone)]
193pub struct ShmHealthSnapshot {
194    pub ts: u64,
195    pub overall_status: ShmStatus,
196    pub probe_statuses: HashMap<ShmProbeId, ShmStatus>,
197    /// Weighted composite score in [0.0, 1.0] where 1.0 = fully healthy.
198    pub score: f64,
199    /// Number of probes sampled.
200    pub probe_count: usize,
201}
202
203/// A fired alert from the monitoring system.
204#[derive(Debug, Clone)]
205pub struct ShmAlert {
206    pub id: usize,
207    pub ts: u64,
208    pub probe_id: ShmProbeId,
209    pub severity: ShmSeverity,
210    pub message: String,
211    /// If set, the alert may be automatically expired at this Unix-epoch timestamp.
212    pub auto_resolve_at: Option<u64>,
213    pub resolved: bool,
214}
215
216// ─── stats ───────────────────────────────────────────────────────────────────
217
218/// Aggregate statistics for the monitor.
219#[derive(Debug, Clone, Default)]
220pub struct ShmMonitorStats {
221    pub total_probes: usize,
222    pub healthy_count: usize,
223    pub degraded_count: usize,
224    pub critical_count: usize,
225    pub unknown_count: usize,
226    pub recovering_count: usize,
227    pub total_alerts_fired: u64,
228    pub active_alerts: usize,
229    pub snapshots_stored: usize,
230    pub total_checks_recorded: u64,
231}
232
233// ─── main struct ─────────────────────────────────────────────────────────────
234
235/// Production-grade storage-subsystem health monitor.
236///
237/// Manages a collection of named probes, records check outcomes (success/failure +
238/// latency), computes EWMA metrics, fires alerts, and maintains a rolling history
239/// of health snapshots.
240pub struct StorageHealthMonitor {
241    config: ShmMonitorConfig,
242    probes: HashMap<ShmProbeId, ShmProbe>,
243    /// Bounded deque of historical snapshots (max 200).
244    history: VecDeque<ShmHealthSnapshot>,
245    /// Bounded deque of fired alerts (max 500).
246    alerts: VecDeque<ShmAlert>,
247    next_probe_id: ShmProbeId,
248    next_alert_id: usize,
249    total_alerts_fired: u64,
250    /// Simple monotonic clock seed (xorshift64 state for jitter/demo purposes).
251    rng_state: u64,
252}
253
254const MAX_HISTORY: usize = 200;
255const MAX_ALERTS: usize = 500;
256
257impl StorageHealthMonitor {
258    // ── construction ──────────────────────────────────────────────────────
259
260    /// Create a new monitor with the given configuration.
261    pub fn new(config: ShmMonitorConfig) -> Self {
262        Self {
263            config,
264            probes: HashMap::new(),
265            history: VecDeque::with_capacity(MAX_HISTORY),
266            alerts: VecDeque::with_capacity(MAX_ALERTS),
267            next_probe_id: 1,
268            next_alert_id: 0,
269            total_alerts_fired: 0,
270            rng_state: 0xdeadbeef_cafebabe,
271        }
272    }
273
274    /// Create a monitor with default configuration.
275    pub fn default_config() -> Self {
276        Self::new(ShmMonitorConfig::default())
277    }
278
279    // ── probe registration ────────────────────────────────────────────────
280
281    /// Register a new probe and return its unique [`ShmProbeId`].
282    pub fn register_probe(
283        &mut self,
284        name: impl Into<String>,
285        category: ShmCategory,
286        metadata: HashMap<String, String>,
287    ) -> ShmProbeId {
288        let id = self.next_probe_id;
289        self.next_probe_id = self.next_probe_id.saturating_add(1);
290        let probe = ShmProbe::new(id, name, category, metadata);
291        self.probes.insert(id, probe);
292        id
293    }
294
295    // ── check recording ───────────────────────────────────────────────────
296
297    /// Record the outcome of a health check for `probe_id`.
298    ///
299    /// Updates EWMA success rate and latency, transitions probe status, and
300    /// fires alerts when thresholds are crossed.
301    ///
302    /// Returns an error string if the probe does not exist.
303    pub fn record_check(
304        &mut self,
305        probe_id: ShmProbeId,
306        success: bool,
307        latency_ms: f64,
308    ) -> Result<(), String> {
309        let ts = self.now_ts();
310        self.record_check_at(probe_id, success, latency_ms, ts)
311    }
312
313    /// Same as `record_check` but with an explicit Unix-epoch timestamp.
314    pub fn record_check_at(
315        &mut self,
316        probe_id: ShmProbeId,
317        success: bool,
318        latency_ms: f64,
319        now_ts: u64,
320    ) -> Result<(), String> {
321        let config = self.config.clone();
322        let probe = self
323            .probes
324            .get_mut(&probe_id)
325            .ok_or_else(|| format!("probe {} not found", probe_id))?;
326
327        probe.total_checks += 1;
328        probe.last_check_ts = now_ts;
329
330        // EWMA success rate
331        let outcome = if success { 1.0_f64 } else { 0.0_f64 };
332        probe.success_rate =
333            config.ewma_alpha * outcome + (1.0 - config.ewma_alpha) * probe.success_rate;
334
335        // EWMA latency (only update on success to avoid polluting with failure spikes)
336        if success {
337            probe.total_successes += 1;
338            probe.consecutive_failures = 0;
339            let lat = latency_ms.max(0.0);
340            if probe.latency_ms == 0.0 {
341                probe.latency_ms = lat;
342            } else {
343                probe.latency_ms = config.ewma_latency_alpha * lat
344                    + (1.0 - config.ewma_latency_alpha) * probe.latency_ms;
345            }
346        } else {
347            probe.consecutive_failures = probe.consecutive_failures.saturating_add(1);
348        }
349
350        // Determine new status
351        let new_status = self.compute_probe_status(probe_id, &config)?;
352        let old_status = self.probes[&probe_id].status;
353
354        if let Some(p) = self.probes.get_mut(&probe_id) {
355            p.status = new_status;
356        }
357
358        // Fire alerts on status transitions
359        self.maybe_fire_alert(probe_id, old_status, new_status, now_ts, &config);
360
361        Ok(())
362    }
363
364    // ── health snapshot ───────────────────────────────────────────────────
365
366    /// Compute an overall [`ShmHealthSnapshot`] and push it to the history.
367    pub fn run_health_check(&mut self) -> ShmHealthSnapshot {
368        let ts = self.now_ts();
369        self.run_health_check_at(ts)
370    }
371
372    /// Same as `run_health_check` but with an explicit timestamp.
373    pub fn run_health_check_at(&mut self, now_ts: u64) -> ShmHealthSnapshot {
374        let probe_statuses: HashMap<ShmProbeId, ShmStatus> =
375            self.probes.iter().map(|(&id, p)| (id, p.status)).collect();
376
377        let (score, overall_status) = self.compute_overall_score();
378
379        let snap = ShmHealthSnapshot {
380            ts: now_ts,
381            overall_status,
382            probe_statuses,
383            score,
384            probe_count: self.probes.len(),
385        };
386
387        if self.history.len() >= MAX_HISTORY {
388            self.history.pop_front();
389        }
390        self.history.push_back(snap.clone());
391
392        snap
393    }
394
395    // ── alert management ─────────────────────────────────────────────────
396
397    /// Mark an alert as resolved by its index in the internal deque.
398    pub fn resolve_alert(&mut self, alert_id: usize) -> Result<(), String> {
399        let alert = self
400            .alerts
401            .iter_mut()
402            .find(|a| a.id == alert_id)
403            .ok_or_else(|| format!("alert {} not found", alert_id))?;
404        alert.resolved = true;
405        Ok(())
406    }
407
408    /// Expire all unresolved alerts whose `auto_resolve_at` timestamp ≤ `now_ts`.
409    pub fn expire_alerts(&mut self, now_ts: u64) {
410        for alert in self.alerts.iter_mut() {
411            if let Some(expire_at) = alert.auto_resolve_at {
412                if !alert.resolved && now_ts >= expire_at {
413                    alert.resolved = true;
414                }
415            }
416        }
417    }
418
419    /// Return active (unresolved) alerts.
420    pub fn active_alerts(&self) -> Vec<&ShmAlert> {
421        self.alerts.iter().filter(|a| !a.resolved).collect()
422    }
423
424    /// Return all alerts (resolved and unresolved).
425    pub fn all_alerts(&self) -> Vec<&ShmAlert> {
426        self.alerts.iter().collect()
427    }
428
429    // ── query helpers ─────────────────────────────────────────────────────
430
431    /// Return all probes belonging to the given category.
432    pub fn probes_by_category(&self, cat: ShmCategory) -> Vec<&ShmProbe> {
433        self.probes.values().filter(|p| p.category == cat).collect()
434    }
435
436    /// Return a reference to a probe by id.
437    pub fn probe(&self, id: ShmProbeId) -> Option<&ShmProbe> {
438        self.probes.get(&id)
439    }
440
441    /// Return the last `window` overall health scores from history (oldest first).
442    pub fn health_trend(&self, window: usize) -> Vec<f64> {
443        let skip = self.history.len().saturating_sub(window);
444        self.history.iter().skip(skip).map(|s| s.score).collect()
445    }
446
447    /// Return the last `window` snapshots from history (oldest first).
448    pub fn history_window(&self, window: usize) -> Vec<&ShmHealthSnapshot> {
449        let skip = self.history.len().saturating_sub(window);
450        self.history.iter().skip(skip).collect()
451    }
452
453    // ── recovery suggestions ──────────────────────────────────────────────
454
455    /// Produce a human-readable recovery suggestion for the given probe.
456    pub fn suggest_recovery(&self, probe_id: ShmProbeId) -> Result<String, String> {
457        let probe = self
458            .probes
459            .get(&probe_id)
460            .ok_or_else(|| format!("probe {} not found", probe_id))?;
461
462        let suggestion = match (probe.category, probe.status) {
463            (ShmCategory::BlockStore, ShmStatus::Critical) => {
464                "Run block-store integrity scan and compact fragmented segments. \
465                 Check disk utilisation and I/O error counters. \
466                 Consider failing over to a replica shard."
467            }
468            (ShmCategory::BlockStore, ShmStatus::Degraded) => {
469                "Increase block-store flush concurrency. \
470                 Verify that write-back cache is not saturated. \
471                 Monitor IOPS and queue depth."
472            }
473            (ShmCategory::IndexStore, ShmStatus::Critical) => {
474                "Trigger an index rebuild from the WAL. \
475                 Verify B-tree pages for corruption. \
476                 Restore from last known-good index snapshot if rebuild fails."
477            }
478            (ShmCategory::IndexStore, ShmStatus::Degraded) => {
479                "Schedule an index compaction pass. \
480                 Check for lock contention on hot index buckets."
481            }
482            (ShmCategory::WalLog, ShmStatus::Critical) => {
483                "Halt writes immediately to prevent data loss. \
484                 Replay the WAL from the last checkpoint. \
485                 Verify disk space; rotate or archive old segments."
486            }
487            (ShmCategory::WalLog, ShmStatus::Degraded) => {
488                "Increase WAL segment size or rotation frequency. \
489                 Monitor sync latency; consider async fsync mode."
490            }
491            (ShmCategory::Cache, ShmStatus::Critical) => {
492                "Flush and invalidate the cache. \
493                 Investigate memory pressure — consider reducing cache capacity. \
494                 Check for eviction storms."
495            }
496            (ShmCategory::Cache, ShmStatus::Degraded) => {
497                "Tune LRU/LFU eviction policy. \
498                 Review cache hit-rate; if < 60 % consider increasing capacity."
499            }
500            (ShmCategory::Network, ShmStatus::Critical) => {
501                "Check peer connectivity and firewall rules. \
502                 Verify TLS certificate validity. \
503                 Restart the libp2p swarm if persistent connection failures."
504            }
505            (ShmCategory::Network, ShmStatus::Degraded) => {
506                "Inspect bandwidth utilisation. \
507                 Check for TCP retransmissions and increase socket buffer sizes if needed."
508            }
509            (ShmCategory::Encryption, ShmStatus::Critical) => {
510                "Rotate encryption keys immediately. \
511                 Verify KMS availability. \
512                 Do not serve requests until the encryption subsystem is healthy."
513            }
514            (ShmCategory::Encryption, ShmStatus::Degraded) => {
515                "Check key-derivation latency. \
516                 Ensure HSM or KMS response times are acceptable."
517            }
518            (ShmCategory::Compression, ShmStatus::Critical) => {
519                "Disable compression and serve raw blocks temporarily. \
520                 Inspect codec state; a corrupted dictionary may require rebuild."
521            }
522            (ShmCategory::Compression, ShmStatus::Degraded) => {
523                "Lower compression level to reduce CPU pressure. \
524                 Consider switching to a faster codec (LZ4 instead of Zstd)."
525            }
526            (_, ShmStatus::Unknown) => {
527                "No check data yet — register and execute at least one probe check \
528                 before interpreting status."
529            }
530            (_, ShmStatus::Recovering) => {
531                "Probe is recovering; monitor consecutive successes. \
532                 Do not re-introduce heavy load until status reaches Healthy."
533            }
534            (_, ShmStatus::Healthy) => "No action required — probe is healthy.",
535        };
536
537        Ok(format!(
538            "[probe={} category={} status={:?}] {}",
539            probe.name,
540            probe.category.label(),
541            probe.status,
542            suggestion
543        ))
544    }
545
546    // ── statistics ────────────────────────────────────────────────────────
547
548    /// Return aggregate statistics for the monitor.
549    pub fn monitor_stats(&self) -> ShmMonitorStats {
550        let mut stats = ShmMonitorStats {
551            total_probes: self.probes.len(),
552            total_alerts_fired: self.total_alerts_fired,
553            active_alerts: self.active_alerts().len(),
554            snapshots_stored: self.history.len(),
555            total_checks_recorded: self.probes.values().map(|p| p.total_checks).sum(),
556            ..Default::default()
557        };
558        for probe in self.probes.values() {
559            match probe.status {
560                ShmStatus::Healthy => stats.healthy_count += 1,
561                ShmStatus::Degraded => stats.degraded_count += 1,
562                ShmStatus::Critical => stats.critical_count += 1,
563                ShmStatus::Unknown => stats.unknown_count += 1,
564                ShmStatus::Recovering => stats.recovering_count += 1,
565            }
566        }
567        stats
568    }
569
570    /// Return the current configuration.
571    pub fn config(&self) -> &ShmMonitorConfig {
572        &self.config
573    }
574
575    /// Update the configuration at runtime.
576    pub fn set_config(&mut self, config: ShmMonitorConfig) {
577        self.config = config;
578    }
579
580    /// Return the number of registered probes.
581    pub fn probe_count(&self) -> usize {
582        self.probes.len()
583    }
584
585    /// Return the number of stored history snapshots.
586    pub fn history_len(&self) -> usize {
587        self.history.len()
588    }
589
590    // ── internal helpers ──────────────────────────────────────────────────
591
592    /// A simple monotonically-advancing timestamp based on xorshift64.
593    /// In production callers should pass real wall-clock timestamps;
594    /// this is used as a fallback / for internal state seeding.
595    fn now_ts(&mut self) -> u64 {
596        // Use xorshift just to advance state — real time via std
597        let _ = xorshift64(&mut self.rng_state);
598        std::time::SystemTime::now()
599            .duration_since(std::time::UNIX_EPOCH)
600            .map(|d| d.as_secs())
601            .unwrap_or(0)
602    }
603
604    /// Compute probe status from current EWMA and consecutive failures.
605    fn compute_probe_status(
606        &self,
607        probe_id: ShmProbeId,
608        config: &ShmMonitorConfig,
609    ) -> Result<ShmStatus, String> {
610        let probe = self
611            .probes
612            .get(&probe_id)
613            .ok_or_else(|| format!("probe {} not found", probe_id))?;
614
615        let status = if probe.consecutive_failures >= config.max_consecutive_failures {
616            ShmStatus::Critical
617        } else if probe.success_rate < config.alert_threshold {
618            // Distinguish degraded vs critical based on how far below threshold
619            let ratio = probe.success_rate / config.alert_threshold;
620            if ratio < 0.5 {
621                ShmStatus::Critical
622            } else {
623                ShmStatus::Degraded
624            }
625        } else if probe.success_rate >= config.recovery_threshold
626            && matches!(probe.status, ShmStatus::Degraded | ShmStatus::Critical)
627        {
628            // Rate is recovering but was previously unhealthy — stay in Recovering
629            ShmStatus::Recovering
630        } else if probe.success_rate >= config.recovery_threshold
631            && probe.status == ShmStatus::Recovering
632            && probe.consecutive_failures == 0
633        {
634            // Sustained recovery with no recent failures → promote to Healthy
635            ShmStatus::Healthy
636        } else if probe.status == ShmStatus::Unknown {
637            if probe.success_rate >= config.recovery_threshold {
638                ShmStatus::Healthy
639            } else {
640                ShmStatus::Unknown
641            }
642        } else {
643            // Stays healthy if currently healthy and rate is fine
644            probe.status
645        };
646
647        Ok(status)
648    }
649
650    /// Compute (score, overall_status) across all probes.
651    fn compute_overall_score(&self) -> (f64, ShmStatus) {
652        if self.probes.is_empty() {
653            return (1.0, ShmStatus::Healthy);
654        }
655
656        let total: f64 = self.probes.values().map(|p| p.success_rate).sum();
657        let score = (total / self.probes.len() as f64).clamp(0.0, 1.0);
658
659        // Overall status is driven by the worst probe
660        let worst = self.probes.values().fold(ShmStatus::Healthy, |acc, p| {
661            if p.status.weight() > acc.weight() {
662                p.status
663            } else {
664                acc
665            }
666        });
667
668        (score, worst)
669    }
670
671    /// Fire an alert if the probe status changed to a noteworthy state.
672    fn maybe_fire_alert(
673        &mut self,
674        probe_id: ShmProbeId,
675        old_status: ShmStatus,
676        new_status: ShmStatus,
677        now_ts: u64,
678        config: &ShmMonitorConfig,
679    ) {
680        if old_status == new_status {
681            return;
682        }
683
684        let severity = match new_status {
685            ShmStatus::Healthy => ShmSeverity::Info,
686            ShmStatus::Recovering => ShmSeverity::Info,
687            ShmStatus::Degraded => ShmSeverity::Warning,
688            ShmStatus::Critical => ShmSeverity::Critical,
689            ShmStatus::Unknown => return, // not worth alerting on unknown
690        };
691
692        let probe_name = match self.probes.get(&probe_id) {
693            Some(p) => p.name.clone(),
694            None => format!("probe-{}", probe_id),
695        };
696
697        let message = format!(
698            "probe '{}' transitioned {:?} → {:?}",
699            probe_name, old_status, new_status
700        );
701
702        let auto_resolve_at = if config.alert_auto_resolve_secs > 0 {
703            Some(now_ts.saturating_add(config.alert_auto_resolve_secs))
704        } else {
705            None
706        };
707
708        let alert_id = self.next_alert_id;
709        self.next_alert_id += 1;
710        self.total_alerts_fired += 1;
711
712        let alert = ShmAlert {
713            id: alert_id,
714            ts: now_ts,
715            probe_id,
716            severity,
717            message,
718            auto_resolve_at,
719            resolved: false,
720        };
721
722        if self.alerts.len() >= MAX_ALERTS {
723            self.alerts.pop_front();
724        }
725        self.alerts.push_back(alert);
726    }
727}
728
729// ─── tests ───────────────────────────────────────────────────────────────────
730
731#[cfg(test)]
732mod tests {
733    use super::*;
734
735    fn make_monitor() -> StorageHealthMonitor {
736        StorageHealthMonitor::new(ShmMonitorConfig {
737            check_interval_secs: 10,
738            alert_threshold: 0.80,
739            recovery_threshold: 0.90,
740            max_consecutive_failures: 3,
741            ewma_alpha: 0.5, // aggressive smoothing for fast tests
742            ewma_latency_alpha: 0.5,
743            alert_auto_resolve_secs: 600,
744        })
745    }
746
747    // ── basic registration ────────────────────────────────────────────────
748
749    #[test]
750    fn test_register_probe_returns_unique_ids() {
751        let mut m = make_monitor();
752        let a = m.register_probe("a", ShmCategory::BlockStore, Default::default());
753        let b = m.register_probe("b", ShmCategory::Cache, Default::default());
754        assert_ne!(a, b);
755    }
756
757    #[test]
758    fn test_probe_count_after_registration() {
759        let mut m = make_monitor();
760        assert_eq!(m.probe_count(), 0);
761        m.register_probe("x", ShmCategory::WalLog, Default::default());
762        assert_eq!(m.probe_count(), 1);
763        m.register_probe("y", ShmCategory::Network, Default::default());
764        assert_eq!(m.probe_count(), 2);
765    }
766
767    #[test]
768    fn test_registered_probe_initial_status_unknown() {
769        let mut m = make_monitor();
770        let id = m.register_probe("z", ShmCategory::Encryption, Default::default());
771        assert_eq!(m.probe(id).map(|p| p.status), Some(ShmStatus::Unknown));
772    }
773
774    #[test]
775    fn test_registered_probe_name_stored() {
776        let mut m = make_monitor();
777        let id = m.register_probe("my-probe", ShmCategory::Cache, Default::default());
778        assert_eq!(m.probe(id).map(|p| p.name.as_str()), Some("my-probe"));
779    }
780
781    #[test]
782    fn test_registered_probe_category_stored() {
783        let mut m = make_monitor();
784        let id = m.register_probe("c", ShmCategory::Compression, Default::default());
785        assert_eq!(
786            m.probe(id).map(|p| p.category),
787            Some(ShmCategory::Compression)
788        );
789    }
790
791    #[test]
792    fn test_metadata_stored_on_probe() {
793        let mut m = make_monitor();
794        let mut meta = HashMap::new();
795        meta.insert("host".to_string(), "node-1".to_string());
796        let id = m.register_probe("p", ShmCategory::Network, meta);
797        let probe = m.probe(id).expect("probe exists");
798        assert_eq!(probe.metadata.get("host"), Some(&"node-1".to_string()));
799    }
800
801    #[test]
802    fn test_probe_ids_increment() {
803        let mut m = make_monitor();
804        let ids: Vec<_> = (0..5)
805            .map(|i| m.register_probe(format!("p{}", i), ShmCategory::Cache, Default::default()))
806            .collect();
807        for w in ids.windows(2) {
808            assert!(w[1] > w[0]);
809        }
810    }
811
812    // ── record_check basic ────────────────────────────────────────────────
813
814    #[test]
815    fn test_record_check_unknown_probe_returns_error() {
816        let mut m = make_monitor();
817        assert!(m.record_check(999, true, 1.0).is_err());
818    }
819
820    #[test]
821    fn test_record_check_success_updates_total_checks() {
822        let mut m = make_monitor();
823        let id = m.register_probe("p", ShmCategory::BlockStore, Default::default());
824        m.record_check(id, true, 2.0).unwrap();
825        assert_eq!(m.probe(id).unwrap().total_checks, 1);
826        assert_eq!(m.probe(id).unwrap().total_successes, 1);
827    }
828
829    #[test]
830    fn test_record_check_failure_increments_consecutive_failures() {
831        let mut m = make_monitor();
832        let id = m.register_probe("p", ShmCategory::BlockStore, Default::default());
833        m.record_check(id, false, 0.0).unwrap();
834        assert_eq!(m.probe(id).unwrap().consecutive_failures, 1);
835    }
836
837    #[test]
838    fn test_record_check_success_resets_consecutive_failures() {
839        let mut m = make_monitor();
840        let id = m.register_probe("p", ShmCategory::BlockStore, Default::default());
841        m.record_check(id, false, 0.0).unwrap();
842        m.record_check(id, false, 0.0).unwrap();
843        m.record_check(id, true, 1.0).unwrap();
844        assert_eq!(m.probe(id).unwrap().consecutive_failures, 0);
845    }
846
847    #[test]
848    fn test_success_rate_ewma_update() {
849        let mut m = make_monitor();
850        let id = m.register_probe("p", ShmCategory::Cache, Default::default());
851        // Initial success_rate = 1.0, alpha = 0.5
852        m.record_check(id, false, 0.0).unwrap(); // outcome 0 → new = 0.5*0 + 0.5*1.0 = 0.5
853        let rate = m.probe(id).unwrap().success_rate;
854        assert!((rate - 0.5).abs() < 1e-9, "expected 0.5, got {}", rate);
855    }
856
857    #[test]
858    fn test_latency_ewma_update() {
859        let mut m = make_monitor();
860        let id = m.register_probe("p", ShmCategory::WalLog, Default::default());
861        m.record_check(id, true, 10.0).unwrap(); // initial = 10.0
862        m.record_check(id, true, 20.0).unwrap(); // 0.5*20 + 0.5*10 = 15.0
863        let lat = m.probe(id).unwrap().latency_ms;
864        assert!((lat - 15.0).abs() < 1e-9, "expected 15.0, got {}", lat);
865    }
866
867    #[test]
868    fn test_failure_does_not_update_latency() {
869        let mut m = make_monitor();
870        let id = m.register_probe("p", ShmCategory::WalLog, Default::default());
871        m.record_check(id, true, 5.0).unwrap();
872        let lat_before = m.probe(id).unwrap().latency_ms;
873        m.record_check(id, false, 999.0).unwrap();
874        let lat_after = m.probe(id).unwrap().latency_ms;
875        assert!((lat_before - lat_after).abs() < 1e-9);
876    }
877
878    // ── status transitions ────────────────────────────────────────────────
879
880    #[test]
881    fn test_probe_becomes_critical_after_max_consecutive_failures() {
882        let mut m = make_monitor();
883        let id = m.register_probe("p", ShmCategory::BlockStore, Default::default());
884        for _ in 0..3 {
885            m.record_check(id, false, 0.0).unwrap();
886        }
887        assert_eq!(m.probe(id).unwrap().status, ShmStatus::Critical);
888    }
889
890    #[test]
891    fn test_probe_becomes_healthy_after_successes() {
892        let mut m = make_monitor();
893        let id = m.register_probe("p", ShmCategory::Cache, Default::default());
894        // Drive up success rate quickly with alpha=0.5
895        for _ in 0..10 {
896            m.record_check(id, true, 1.0).unwrap();
897        }
898        assert_eq!(m.probe(id).unwrap().status, ShmStatus::Healthy);
899    }
900
901    #[test]
902    fn test_probe_status_unknown_initially() {
903        let mut m = make_monitor();
904        let id = m.register_probe("p", ShmCategory::IndexStore, Default::default());
905        assert_eq!(m.probe(id).unwrap().status, ShmStatus::Unknown);
906    }
907
908    #[test]
909    fn test_probe_degraded_on_low_success_rate() {
910        let mut m = make_monitor();
911        let id = m.register_probe("p", ShmCategory::Network, Default::default());
912        // With alpha=0.5 and starting at 1.0:
913        // after 5 failures: 1→0.5→0.25→0.125→0.0625→0.03125 → all below 0.8 threshold
914        // but consecutive_failures also triggers — let's use a separate config
915        let mut m2 = StorageHealthMonitor::new(ShmMonitorConfig {
916            ewma_alpha: 0.5,
917            max_consecutive_failures: 100, // prevent critical via consec
918            alert_threshold: 0.80,
919            recovery_threshold: 0.90,
920            ..Default::default()
921        });
922        let id2 = m2.register_probe("p2", ShmCategory::Network, Default::default());
923        m2.record_check(id2, false, 0.0).unwrap(); // 0.5
924        m2.record_check(id2, false, 0.0).unwrap(); // 0.25
925                                                   // success_rate = 0.25 < 0.80 → degraded (ratio 0.25/0.80 = 0.3125 < 0.5 → critical actually)
926                                                   // Let's just verify it's not healthy
927        assert_ne!(m2.probe(id2).unwrap().status, ShmStatus::Healthy);
928        let _ = id;
929    }
930
931    // ── overall score ─────────────────────────────────────────────────────
932
933    #[test]
934    fn test_overall_score_empty_monitor_is_one() {
935        let mut m = make_monitor();
936        let snap = m.run_health_check();
937        assert!((snap.score - 1.0).abs() < 1e-9);
938    }
939
940    #[test]
941    fn test_overall_score_all_healthy_approaches_one() {
942        let mut m = make_monitor();
943        let id = m.register_probe("p", ShmCategory::Cache, Default::default());
944        for _ in 0..20 {
945            m.record_check(id, true, 1.0).unwrap();
946        }
947        let snap = m.run_health_check();
948        assert!(
949            snap.score > 0.95,
950            "score should be near 1.0, got {}",
951            snap.score
952        );
953    }
954
955    #[test]
956    fn test_overall_score_all_failures_drops() {
957        let mut m = make_monitor();
958        let id = m.register_probe("p", ShmCategory::BlockStore, Default::default());
959        for _ in 0..10 {
960            m.record_check(id, false, 0.0).unwrap();
961        }
962        let snap = m.run_health_check();
963        assert!(snap.score < 0.5, "score should be low, got {}", snap.score);
964    }
965
966    #[test]
967    fn test_overall_score_in_range() {
968        let mut m = make_monitor();
969        let id = m.register_probe("p", ShmCategory::Cache, Default::default());
970        m.record_check(id, true, 1.0).unwrap();
971        m.record_check(id, false, 0.0).unwrap();
972        let snap = m.run_health_check();
973        assert!(snap.score >= 0.0 && snap.score <= 1.0);
974    }
975
976    #[test]
977    fn test_snapshot_probe_count_matches() {
978        let mut m = make_monitor();
979        m.register_probe("a", ShmCategory::Cache, Default::default());
980        m.register_probe("b", ShmCategory::WalLog, Default::default());
981        let snap = m.run_health_check();
982        assert_eq!(snap.probe_count, 2);
983    }
984
985    // ── history ───────────────────────────────────────────────────────────
986
987    #[test]
988    fn test_history_grows_with_snapshots() {
989        let mut m = make_monitor();
990        for _ in 0..5 {
991            m.run_health_check();
992        }
993        assert_eq!(m.history_len(), 5);
994    }
995
996    #[test]
997    fn test_history_bounded_at_200() {
998        let mut m = make_monitor();
999        for _ in 0..250 {
1000            m.run_health_check();
1001        }
1002        assert_eq!(m.history_len(), 200);
1003    }
1004
1005    #[test]
1006    fn test_health_trend_returns_correct_window() {
1007        let mut m = make_monitor();
1008        for _ in 0..10 {
1009            m.run_health_check();
1010        }
1011        let trend = m.health_trend(5);
1012        assert_eq!(trend.len(), 5);
1013    }
1014
1015    #[test]
1016    fn test_health_trend_returns_all_if_window_larger_than_history() {
1017        let mut m = make_monitor();
1018        for _ in 0..3 {
1019            m.run_health_check();
1020        }
1021        let trend = m.health_trend(100);
1022        assert_eq!(trend.len(), 3);
1023    }
1024
1025    #[test]
1026    fn test_health_trend_scores_in_range() {
1027        let mut m = make_monitor();
1028        let id = m.register_probe("p", ShmCategory::Cache, Default::default());
1029        m.record_check(id, true, 1.0).unwrap();
1030        m.run_health_check();
1031        let trend = m.health_trend(1);
1032        for s in &trend {
1033            assert!(*s >= 0.0 && *s <= 1.0, "score out of range: {}", s);
1034        }
1035    }
1036
1037    // ── alerts ────────────────────────────────────────────────────────────
1038
1039    #[test]
1040    fn test_alert_fired_on_critical_transition() {
1041        let mut m = make_monitor();
1042        let id = m.register_probe("p", ShmCategory::BlockStore, Default::default());
1043        for _ in 0..3 {
1044            m.record_check(id, false, 0.0).unwrap();
1045        }
1046        let active = m.active_alerts();
1047        assert!(!active.is_empty(), "expected at least one active alert");
1048    }
1049
1050    #[test]
1051    fn test_alert_severity_critical_on_critical_status() {
1052        let mut m = make_monitor();
1053        let id = m.register_probe("p", ShmCategory::WalLog, Default::default());
1054        for _ in 0..3 {
1055            m.record_check(id, false, 0.0).unwrap();
1056        }
1057        let critical = m
1058            .active_alerts()
1059            .iter()
1060            .any(|a| a.severity == ShmSeverity::Critical);
1061        assert!(critical, "expected a Critical alert");
1062    }
1063
1064    #[test]
1065    fn test_resolve_alert_marks_resolved() {
1066        let mut m = make_monitor();
1067        let id = m.register_probe("p", ShmCategory::Cache, Default::default());
1068        for _ in 0..3 {
1069            m.record_check(id, false, 0.0).unwrap();
1070        }
1071        let alert_id = m
1072            .active_alerts()
1073            .first()
1074            .map(|a| a.id)
1075            .expect("alert exists");
1076        m.resolve_alert(alert_id).unwrap();
1077        assert!(m
1078            .all_alerts()
1079            .iter()
1080            .find(|a| a.id == alert_id)
1081            .map(|a| a.resolved)
1082            .unwrap_or(false));
1083    }
1084
1085    #[test]
1086    fn test_resolve_nonexistent_alert_returns_error() {
1087        let mut m = make_monitor();
1088        assert!(m.resolve_alert(9999).is_err());
1089    }
1090
1091    #[test]
1092    fn test_expire_alerts_resolves_timed_out() {
1093        let mut m = StorageHealthMonitor::new(ShmMonitorConfig {
1094            alert_auto_resolve_secs: 100,
1095            max_consecutive_failures: 3,
1096            ewma_alpha: 0.5,
1097            ..Default::default()
1098        });
1099        let id = m.register_probe("p", ShmCategory::BlockStore, Default::default());
1100        let base_ts: u64 = 1_000_000;
1101        for _ in 0..3 {
1102            m.record_check_at(id, false, 0.0, base_ts).unwrap();
1103        }
1104        // expire at base_ts + 200 (> auto_resolve_secs=100)
1105        m.expire_alerts(base_ts + 200);
1106        let active = m.active_alerts();
1107        assert!(active.is_empty(), "all alerts should have expired");
1108    }
1109
1110    #[test]
1111    fn test_alerts_bounded_at_500() {
1112        let mut m = StorageHealthMonitor::new(ShmMonitorConfig {
1113            max_consecutive_failures: 1,
1114            ewma_alpha: 1.0,      // instant update
1115            alert_threshold: 1.1, // never degrade (won't fire degraded)
1116            ..Default::default()
1117        });
1118        let id = m.register_probe("p", ShmCategory::Network, Default::default());
1119        // Fire critical → healthy transitions to generate many alerts
1120        for i in 0..600_u64 {
1121            let success = i % 2 == 0;
1122            m.record_check_at(id, success, 1.0, i).unwrap();
1123        }
1124        assert!(m.all_alerts().len() <= 500);
1125    }
1126
1127    #[test]
1128    fn test_alert_contains_probe_id() {
1129        let mut m = make_monitor();
1130        let id = m.register_probe("p", ShmCategory::Encryption, Default::default());
1131        for _ in 0..3 {
1132            m.record_check(id, false, 0.0).unwrap();
1133        }
1134        let alerts_for_probe: Vec<_> = m
1135            .active_alerts()
1136            .into_iter()
1137            .filter(|a| a.probe_id == id)
1138            .collect();
1139        assert!(!alerts_for_probe.is_empty());
1140    }
1141
1142    #[test]
1143    fn test_alert_auto_resolve_at_set() {
1144        let mut m = StorageHealthMonitor::new(ShmMonitorConfig {
1145            alert_auto_resolve_secs: 300,
1146            max_consecutive_failures: 1,
1147            ewma_alpha: 1.0,
1148            ..Default::default()
1149        });
1150        let id = m.register_probe("p", ShmCategory::Cache, Default::default());
1151        m.record_check_at(id, false, 0.0, 1000).unwrap();
1152        // At least one alert should have auto_resolve_at set
1153        let has_auto = m.all_alerts().iter().any(|a| a.auto_resolve_at.is_some());
1154        assert!(has_auto);
1155    }
1156
1157    // ── probes_by_category ────────────────────────────────────────────────
1158
1159    #[test]
1160    fn test_probes_by_category_filter() {
1161        let mut m = make_monitor();
1162        m.register_probe("a", ShmCategory::Cache, Default::default());
1163        m.register_probe("b", ShmCategory::Cache, Default::default());
1164        m.register_probe("c", ShmCategory::WalLog, Default::default());
1165        let cache_probes = m.probes_by_category(ShmCategory::Cache);
1166        assert_eq!(cache_probes.len(), 2);
1167    }
1168
1169    #[test]
1170    fn test_probes_by_category_empty_when_none_registered() {
1171        let m = make_monitor();
1172        assert!(m.probes_by_category(ShmCategory::Encryption).is_empty());
1173    }
1174
1175    #[test]
1176    fn test_probes_by_category_all_categories() {
1177        let mut m = make_monitor();
1178        let cats = [
1179            ShmCategory::BlockStore,
1180            ShmCategory::IndexStore,
1181            ShmCategory::WalLog,
1182            ShmCategory::Cache,
1183            ShmCategory::Network,
1184            ShmCategory::Encryption,
1185            ShmCategory::Compression,
1186        ];
1187        for (i, cat) in cats.iter().enumerate() {
1188            m.register_probe(format!("p{}", i), *cat, Default::default());
1189        }
1190        for cat in &cats {
1191            assert_eq!(m.probes_by_category(*cat).len(), 1);
1192        }
1193    }
1194
1195    // ── recovery suggestions ──────────────────────────────────────────────
1196
1197    #[test]
1198    fn test_suggest_recovery_unknown_probe_returns_error() {
1199        let m = make_monitor();
1200        assert!(m.suggest_recovery(9999).is_err());
1201    }
1202
1203    #[test]
1204    fn test_suggest_recovery_healthy_returns_no_action() {
1205        let mut m = make_monitor();
1206        let id = m.register_probe("p", ShmCategory::Cache, Default::default());
1207        for _ in 0..20 {
1208            m.record_check(id, true, 1.0).unwrap();
1209        }
1210        let suggestion = m.suggest_recovery(id).unwrap();
1211        assert!(
1212            suggestion.contains("No action required")
1213                || suggestion.contains("healthy")
1214                || suggestion.contains("Healthy")
1215        );
1216    }
1217
1218    #[test]
1219    fn test_suggest_recovery_critical_blockstore() {
1220        let mut m = make_monitor();
1221        let id = m.register_probe("bs", ShmCategory::BlockStore, Default::default());
1222        for _ in 0..3 {
1223            m.record_check(id, false, 0.0).unwrap();
1224        }
1225        let s = m.suggest_recovery(id).unwrap();
1226        assert!(
1227            s.contains("block-store")
1228                || s.contains("BlockStore")
1229                || s.contains("integrity")
1230                || s.contains("block")
1231        );
1232    }
1233
1234    #[test]
1235    fn test_suggest_recovery_critical_wallog() {
1236        let mut m = make_monitor();
1237        let id = m.register_probe("wal", ShmCategory::WalLog, Default::default());
1238        for _ in 0..3 {
1239            m.record_check(id, false, 0.0).unwrap();
1240        }
1241        let s = m.suggest_recovery(id).unwrap();
1242        assert!(
1243            s.contains("WAL")
1244                || s.contains("wal")
1245                || s.contains("writes")
1246                || s.contains("checkpoint")
1247        );
1248    }
1249
1250    #[test]
1251    fn test_suggest_recovery_contains_probe_name() {
1252        let mut m = make_monitor();
1253        let id = m.register_probe("my-node", ShmCategory::Network, Default::default());
1254        let s = m.suggest_recovery(id).unwrap();
1255        assert!(s.contains("my-node"));
1256    }
1257
1258    // ── monitor stats ─────────────────────────────────────────────────────
1259
1260    #[test]
1261    fn test_monitor_stats_total_probes() {
1262        let mut m = make_monitor();
1263        m.register_probe("a", ShmCategory::Cache, Default::default());
1264        m.register_probe("b", ShmCategory::WalLog, Default::default());
1265        let stats = m.monitor_stats();
1266        assert_eq!(stats.total_probes, 2);
1267    }
1268
1269    #[test]
1270    fn test_monitor_stats_counts_by_status() {
1271        let mut m = make_monitor();
1272        let a = m.register_probe("a", ShmCategory::Cache, Default::default());
1273        let _b = m.register_probe("b", ShmCategory::WalLog, Default::default());
1274        // make a healthy
1275        for _ in 0..10 {
1276            m.record_check(a, true, 1.0).unwrap();
1277        }
1278        let stats = m.monitor_stats();
1279        assert!(stats.healthy_count >= 1);
1280    }
1281
1282    #[test]
1283    fn test_monitor_stats_total_checks_recorded() {
1284        let mut m = make_monitor();
1285        let id = m.register_probe("p", ShmCategory::BlockStore, Default::default());
1286        for _ in 0..7 {
1287            m.record_check(id, true, 1.0).unwrap();
1288        }
1289        let stats = m.monitor_stats();
1290        assert_eq!(stats.total_checks_recorded, 7);
1291    }
1292
1293    #[test]
1294    fn test_monitor_stats_active_alerts() {
1295        let mut m = make_monitor();
1296        let id = m.register_probe("p", ShmCategory::BlockStore, Default::default());
1297        for _ in 0..3 {
1298            m.record_check(id, false, 0.0).unwrap();
1299        }
1300        let stats = m.monitor_stats();
1301        assert!(stats.active_alerts > 0);
1302    }
1303
1304    #[test]
1305    fn test_monitor_stats_snapshots_stored() {
1306        let mut m = make_monitor();
1307        m.run_health_check();
1308        m.run_health_check();
1309        let stats = m.monitor_stats();
1310        assert_eq!(stats.snapshots_stored, 2);
1311    }
1312
1313    // ── config ────────────────────────────────────────────────────────────
1314
1315    #[test]
1316    fn test_set_config_updates_config() {
1317        let mut m = make_monitor();
1318        let new_cfg = ShmMonitorConfig {
1319            check_interval_secs: 60,
1320            ..Default::default()
1321        };
1322        m.set_config(new_cfg);
1323        assert_eq!(m.config().check_interval_secs, 60);
1324    }
1325
1326    #[test]
1327    fn test_default_config_sensible_values() {
1328        let cfg = ShmMonitorConfig::default();
1329        assert!(cfg.alert_threshold > 0.0 && cfg.alert_threshold < 1.0);
1330        assert!(cfg.recovery_threshold > cfg.alert_threshold);
1331        assert!(cfg.ewma_alpha > 0.0 && cfg.ewma_alpha <= 1.0);
1332    }
1333
1334    // ── category labels ───────────────────────────────────────────────────
1335
1336    #[test]
1337    fn test_category_labels_non_empty() {
1338        let cats = [
1339            ShmCategory::BlockStore,
1340            ShmCategory::IndexStore,
1341            ShmCategory::WalLog,
1342            ShmCategory::Cache,
1343            ShmCategory::Network,
1344            ShmCategory::Encryption,
1345            ShmCategory::Compression,
1346        ];
1347        for cat in cats {
1348            assert!(!cat.label().is_empty());
1349        }
1350    }
1351
1352    // ── status weight ordering ────────────────────────────────────────────
1353
1354    #[test]
1355    fn test_status_weights_ordered() {
1356        assert!(ShmStatus::Healthy.weight() < ShmStatus::Degraded.weight());
1357        assert!(ShmStatus::Degraded.weight() < ShmStatus::Critical.weight());
1358    }
1359
1360    // ── xorshift64 ────────────────────────────────────────────────────────
1361
1362    #[test]
1363    fn test_xorshift64_produces_different_values() {
1364        let mut state = 12345_u64;
1365        let a = xorshift64(&mut state);
1366        let b = xorshift64(&mut state);
1367        assert_ne!(a, b);
1368    }
1369
1370    #[test]
1371    fn test_xorshift64_state_changes() {
1372        let mut state = 99_u64;
1373        let before = state;
1374        xorshift64(&mut state);
1375        assert_ne!(state, before);
1376    }
1377
1378    // ── overall health status ─────────────────────────────────────────────
1379
1380    #[test]
1381    fn test_overall_status_healthy_when_all_probes_healthy() {
1382        let mut m = make_monitor();
1383        let id = m.register_probe("p", ShmCategory::Cache, Default::default());
1384        for _ in 0..20 {
1385            m.record_check(id, true, 1.0).unwrap();
1386        }
1387        let snap = m.run_health_check();
1388        assert_eq!(snap.overall_status, ShmStatus::Healthy);
1389    }
1390
1391    #[test]
1392    fn test_overall_status_critical_when_any_critical() {
1393        let mut m = make_monitor();
1394        let id = m.register_probe("p", ShmCategory::BlockStore, Default::default());
1395        for _ in 0..3 {
1396            m.record_check(id, false, 0.0).unwrap();
1397        }
1398        let snap = m.run_health_check();
1399        assert_eq!(snap.overall_status, ShmStatus::Critical);
1400    }
1401
1402    #[test]
1403    fn test_snapshot_probe_statuses_populated() {
1404        let mut m = make_monitor();
1405        let a = m.register_probe("a", ShmCategory::Cache, Default::default());
1406        let b = m.register_probe("b", ShmCategory::WalLog, Default::default());
1407        let snap = m.run_health_check();
1408        assert!(snap.probe_statuses.contains_key(&a));
1409        assert!(snap.probe_statuses.contains_key(&b));
1410    }
1411
1412    // ── multi-probe interaction ────────────────────────────────────────────
1413
1414    #[test]
1415    fn test_one_critical_probe_does_not_affect_others() {
1416        let mut m = make_monitor();
1417        let good = m.register_probe("good", ShmCategory::Cache, Default::default());
1418        let bad = m.register_probe("bad", ShmCategory::BlockStore, Default::default());
1419        for _ in 0..20 {
1420            m.record_check(good, true, 1.0).unwrap();
1421        }
1422        for _ in 0..3 {
1423            m.record_check(bad, false, 0.0).unwrap();
1424        }
1425        let snap = m.run_health_check();
1426        assert_eq!(snap.probe_statuses[&good], ShmStatus::Healthy);
1427        assert_eq!(snap.probe_statuses[&bad], ShmStatus::Critical);
1428    }
1429
1430    #[test]
1431    fn test_total_alerts_fired_increments() {
1432        let mut m = make_monitor();
1433        let id = m.register_probe("p", ShmCategory::Encryption, Default::default());
1434        for _ in 0..3 {
1435            m.record_check(id, false, 0.0).unwrap();
1436        }
1437        let stats = m.monitor_stats();
1438        assert!(stats.total_alerts_fired > 0);
1439    }
1440
1441    #[test]
1442    fn test_history_window_returns_subset() {
1443        let mut m = make_monitor();
1444        for _ in 0..10 {
1445            m.run_health_check();
1446        }
1447        let window = m.history_window(3);
1448        assert_eq!(window.len(), 3);
1449    }
1450
1451    #[test]
1452    fn test_all_alerts_includes_resolved() {
1453        let mut m = make_monitor();
1454        let id = m.register_probe("p", ShmCategory::Cache, Default::default());
1455        for _ in 0..3 {
1456            m.record_check(id, false, 0.0).unwrap();
1457        }
1458        let alert_id = m.active_alerts().first().map(|a| a.id).unwrap();
1459        m.resolve_alert(alert_id).unwrap();
1460        assert!(!m.all_alerts().is_empty());
1461        assert!(m.all_alerts().iter().any(|a| a.resolved));
1462    }
1463
1464    #[test]
1465    fn test_probe_nonexistent_returns_none() {
1466        let m = make_monitor();
1467        assert!(m.probe(42).is_none());
1468    }
1469
1470    #[test]
1471    fn test_initial_success_rate_is_one() {
1472        let mut m = make_monitor();
1473        let id = m.register_probe("p", ShmCategory::Network, Default::default());
1474        assert!((m.probe(id).unwrap().success_rate - 1.0).abs() < 1e-9);
1475    }
1476
1477    #[test]
1478    fn test_initial_latency_is_zero() {
1479        let mut m = make_monitor();
1480        let id = m.register_probe("p", ShmCategory::Network, Default::default());
1481        assert!((m.probe(id).unwrap().latency_ms).abs() < 1e-9);
1482    }
1483
1484    #[test]
1485    fn test_zero_latency_handled_gracefully() {
1486        let mut m = make_monitor();
1487        let id = m.register_probe("p", ShmCategory::BlockStore, Default::default());
1488        m.record_check(id, true, 0.0).unwrap();
1489        assert!(m.probe(id).unwrap().latency_ms >= 0.0);
1490    }
1491
1492    #[test]
1493    fn test_large_latency_does_not_panic() {
1494        let mut m = make_monitor();
1495        let id = m.register_probe("p", ShmCategory::BlockStore, Default::default());
1496        m.record_check(id, true, f64::MAX / 2.0).unwrap();
1497        // Just shouldn't panic
1498        assert!(m.probe(id).unwrap().latency_ms >= 0.0);
1499    }
1500
1501    #[test]
1502    fn test_many_probes_score_still_in_range() {
1503        let mut m = make_monitor();
1504        for i in 0..50 {
1505            let id = m.register_probe(format!("p{}", i), ShmCategory::Cache, Default::default());
1506            m.record_check(id, i % 3 != 0, 1.0).unwrap();
1507        }
1508        let snap = m.run_health_check();
1509        assert!(snap.score >= 0.0 && snap.score <= 1.0);
1510    }
1511
1512    #[test]
1513    fn test_monitor_default_config_constructor() {
1514        let m = StorageHealthMonitor::default_config();
1515        assert_eq!(m.config().check_interval_secs, 30);
1516    }
1517
1518    #[test]
1519    fn test_severity_ordering() {
1520        assert!(ShmSeverity::Info < ShmSeverity::Warning);
1521        assert!(ShmSeverity::Warning < ShmSeverity::Error);
1522        assert!(ShmSeverity::Error < ShmSeverity::Critical);
1523    }
1524
1525    #[test]
1526    fn test_all_statuses_covered_in_weight() {
1527        let statuses = [
1528            ShmStatus::Healthy,
1529            ShmStatus::Recovering,
1530            ShmStatus::Unknown,
1531            ShmStatus::Degraded,
1532            ShmStatus::Critical,
1533        ];
1534        for s in statuses {
1535            let w = s.weight();
1536            assert!(w >= 0.0);
1537        }
1538    }
1539}