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