Skip to main content

ghostlink_core/
health.rs

1//! Network Health Monitoring for Ghost-Link Cluster
2//!
3//! This module provides:
4//! - Ping/pong latency tracking per node
5//! - Delivery ratio monitoring
6//! - Automatic quantization fallback triggers
7//! - Fault detection and recovery
8//! - Dynamic reconfiguration on hardware changes via `SystemProfileWatcher`.
9
10use std::collections::{HashMap, VecDeque};
11use std::net::{SocketAddr, TcpStream};
12use std::sync::{Arc, Mutex};
13use std::time::{Duration, Instant};
14
15use crate::cluster::{ClusterState, NodeStatus};
16use crate::host::{AccelerationMode, RuntimeProfile};
17use crate::system_profile::SystemProfile;
18use crate::watcher::ProfileChange;
19
20/// Health check configuration
21#[derive(Clone, Copy, Debug)]
22pub struct HealthConfig {
23    /// Interval between health checks
24    pub check_interval: Duration,
25    /// Timeout for health check response
26    pub timeout: Duration,
27    /// Minimum number of successful checks before considering node healthy
28    pub min_successes: usize,
29    /// Maximum allowed failures before marking node degraded
30    pub max_failures: usize,
31    /// Upper latency bound for a healthy node.
32    pub healthy_latency_us: f32,
33    /// Upper latency bound for a degraded-but-usable node.
34    pub degraded_latency_us: f32,
35    /// Minimum delivery ratio for a healthy node.
36    pub healthy_delivery_ratio: f32,
37    /// Minimum delivery ratio for a degraded node.
38    pub degraded_delivery_ratio: f32,
39}
40
41impl Default for HealthConfig {
42    fn default() -> Self {
43        Self {
44            check_interval: Duration::from_secs(5),
45            timeout: Duration::from_secs(3),
46            min_successes: 2,
47            max_failures: 3,
48            healthy_latency_us: 10.0,
49            degraded_latency_us: 20.0,
50            healthy_delivery_ratio: 0.95,
51            degraded_delivery_ratio: 0.80,
52        }
53    }
54}
55
56impl HealthConfig {
57    /// Tune health thresholds from the detected runtime profile.
58    pub fn autotuned(profile: &RuntimeProfile) -> Self {
59        let (healthy_latency_us, degraded_latency_us, timeout, max_failures) =
60            match profile.acceleration_mode {
61                AccelerationMode::Gpu => (6.0, 14.0, Duration::from_secs(2), 4),
62                AccelerationMode::Avx512 => (8.0, 18.0, Duration::from_secs(2), 3),
63                _ => (10.0, 20.0, Duration::from_secs(3), 3),
64            };
65
66        Self {
67            check_interval: Duration::from_secs(5),
68            timeout,
69            min_successes: if profile.recommended_workers >= 8 {
70                3
71            } else {
72                2
73            },
74            max_failures,
75            healthy_latency_us,
76            degraded_latency_us,
77            healthy_delivery_ratio: if profile.node_resources.vram_gb > 0.0 {
78                0.97
79            } else {
80                0.95
81            },
82            degraded_delivery_ratio: if profile.node_resources.vram_gb > 0.0 {
83                0.88
84            } else {
85                0.80
86            },
87        }
88    }
89}
90
91/// Health status for a node
92#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
93pub enum HealthStatus {
94    /// Node is healthy
95    Healthy,
96    /// Node is degraded (performance issues)
97    Degraded,
98    /// Node has failed
99    Failed,
100    /// No health data available yet
101    #[default]
102    Unknown,
103}
104
105/// Health check result
106#[derive(Clone, Debug)]
107pub struct HealthCheckResult {
108    /// Node ID
109    pub node_id: String,
110    /// Latency in microseconds
111    pub latency_us: f32,
112    /// Delivery ratio (0.0 to 1.0)
113    pub delivery_ratio: f32,
114    /// Status
115    pub status: HealthStatus,
116    /// Timestamp of check
117    pub timestamp: Instant,
118}
119
120/// Network health monitor
121#[derive(Clone, Debug)]
122pub struct NetworkHealthMonitor {
123    /// Cluster state
124    cluster: Arc<ClusterState>,
125    /// Configuration (behind Mutex for atomic re-tune from watcher)
126    config: Arc<Mutex<HealthConfig>>,
127    /// Last check timestamp
128    last_check: Arc<Mutex<Option<Instant>>>,
129    /// Recent check results per node
130    recent_checks: Arc<Mutex<HashMap<String, VecDeque<HealthCheckResult>>>>,
131    /// Optional per-node TCP probe targets for active liveness checks.
132    tcp_probe_targets: Arc<Mutex<HashMap<String, SocketAddr>>>,
133}
134
135impl NetworkHealthMonitor {
136    /// Create new health monitor
137    pub fn new(cluster: Arc<ClusterState>, config: HealthConfig) -> Self {
138        Self {
139            cluster,
140            config: Arc::new(Mutex::new(config)),
141            last_check: Arc::new(Mutex::new(None)),
142            recent_checks: Arc::new(Mutex::new(HashMap::new())),
143            tcp_probe_targets: Arc::new(Mutex::new(HashMap::new())),
144        }
145    }
146
147    /// Create a monitor with thresholds derived from runtime auto-detection.
148    pub fn with_runtime_profile(cluster: Arc<ClusterState>, profile: &RuntimeProfile) -> Self {
149        Self::new(cluster, HealthConfig::autotuned(profile))
150    }
151
152    /// Read the current configuration.
153    pub fn config(&self) -> HealthConfig {
154        self.config
155            .lock()
156            .ok()
157            .as_deref()
158            .copied()
159            .unwrap_or_default()
160    }
161
162    /// Register a node-specific TCP probe target.
163    pub fn register_tcp_probe_target(&self, node_id: impl Into<String>, target: SocketAddr) {
164        self.tcp_probe_targets
165            .lock()
166            .unwrap_or_else(|poison| poison.into_inner())
167            .insert(node_id.into(), target);
168    }
169
170    /// Remove a TCP probe target for a node.
171    pub fn clear_tcp_probe_target(&self, node_id: &str) -> bool {
172        self.tcp_probe_targets
173            .lock()
174            .unwrap_or_else(|poison| poison.into_inner())
175            .remove(node_id)
176            .is_some()
177    }
178
179    /// Run health check on all nodes
180    pub fn check_all(&self) {
181        let cfg = self.config();
182        let now = Instant::now();
183        let nodes_snapshot = self.cluster.nodes_snapshot();
184
185        // Phase 1: Collect probe target addresses under a brief lock read
186        let targets: Vec<(String, SocketAddr)> = {
187            let probe_targets = self
188                .tcp_probe_targets
189                .lock()
190                .unwrap_or_else(|poison| poison.into_inner());
191            nodes_snapshot
192                .iter()
193                .filter_map(|node| {
194                    probe_targets
195                        .get(&node.id)
196                        .map(|addr| (node.id.clone(), *addr))
197                })
198                .collect()
199        };
200
201        // Phase 2: Execute active TCP probes OUTSIDE of any state locks
202        let mut probe_results = HashMap::with_capacity(targets.len());
203        for (node_id, addr) in targets {
204            let ok = TcpStream::connect_timeout(&addr, cfg.timeout).is_ok();
205            probe_results.insert(node_id, ok);
206        }
207
208        // Phase 3: Acquire cluster metrics and recent_checks locks ONCE to update state
209        let mut metrics_guard = self
210            .cluster
211            .metrics
212            .lock()
213            .unwrap_or_else(|poison| poison.into_inner());
214
215        let mut checks = self
216            .recent_checks
217            .lock()
218            .unwrap_or_else(|poison| poison.into_inner());
219
220        for node in nodes_snapshot.iter() {
221            let tcp_probe_ok = probe_results.get(&node.id).copied();
222
223            let result = if let Some(m) = metrics_guard.get_mut(&node.id) {
224                let timeout =
225                    now.saturating_duration_since(m.last_heartbeat) >= m.heartbeat_timeout;
226                let latency_us = if m.latency_samples > 0 {
227                    m.avg_latency_us
228                } else {
229                    0.0
230                };
231
232                let delivery_ratio = if m.delivery_ratio_initialized {
233                    m.delivery_ratio
234                } else {
235                    1.0
236                };
237
238                let status =
239                    if tcp_probe_ok == Some(false) || timeout || m.status == NodeStatus::Failed {
240                        HealthStatus::Failed
241                    } else if m.latency_samples == 0 && !m.delivery_ratio_initialized {
242                        HealthStatus::Unknown
243                    } else {
244                        self.get_health_status(latency_us, delivery_ratio)
245                    };
246
247                let measured_latency_us = if tcp_probe_ok == Some(false) {
248                    cfg.timeout.as_secs_f32() * 1_000_000.0
249                } else {
250                    latency_us
251                };
252
253                // Keep cluster node status aligned with health checks directly in place
254                m.status = match status {
255                    HealthStatus::Healthy => NodeStatus::Active,
256                    HealthStatus::Degraded => NodeStatus::Degraded,
257                    HealthStatus::Failed => NodeStatus::Failed,
258                    HealthStatus::Unknown => m.status,
259                };
260
261                HealthCheckResult {
262                    node_id: node.id.clone(),
263                    latency_us: measured_latency_us,
264                    delivery_ratio,
265                    status,
266                    timestamp: now,
267                }
268            } else {
269                HealthCheckResult {
270                    node_id: node.id.clone(),
271                    latency_us: 0.0,
272                    delivery_ratio: 1.0,
273                    status: HealthStatus::Unknown,
274                    timestamp: now,
275                }
276            };
277
278            // Store recent check results in O(1) ring buffer (keep last 10)
279            let node_checks = checks.entry(node.id.clone()).or_default();
280            node_checks.push_back(result);
281            if node_checks.len() > 10 {
282                node_checks.pop_front();
283            }
284        }
285
286        *self
287            .last_check
288            .lock()
289            .unwrap_or_else(|poison| poison.into_inner()) = Some(now);
290    }
291
292    /// Get health status based on metrics.
293    ///
294    /// Degraded requires BOTH metrics to still be within their (looser)
295    /// degraded floor — using OR here previously meant a node was only ever
296    /// classified Failed when *both* metrics were simultaneously catastrophic,
297    /// since either one alone being within its floor was enough to satisfy the
298    /// OR. A node with fine delivery but catastrophic latency (or vice versa)
299    /// could never reach Failed. Either metric crossing its floor should be
300    /// enough to fail the node.
301    fn get_health_status(&self, latency_us: f32, delivery_ratio: f32) -> HealthStatus {
302        let cfg = self.config();
303        if delivery_ratio >= cfg.healthy_delivery_ratio && latency_us <= cfg.healthy_latency_us {
304            HealthStatus::Healthy
305        } else if delivery_ratio >= cfg.degraded_delivery_ratio
306            && latency_us <= cfg.degraded_latency_us
307        {
308            HealthStatus::Degraded
309        } else {
310            HealthStatus::Failed
311        }
312    }
313
314    /// Get health report for a specific node
315    pub fn get_node_health(&self, node_id: &str) -> Option<HealthCheckResult> {
316        let checks = self
317            .recent_checks
318            .lock()
319            .unwrap_or_else(|poison| poison.into_inner());
320        checks
321            .get(node_id)
322            .and_then(|checks| checks.back())
323            .cloned()
324    }
325
326    /// Get health report for all nodes
327    pub fn get_all_health(&self) -> Vec<HealthCheckResult> {
328        let checks = self
329            .recent_checks
330            .lock()
331            .unwrap_or_else(|poison| poison.into_inner());
332        checks
333            .values()
334            .flat_map(|checks| checks.iter().cloned())
335            .collect()
336    }
337
338    /// Get the number of nodes whose latest health sample is healthy.
339    pub fn healthy_node_count(&self) -> usize {
340        let checks = self
341            .recent_checks
342            .lock()
343            .unwrap_or_else(|poison| poison.into_inner());
344        checks
345            .values()
346            .filter(|node_checks| {
347                node_checks
348                    .back()
349                    .map(|c| c.status == HealthStatus::Healthy)
350                    .unwrap_or(false)
351            })
352            .count()
353    }
354
355    /// Check if node needs quantization fallback
356    pub fn needs_quantization_fallback(&self, node_id: &str) -> bool {
357        let cfg = self.config();
358        let checks = self
359            .recent_checks
360            .lock()
361            .unwrap_or_else(|poison| poison.into_inner());
362
363        if let Some(node_checks) = checks.get(node_id) {
364            // Check last 3 results for consistent degradation
365            let recent: Vec<_> = node_checks.iter().rev().take(3).collect();
366
367            if recent.len() < 3 {
368                return false;
369            }
370
371            // Calculate average delivery ratio of last 3 checks
372            let avg_delivery_ratio: f32 =
373                recent.iter().map(|r| r.delivery_ratio).sum::<f32>() / 3.0;
374
375            // Check average latency
376            let avg_latency_us: f32 = recent.iter().map(|r| r.latency_us).sum::<f32>() / 3.0;
377
378            // Need fallback if delivery ratio dropped below threshold or latency increased significantly
379            avg_delivery_ratio < cfg.healthy_delivery_ratio
380                || avg_latency_us > cfg.healthy_latency_us
381        } else {
382            false
383        }
384    }
385
386    /// Get cluster-wide health summary
387    pub fn get_health_summary(&self) -> String {
388        let checks = self
389            .recent_checks
390            .lock()
391            .unwrap_or_else(|poison| poison.into_inner());
392
393        let total_nodes = self.cluster.node_count();
394        let healthy_count = checks
395            .values()
396            .filter(|node_checks| {
397                node_checks
398                    .back()
399                    .map(|c| c.status == HealthStatus::Healthy)
400                    .unwrap_or(false)
401            })
402            .count();
403
404        let degraded_count = checks
405            .values()
406            .filter(|node_checks| {
407                node_checks
408                    .back()
409                    .map(|c| c.status == HealthStatus::Degraded)
410                    .unwrap_or(false)
411            })
412            .count();
413
414        let failed_count = checks
415            .values()
416            .filter(|node_checks| {
417                node_checks
418                    .back()
419                    .map(|c| c.status == HealthStatus::Failed)
420                    .unwrap_or(false)
421            })
422            .count();
423
424        format!(
425            "Network Health Summary\n\
426             =================\n\
427             Total nodes: {total_nodes}\n\
428             Healthy: {healthy_count}\n\
429             Degraded: {degraded_count}\n\
430             Failed: {failed_count}\n"
431        )
432    }
433
434    /// Start periodic health checks in background
435    pub fn start_periodic_checks(&self) {
436        let check_interval = self.config().check_interval;
437        let this = self.clone();
438
439        std::thread::spawn(move || loop {
440            this.check_all();
441            std::thread::sleep(check_interval);
442        });
443    }
444
445    // ------------------------------------------------------------------
446    // SystemProfileWatcher integration
447    // ------------------------------------------------------------------
448
449    /// Re-tune health thresholds from a system profile detected at runtime.
450    ///
451    /// This is the main integration point with `SystemProfileWatcher`: when
452    /// hardware changes (GPU hot-plug, CPU scaling), call this to keep the
453    /// health monitor's expectations aligned with reality.
454    pub fn reconfigure_from_system_profile(&self, profile: &SystemProfile) {
455        let rp: RuntimeProfile = profile.into();
456        if let Ok(mut guard) = self.config.lock() {
457            *guard = HealthConfig::autotuned(&rp);
458        }
459    }
460
461    /// Subscribe to a `SystemProfileWatcher`'s broadcast channel and
462    /// automatically re-tune whenever hardware changes are detected.
463    ///
464    /// Returns a `JoinHandle` that can be cancelled by dropping it.
465    pub fn subscribe_to_watcher(
466        self: &Arc<Self>,
467        watcher: &crate::watcher::SystemProfileWatcher,
468    ) -> tokio::task::JoinHandle<()> {
469        let this = self.clone();
470        let mut rx = watcher.subscribe();
471        tokio::spawn(async move {
472            while let Ok(event) = rx.recv().await {
473                if let ProfileChange::Updated(profile) = event {
474                    this.reconfigure_from_system_profile(&profile);
475                    tracing::info!(
476                        "health monitor re-tuned from profile: {} workers, {:?} accel",
477                        profile.recommended_workers,
478                        profile.acceleration_mode,
479                    );
480                }
481            }
482        })
483    }
484}
485
486/// Health metrics aggregation
487#[derive(Clone, Debug, Default)]
488pub struct HealthMetrics {
489    /// Average latency across cluster
490    pub avg_latency_us: f32,
491    /// Minimum observed latency
492    pub min_latency_us: f32,
493    /// Maximum observed latency
494    pub max_latency_us: f32,
495    /// Average delivery ratio
496    pub avg_delivery_ratio: f32,
497    /// Minimum delivery ratio
498    pub min_delivery_ratio: f32,
499    /// Number of samples
500    pub sample_count: usize,
501}
502
503impl HealthMetrics {
504    /// Create new metrics aggregator
505    pub fn new() -> Self {
506        Self::default()
507    }
508
509    /// Record a health check result
510    pub fn record(&mut self, latency_us: f32, delivery_ratio: f32) {
511        if self.sample_count == 0 {
512            self.min_latency_us = latency_us;
513            self.max_latency_us = latency_us;
514            self.min_delivery_ratio = delivery_ratio;
515        } else {
516            if latency_us < self.min_latency_us {
517                self.min_latency_us = latency_us;
518            }
519            if latency_us > self.max_latency_us {
520                self.max_latency_us = latency_us;
521            }
522            if delivery_ratio < self.min_delivery_ratio {
523                self.min_delivery_ratio = delivery_ratio;
524            }
525        }
526
527        // Running mean
528        let n = self.sample_count as f32;
529        self.avg_latency_us = (self.avg_latency_us * n + latency_us) / (n + 1.0);
530        self.avg_delivery_ratio = (self.avg_delivery_ratio * n + delivery_ratio) / (n + 1.0);
531
532        self.sample_count += 1;
533    }
534
535    /// Get health status recommendation
536    pub fn get_recommendation(&self) -> String {
537        if self.sample_count == 0 {
538            return "No data available".to_string();
539        }
540
541        match (self.avg_delivery_ratio, self.avg_latency_us) {
542            (ratio, latency) if ratio >= 0.95 && latency <= 10.0 => {
543                "Cluster is healthy. No action needed.".to_string()
544            }
545            (ratio, latency) if ratio >= 0.90 && latency <= 20.0 => {
546                "Cluster is degraded. Consider load balancing.".to_string()
547            }
548            (ratio, latency) if ratio >= 0.80 && latency <= 50.0 => {
549                "Cluster performance is poor. Recommend quantization fallback.".to_string()
550            }
551            _ => "Cluster has failed nodes. Investigate and recover.".to_string(),
552        }
553    }
554}
555
556/// Fault detection and recovery system
557#[derive(Clone, Debug)]
558pub struct FaultDetector {
559    /// Cluster state
560    cluster: Arc<ClusterState>,
561    /// Detection interval
562    detection_interval: Duration,
563}
564
565impl FaultDetector {
566    /// Create new fault detector
567    pub fn new(cluster: Arc<ClusterState>, detection_interval: Duration) -> Self {
568        Self {
569            cluster,
570            detection_interval,
571        }
572    }
573
574    /// Detect failed nodes based on heartbeat timeouts
575    ///
576    /// OPTIMIZATION: Acquires the cluster metrics lock once and mutates status in-place
577    /// instead of acquiring locks repeatedly (up to 3N times) and deep-cloning `NodeMetrics`
578    /// (including heap-allocated latency history vectors) on every node check.
579    pub fn detect_failures(&self) -> Vec<String> {
580        let nodes_snapshot = self.cluster.nodes_snapshot();
581        let now = Instant::now();
582        let mut failed_nodes = Vec::new();
583
584        let mut metrics = self
585            .cluster
586            .metrics
587            .lock()
588            .unwrap_or_else(|poison| poison.into_inner());
589
590        for node in nodes_snapshot.iter() {
591            if let Some(m) = metrics.get_mut(&node.id) {
592                if now.saturating_duration_since(m.last_heartbeat) >= m.heartbeat_timeout {
593                    // Any node whose heartbeat has timed out should be marked
594                    // Failed, not just ones currently Active. This used to
595                    // check `status == Active`, so a node already marked
596                    // Degraded (e.g. by NetworkHealthMonitor) that then went
597                    // completely silent would never be detected as failed
598                    // here, and would never be reported in `failed_nodes`.
599                    if m.status != NodeStatus::Failed {
600                        m.status = NodeStatus::Failed;
601                        failed_nodes.push(node.id.clone());
602                    }
603                }
604            } else {
605                // No metrics for this node - insert entry with Failed status so
606                // it is only reported once on initial failure detection.
607                let mut m = crate::cluster::NodeMetrics::new(
608                    node.vram_gb,
609                    node.system_memory_gb,
610                    node.compute_capability.clone(),
611                    Duration::from_secs(5),
612                );
613                m.name = node.id.clone();
614                m.gpu_name = node.gpu_name.clone();
615                m.status = NodeStatus::Failed;
616                metrics.insert(node.id.clone(), m);
617                failed_nodes.push(node.id.clone());
618            }
619        }
620
621        failed_nodes
622    }
623
624    /// Recover a failed node
625    pub fn recover_node(&self, node_id: &str) {
626        self.cluster.get_metrics_mut(node_id, |metrics| {
627            metrics.status = NodeStatus::Active;
628            metrics.last_heartbeat = Instant::now();
629
630            // Reset metrics
631            metrics.avg_latency_us = 0.0;
632            metrics.min_latency_us = f32::MAX;
633            metrics.max_latency_us = 0.0;
634            metrics.latency_samples = 0;
635            metrics.delivery_ratio = 1.0;
636            metrics.throughput_gbps = 0.0;
637        });
638    }
639
640    /// Start periodic fault detection in background
641    pub fn start_periodic_detection(&self) {
642        let this = self.clone();
643
644        std::thread::spawn(move || loop {
645            let failed = this.detect_failures();
646            if !failed.is_empty() {
647                tracing::warn!(
648                    "Detected {} failed nodes: {:?}",
649                    failed.len(),
650                    failed.iter().map(|s| s.as_str()).collect::<Vec<_>>()
651                );
652            }
653            std::thread::sleep(this.detection_interval);
654        });
655    }
656}
657
658#[cfg(test)]
659mod tests {
660    use super::*;
661    use crate::cluster::ClusterState;
662    use crate::protocol::NodeResources;
663    use std::net::TcpListener;
664    use std::sync::Arc;
665    use std::time::Duration;
666
667    #[test]
668    fn health_monitor_checks_all_nodes() {
669        let cluster = Arc::new(ClusterState::new());
670        cluster.register(NodeResources::new("node-a", 24.0, 64.0, "8.9", None));
671        cluster.register(NodeResources::new("node-b", 12.0, 32.0, "8.6", None));
672
673        let monitor = NetworkHealthMonitor::new(cluster.clone(), HealthConfig::default());
674        monitor.check_all();
675
676        let summary = monitor.get_health_summary();
677        assert!(summary.contains("Total nodes: 2"));
678    }
679
680    #[test]
681    fn health_monitor_marks_fresh_nodes_unknown_without_samples() {
682        let cluster = Arc::new(ClusterState::new());
683        cluster.register(NodeResources::new("node-a", 24.0, 64.0, "8.9", None));
684
685        let monitor = NetworkHealthMonitor::new(cluster, HealthConfig::default());
686        monitor.check_all();
687
688        let node_health = monitor
689            .get_node_health("node-a")
690            .expect("expected health check result for node-a");
691        assert_eq!(node_health.status, HealthStatus::Unknown);
692    }
693
694    #[test]
695    fn health_monitor_detects_degraded_nodes() {
696        let cluster = Arc::new(ClusterState::new());
697        cluster.register(NodeResources::new("node-a", 24.0, 64.0, "8.9", None));
698
699        let monitor = NetworkHealthMonitor::new(cluster.clone(), HealthConfig::default());
700
701        // Simulate multiple degraded health checks by seeding recent_checks directly
702        // (check_all uses random healthy values, so we manually push degraded results)
703        for _ in 0..3 {
704            let result = HealthCheckResult {
705                node_id: "node-a".to_string(),
706                latency_us: 15.0,
707                delivery_ratio: 0.85,
708                status: HealthStatus::Degraded,
709                timestamp: std::time::Instant::now(),
710            };
711            monitor
712                .recent_checks
713                .lock()
714                .unwrap()
715                .entry("node-a".to_string())
716                .or_default()
717                .push_back(result);
718        }
719
720        assert!(monitor.needs_quantization_fallback("node-a"));
721    }
722
723    #[test]
724    fn health_metrics_aggregates() {
725        let mut metrics = HealthMetrics::new();
726
727        metrics.record(1.0, 0.98);
728        metrics.record(2.0, 0.95);
729        metrics.record(1.5, 0.97);
730
731        assert!((metrics.avg_latency_us - 1.5).abs() < 0.1);
732        assert!(metrics.min_latency_us <= 1.0);
733        assert!(metrics.max_latency_us >= 2.0);
734    }
735
736    #[test]
737    fn fault_detector_detects_failures() {
738        let cluster = Arc::new(ClusterState::new());
739        cluster.register(NodeResources::new("node-a", 24.0, 64.0, "8.9", None));
740
741        // Wait for timeout
742        std::thread::sleep(Duration::from_secs(6));
743
744        let detector = FaultDetector::new(cluster.clone(), Duration::from_secs(1));
745        let failed = detector.detect_failures();
746
747        assert!(failed.contains(&"node-a".to_string()));
748    }
749
750    #[test]
751    fn fault_detector_detects_failures_for_degraded_nodes() {
752        // Regression: detect_failures() used to only transition a node from
753        // Active -> Failed on heartbeat timeout. A node already marked
754        // Degraded (e.g. by NetworkHealthMonitor.check_all) that then went
755        // completely silent was never picked up, so it stayed Degraded
756        // forever and was never reported in the failed_nodes list.
757        let cluster = Arc::new(ClusterState::new());
758        cluster.register(NodeResources::new("node-a", 24.0, 64.0, "8.9", None));
759        cluster.get_metrics_mut("node-a", |metrics| {
760            metrics.status = NodeStatus::Degraded;
761        });
762
763        std::thread::sleep(Duration::from_secs(6));
764
765        let detector = FaultDetector::new(cluster.clone(), Duration::from_secs(1));
766        let failed = detector.detect_failures();
767
768        assert!(failed.contains(&"node-a".to_string()));
769        assert_eq!(
770            cluster.get_metrics("node-a").unwrap().status,
771            NodeStatus::Failed
772        );
773    }
774
775    #[test]
776    fn fault_detector_recovers_nodes() {
777        let cluster = Arc::new(ClusterState::new());
778        cluster.register(NodeResources::new("node-a", 24.0, 64.0, "8.9", None));
779
780        std::thread::sleep(Duration::from_secs(6));
781
782        let detector = FaultDetector::new(cluster.clone(), Duration::from_secs(1));
783        detector.detect_failures();
784
785        assert!(cluster.get_metrics("node-a").unwrap().status == NodeStatus::Failed);
786
787        detector.recover_node("node-a");
788        assert_eq!(
789            cluster.get_metrics("node-a").unwrap().status,
790            NodeStatus::Active
791        );
792    }
793
794    #[test]
795    fn health_monitor_gets_recommendation() {
796        let mut metrics = HealthMetrics::new();
797
798        // Good cluster
799        metrics.record(1.0, 0.98);
800        metrics.record(2.0, 0.95);
801
802        let recommendation = metrics.get_recommendation();
803        assert!(recommendation.contains("healthy"));
804    }
805
806    #[test]
807    fn get_recommendation_uses_average_latency_not_minimum() {
808        // Regression: get_recommendation() used to key off `min_latency_us`
809        // instead of `avg_latency_us`, paired against `avg_delivery_ratio`.
810        // Since min_latency_us only ever decreases (or holds), a single fast
811        // sample early on would make the recommendation report "healthy"
812        // forever, no matter how bad subsequent (averaged) latency became.
813        let mut metrics = HealthMetrics::new();
814        metrics.record(1.0, 0.98);
815        metrics.record(200.0, 0.98);
816
817        let recommendation = metrics.get_recommendation();
818        assert!(!recommendation.contains("healthy"));
819    }
820
821    #[test]
822    fn health_config_autotunes_for_gpu_profiles() {
823        let profile = RuntimeProfile {
824            node_resources: NodeResources::new("node-a", 24.0, 64.0, "8.9", None),
825            logical_cores: 16,
826            recommended_workers: 8,
827            acceleration_mode: AccelerationMode::Gpu,
828            gpu_backend: crate::host::GpuBackend::Cuda,
829            xdp_supported: true,
830            detection_source: String::from("test"),
831            probe_mode: crate::host::ProbeMode::Fast,
832        };
833
834        let config = HealthConfig::autotuned(&profile);
835        assert!(config.healthy_latency_us < 10.0);
836        assert!(config.healthy_delivery_ratio > 0.95);
837    }
838
839    #[test]
840    fn get_health_status_fails_on_either_metric_crossing_floor() {
841        // Regression: Degraded used to require only ONE metric within its
842        // floor (an OR), so a node could never reach Failed unless BOTH
843        // metrics were simultaneously catastrophic. Either one alone crossing
844        // its floor must be enough.
845        let cluster = Arc::new(ClusterState::new());
846        let monitor = NetworkHealthMonitor::new(cluster, HealthConfig::default());
847        let cfg = HealthConfig::default();
848
849        // Delivery ratio fine, latency catastrophic — must be Failed, not Degraded.
850        assert_eq!(
851            monitor.get_health_status(cfg.degraded_latency_us * 10.0, 0.99),
852            HealthStatus::Failed
853        );
854        // Latency fine, delivery ratio catastrophic — must be Failed, not Degraded.
855        assert_eq!(
856            monitor.get_health_status(1.0, cfg.degraded_delivery_ratio / 2.0),
857            HealthStatus::Failed
858        );
859        // Both within their degraded floor but not healthy — genuinely Degraded.
860        assert_eq!(
861            monitor.get_health_status(cfg.degraded_latency_us, cfg.degraded_delivery_ratio),
862            HealthStatus::Degraded
863        );
864    }
865
866    #[test]
867    fn tcp_probe_target_can_mark_node_failed_when_unreachable() {
868        let cluster = Arc::new(ClusterState::new());
869        cluster.register(NodeResources::new("node-a", 24.0, 64.0, "8.9", None));
870
871        let listener = TcpListener::bind("127.0.0.1:0").expect("bind probe listener");
872        let addr = listener.local_addr().expect("listener local addr");
873        drop(listener);
874
875        let monitor = NetworkHealthMonitor::new(cluster.clone(), HealthConfig::default());
876        monitor.register_tcp_probe_target("node-a", addr);
877        monitor.check_all();
878
879        let node_health = monitor
880            .get_node_health("node-a")
881            .expect("expected health check result for node-a");
882        assert_eq!(node_health.status, HealthStatus::Failed);
883    }
884
885    #[test]
886    fn tcp_probe_target_healthy_when_reachable() {
887        let cluster = Arc::new(ClusterState::new());
888        cluster.register(NodeResources::new("node-a", 24.0, 64.0, "8.9", None));
889
890        cluster.get_metrics_mut("node-a", |metrics| {
891            metrics.record_latency(2.0);
892            metrics.record_delivery_ratio(0.99);
893        });
894
895        let listener = TcpListener::bind("127.0.0.1:0").expect("bind probe listener");
896        let addr = listener.local_addr().expect("listener local addr");
897
898        let monitor = NetworkHealthMonitor::new(cluster.clone(), HealthConfig::default());
899        monitor.register_tcp_probe_target("node-a", addr);
900        monitor.check_all();
901
902        let node_health = monitor
903            .get_node_health("node-a")
904            .expect("expected health check result for node-a");
905        assert_eq!(node_health.status, HealthStatus::Healthy);
906    }
907}