1use std::collections::{HashMap, VecDeque};
24
25#[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
37pub type ShmProbeId = u32;
41
42pub type ShmStorageHealthMonitor = StorageHealthMonitor;
44
45#[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 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#[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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
99pub enum ShmSeverity {
100 Info,
101 Warning,
102 Error,
103 Critical,
104}
105
106#[derive(Debug, Clone)]
110pub struct ShmMonitorConfig {
111 pub check_interval_secs: u64,
113 pub alert_threshold: f64,
115 pub recovery_threshold: f64,
118 pub max_consecutive_failures: u32,
120 pub ewma_alpha: f64,
122 pub ewma_latency_alpha: f64,
124 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#[derive(Debug, Clone)]
146pub struct ShmProbe {
147 pub id: ShmProbeId,
148 pub name: String,
149 pub category: ShmCategory,
150 pub status: ShmStatus,
151 pub last_check_ts: u64,
153 pub consecutive_failures: u32,
154 pub success_rate: f64,
156 pub latency_ms: f64,
158 pub metadata: HashMap<String, String>,
160 pub total_checks: u64,
162 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, latency_ms: 0.0,
182 metadata,
183 total_checks: 0,
184 total_successes: 0,
185 }
186 }
187}
188
189#[derive(Debug, Clone)]
193pub struct ShmHealthSnapshot {
194 pub ts: u64,
195 pub overall_status: ShmStatus,
196 pub probe_statuses: HashMap<ShmProbeId, ShmStatus>,
197 pub score: f64,
199 pub probe_count: usize,
201}
202
203#[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 pub auto_resolve_at: Option<u64>,
213 pub resolved: bool,
214}
215
216#[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
233pub struct StorageHealthMonitor {
241 config: ShmMonitorConfig,
242 probes: HashMap<ShmProbeId, ShmProbe>,
243 history: VecDeque<ShmHealthSnapshot>,
245 alerts: VecDeque<ShmAlert>,
247 next_probe_id: ShmProbeId,
248 next_alert_id: usize,
249 total_alerts_fired: u64,
250 rng_state: u64,
252}
253
254const MAX_HISTORY: usize = 200;
255const MAX_ALERTS: usize = 500;
256
257impl StorageHealthMonitor {
258 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 pub fn default_config() -> Self {
276 Self::new(ShmMonitorConfig::default())
277 }
278
279 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 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 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 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 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 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 self.maybe_fire_alert(probe_id, old_status, new_status, now_ts, &config);
360
361 Ok(())
362 }
363
364 pub fn run_health_check(&mut self) -> ShmHealthSnapshot {
368 let ts = self.now_ts();
369 self.run_health_check_at(ts)
370 }
371
372 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 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 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 pub fn active_alerts(&self) -> Vec<&ShmAlert> {
421 self.alerts.iter().filter(|a| !a.resolved).collect()
422 }
423
424 pub fn all_alerts(&self) -> Vec<&ShmAlert> {
426 self.alerts.iter().collect()
427 }
428
429 pub fn probes_by_category(&self, cat: ShmCategory) -> Vec<&ShmProbe> {
433 self.probes.values().filter(|p| p.category == cat).collect()
434 }
435
436 pub fn probe(&self, id: ShmProbeId) -> Option<&ShmProbe> {
438 self.probes.get(&id)
439 }
440
441 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 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 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 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 pub fn config(&self) -> &ShmMonitorConfig {
572 &self.config
573 }
574
575 pub fn set_config(&mut self, config: ShmMonitorConfig) {
577 self.config = config;
578 }
579
580 pub fn probe_count(&self) -> usize {
582 self.probes.len()
583 }
584
585 pub fn history_len(&self) -> usize {
587 self.history.len()
588 }
589
590 fn now_ts(&mut self) -> u64 {
596 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 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 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 ShmStatus::Recovering
630 } else if probe.success_rate >= config.recovery_threshold
631 && probe.status == ShmStatus::Recovering
632 && probe.consecutive_failures == 0
633 {
634 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 probe.status
645 };
646
647 Ok(status)
648 }
649
650 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 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 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, };
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#[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, ewma_latency_alpha: 0.5,
743 alert_auto_resolve_secs: 600,
744 })
745 }
746
747 #[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 #[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 m.record_check(id, false, 0.0).unwrap(); 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(); m.record_check(id, true, 20.0).unwrap(); 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 #[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 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 let mut m2 = StorageHealthMonitor::new(ShmMonitorConfig {
916 ewma_alpha: 0.5,
917 max_consecutive_failures: 100, 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(); m2.record_check(id2, false, 0.0).unwrap(); assert_ne!(m2.probe(id2).unwrap().status, ShmStatus::Healthy);
928 let _ = id;
929 }
930
931 #[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 #[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 #[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 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, alert_threshold: 1.1, ..Default::default()
1117 });
1118 let id = m.register_probe("p", ShmCategory::Network, Default::default());
1119 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 let has_auto = m.all_alerts().iter().any(|a| a.auto_resolve_at.is_some());
1154 assert!(has_auto);
1155 }
1156
1157 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 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}