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
347        // OPTIMIZATION: Pre-allocate vector capacity to avoid dynamic heap reallocations
348        // when flattening health check results across node ring buffers.
349        let total_capacity: usize = checks.values().map(|c| c.len()).sum();
350        let mut results = Vec::with_capacity(total_capacity);
351        for node_checks in checks.values() {
352            results.extend(node_checks.iter().cloned());
353        }
354        results
355    }
356
357    /// Get the number of nodes whose latest health sample is healthy.
358    pub fn healthy_node_count(&self) -> usize {
359        let checks = self
360            .recent_checks
361            .lock()
362            .unwrap_or_else(|poison| poison.into_inner());
363        checks
364            .values()
365            .filter(|node_checks| {
366                node_checks
367                    .back()
368                    .map(|c| c.status == HealthStatus::Healthy)
369                    .unwrap_or(false)
370            })
371            .count()
372    }
373
374    /// Check if node needs quantization fallback
375    ///
376    /// OPTIMIZATION: Accumulates delivery ratio and latency across recent checks in a single pass
377    /// without allocating intermediate candidate vectors or performing multiple iterator traversals.
378    pub fn needs_quantization_fallback(&self, node_id: &str) -> bool {
379        let cfg = self.config();
380        let checks = self
381            .recent_checks
382            .lock()
383            .unwrap_or_else(|poison| poison.into_inner());
384
385        if let Some(node_checks) = checks.get(node_id) {
386            if node_checks.len() < 3 {
387                return false;
388            }
389
390            // Calculate average delivery ratio and latency of last 3 checks in a single pass
391            let mut sum_delivery_ratio = 0.0f32;
392            let mut sum_latency_us = 0.0f32;
393
394            for r in node_checks.iter().rev().take(3) {
395                sum_delivery_ratio += r.delivery_ratio;
396                sum_latency_us += r.latency_us;
397            }
398
399            let avg_delivery_ratio = sum_delivery_ratio / 3.0;
400            let avg_latency_us = sum_latency_us / 3.0;
401
402            // Need fallback if delivery ratio dropped below threshold or latency increased significantly
403            avg_delivery_ratio < cfg.healthy_delivery_ratio
404                || avg_latency_us > cfg.healthy_latency_us
405        } else {
406            false
407        }
408    }
409
410    /// Get cluster-wide health summary
411    ///
412    /// OPTIMIZATION: Consolidates node health counts into a single-pass traversal over recent_checks,
413    /// eliminating three redundant iterator traversals and repeated back() calls per node.
414    pub fn get_health_summary(&self) -> String {
415        let checks = self
416            .recent_checks
417            .lock()
418            .unwrap_or_else(|poison| poison.into_inner());
419
420        let total_nodes = self.cluster.node_count();
421        let mut healthy_count = 0usize;
422        let mut degraded_count = 0usize;
423        let mut failed_count = 0usize;
424
425        for node_checks in checks.values() {
426            if let Some(c) = node_checks.back() {
427                match c.status {
428                    HealthStatus::Healthy => healthy_count += 1,
429                    HealthStatus::Degraded => degraded_count += 1,
430                    HealthStatus::Failed => failed_count += 1,
431                    HealthStatus::Unknown => {}
432                }
433            }
434        }
435
436        format!(
437            "Network Health Summary\n\
438             =================\n\
439             Total nodes: {total_nodes}\n\
440             Healthy: {healthy_count}\n\
441             Degraded: {degraded_count}\n\
442             Failed: {failed_count}\n"
443        )
444    }
445
446    /// Start periodic health checks in background
447    pub fn start_periodic_checks(&self) {
448        let check_interval = self.config().check_interval;
449        let this = self.clone();
450
451        std::thread::spawn(move || loop {
452            this.check_all();
453            std::thread::sleep(check_interval);
454        });
455    }
456
457    // ------------------------------------------------------------------
458    // SystemProfileWatcher integration
459    // ------------------------------------------------------------------
460
461    /// Re-tune health thresholds from a system profile detected at runtime.
462    ///
463    /// This is the main integration point with `SystemProfileWatcher`: when
464    /// hardware changes (GPU hot-plug, CPU scaling), call this to keep the
465    /// health monitor's expectations aligned with reality.
466    pub fn reconfigure_from_system_profile(&self, profile: &SystemProfile) {
467        let rp: RuntimeProfile = profile.into();
468        self.config.store(Arc::new(HealthConfig::autotuned(&rp)));
469    }
470
471    /// Subscribe to a `SystemProfileWatcher`'s broadcast channel and
472    /// automatically re-tune whenever hardware changes are detected.
473    ///
474    /// Returns a `JoinHandle` that can be cancelled by dropping it.
475    pub fn subscribe_to_watcher(
476        self: &Arc<Self>,
477        watcher: &crate::watcher::SystemProfileWatcher,
478    ) -> tokio::task::JoinHandle<()> {
479        let this = self.clone();
480        let mut rx = watcher.subscribe();
481        tokio::spawn(async move {
482            while let Ok(event) = rx.recv().await {
483                if let ProfileChange::Updated(profile) = event {
484                    this.reconfigure_from_system_profile(&profile);
485                    tracing::info!(
486                        "health monitor re-tuned from profile: {} workers, {:?} accel",
487                        profile.recommended_workers,
488                        profile.acceleration_mode,
489                    );
490                }
491            }
492        })
493    }
494}
495
496/// Helper function to calculate health status given a reference to HealthConfig.
497#[inline]
498fn get_health_status_with_config(
499    cfg: &HealthConfig,
500    latency_us: f32,
501    delivery_ratio: f32,
502) -> HealthStatus {
503    if delivery_ratio >= cfg.healthy_delivery_ratio && latency_us <= cfg.healthy_latency_us {
504        HealthStatus::Healthy
505    } else if delivery_ratio >= cfg.degraded_delivery_ratio && latency_us <= cfg.degraded_latency_us
506    {
507        HealthStatus::Degraded
508    } else {
509        HealthStatus::Failed
510    }
511}
512
513/// Health metrics aggregation
514#[derive(Clone, Debug, Default)]
515pub struct HealthMetrics {
516    /// Average latency across cluster
517    pub avg_latency_us: f32,
518    /// Minimum observed latency
519    pub min_latency_us: f32,
520    /// Maximum observed latency
521    pub max_latency_us: f32,
522    /// Average delivery ratio
523    pub avg_delivery_ratio: f32,
524    /// Minimum delivery ratio
525    pub min_delivery_ratio: f32,
526    /// Number of samples
527    pub sample_count: usize,
528}
529
530impl HealthMetrics {
531    /// Create new metrics aggregator
532    pub fn new() -> Self {
533        Self::default()
534    }
535
536    /// Record a health check result
537    pub fn record(&mut self, latency_us: f32, delivery_ratio: f32) {
538        if self.sample_count == 0 {
539            self.min_latency_us = latency_us;
540            self.max_latency_us = latency_us;
541            self.min_delivery_ratio = delivery_ratio;
542        } else {
543            if latency_us < self.min_latency_us {
544                self.min_latency_us = latency_us;
545            }
546            if latency_us > self.max_latency_us {
547                self.max_latency_us = latency_us;
548            }
549            if delivery_ratio < self.min_delivery_ratio {
550                self.min_delivery_ratio = delivery_ratio;
551            }
552        }
553
554        // Running mean
555        let n = self.sample_count as f32;
556        self.avg_latency_us = (self.avg_latency_us * n + latency_us) / (n + 1.0);
557        self.avg_delivery_ratio = (self.avg_delivery_ratio * n + delivery_ratio) / (n + 1.0);
558
559        self.sample_count += 1;
560    }
561
562    /// Get health status recommendation
563    pub fn get_recommendation(&self) -> String {
564        if self.sample_count == 0 {
565            return "No data available".to_string();
566        }
567
568        match (self.avg_delivery_ratio, self.avg_latency_us) {
569            (ratio, latency) if ratio >= 0.95 && latency <= 10.0 => {
570                "Cluster is healthy. No action needed.".to_string()
571            }
572            (ratio, latency) if ratio >= 0.90 && latency <= 20.0 => {
573                "Cluster is degraded. Consider load balancing.".to_string()
574            }
575            (ratio, latency) if ratio >= 0.80 && latency <= 50.0 => {
576                "Cluster performance is poor. Recommend quantization fallback.".to_string()
577            }
578            _ => "Cluster has failed nodes. Investigate and recover.".to_string(),
579        }
580    }
581}
582
583/// Fault detection and recovery system
584#[derive(Clone, Debug)]
585pub struct FaultDetector {
586    /// Cluster state
587    cluster: Arc<ClusterState>,
588    /// Detection interval
589    detection_interval: Duration,
590}
591
592impl FaultDetector {
593    /// Create new fault detector
594    pub fn new(cluster: Arc<ClusterState>, detection_interval: Duration) -> Self {
595        Self {
596            cluster,
597            detection_interval,
598        }
599    }
600
601    /// Detect failed nodes based on heartbeat timeouts
602    ///
603    /// OPTIMIZATION: Fast path iterates directly over `metrics.values_mut()` under lock
604    /// when all cluster nodes have registered metrics (`metrics.len() == node_count`).
605    /// This eliminates `nodes_snapshot()` atomic loading overhead and $N$ per-node HashMap
606    /// key lookups. If any cluster node lacks a metrics entry, falls back to snapshot-based
607    /// inspection to initialize and mark missing nodes as failed.
608    pub fn detect_failures(&self) -> Vec<String> {
609        let now = Instant::now();
610        let mut failed_nodes = Vec::new();
611
612        let mut metrics = self
613            .cluster
614            .metrics
615            .lock()
616            .unwrap_or_else(|poison| poison.into_inner());
617
618        let total_nodes = self.cluster.node_count();
619        if metrics.len() == total_nodes {
620            // Fast path: all cluster nodes have metrics entries.
621            // Iterate directly over metrics values without snapshot loading or key lookups.
622            for m in metrics.values_mut() {
623                if now.saturating_duration_since(m.last_heartbeat) >= m.heartbeat_timeout
624                    && m.status != NodeStatus::Failed
625                {
626                    m.status = NodeStatus::Failed;
627                    failed_nodes.push(m.name.clone());
628                }
629            }
630        } else {
631            // Fallback path: inspect nodes_snapshot if any registered node lacks a metrics entry
632            let nodes_snapshot = self.cluster.nodes_snapshot();
633            for node in nodes_snapshot.iter() {
634                if let Some(m) = metrics.get_mut(&node.id) {
635                    if now.saturating_duration_since(m.last_heartbeat) >= m.heartbeat_timeout
636                        && m.status != NodeStatus::Failed
637                    {
638                        m.status = NodeStatus::Failed;
639                        failed_nodes.push(node.id.clone());
640                    }
641                } else {
642                    let mut m = crate::cluster::NodeMetrics::new(
643                        node.vram_gb,
644                        node.system_memory_gb,
645                        node.compute_capability.clone(),
646                        Duration::from_secs(5),
647                    );
648                    m.name = node.id.clone();
649                    m.gpu_name = node.gpu_name.clone();
650                    m.status = NodeStatus::Failed;
651                    metrics.insert(node.id.clone(), m);
652                    failed_nodes.push(node.id.clone());
653                }
654            }
655        }
656
657        failed_nodes
658    }
659
660    /// Recover a failed node
661    pub fn recover_node(&self, node_id: &str) {
662        self.cluster.get_metrics_mut(node_id, |metrics| {
663            metrics.status = NodeStatus::Active;
664            metrics.last_heartbeat = Instant::now();
665
666            // Reset metrics
667            metrics.avg_latency_us = 0.0;
668            metrics.min_latency_us = f32::MAX;
669            metrics.max_latency_us = 0.0;
670            metrics.latency_samples = 0;
671            metrics.delivery_ratio = 1.0;
672            metrics.throughput_gbps = 0.0;
673        });
674    }
675
676    /// Start periodic fault detection in background
677    pub fn start_periodic_detection(&self) {
678        let this = self.clone();
679
680        std::thread::spawn(move || loop {
681            let failed = this.detect_failures();
682            if !failed.is_empty() {
683                tracing::warn!(
684                    "Detected {} failed nodes: {:?}",
685                    failed.len(),
686                    failed.iter().map(|s| s.as_str()).collect::<Vec<_>>()
687                );
688            }
689            std::thread::sleep(this.detection_interval);
690        });
691    }
692}
693
694#[cfg(test)]
695mod tests {
696    use super::*;
697    use crate::cluster::ClusterState;
698    use crate::protocol::NodeResources;
699    use std::net::TcpListener;
700    use std::sync::Arc;
701    use std::time::Duration;
702
703    #[test]
704    fn health_monitor_checks_all_nodes() {
705        let cluster = Arc::new(ClusterState::new());
706        cluster.register(NodeResources::new("node-a", 24.0, 64.0, "8.9", None));
707        cluster.register(NodeResources::new("node-b", 12.0, 32.0, "8.6", None));
708
709        let monitor = NetworkHealthMonitor::new(cluster.clone(), HealthConfig::default());
710        monitor.check_all();
711
712        let summary = monitor.get_health_summary();
713        assert!(summary.contains("Total nodes: 2"));
714    }
715
716    #[test]
717    fn health_monitor_marks_fresh_nodes_unknown_without_samples() {
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, HealthConfig::default());
722        monitor.check_all();
723
724        let node_health = monitor
725            .get_node_health("node-a")
726            .expect("expected health check result for node-a");
727        assert_eq!(node_health.status, HealthStatus::Unknown);
728    }
729
730    #[test]
731    fn health_monitor_detects_degraded_nodes() {
732        let cluster = Arc::new(ClusterState::new());
733        cluster.register(NodeResources::new("node-a", 24.0, 64.0, "8.9", None));
734
735        let monitor = NetworkHealthMonitor::new(cluster.clone(), HealthConfig::default());
736
737        // Simulate multiple degraded health checks by seeding recent_checks directly
738        // (check_all uses random healthy values, so we manually push degraded results)
739        for _ in 0..3 {
740            let result = HealthCheckResult {
741                node_id: "node-a".to_string(),
742                latency_us: 15.0,
743                delivery_ratio: 0.85,
744                status: HealthStatus::Degraded,
745                timestamp: std::time::Instant::now(),
746            };
747            monitor
748                .recent_checks
749                .lock()
750                .unwrap()
751                .entry("node-a".to_string())
752                .or_default()
753                .push_back(result);
754        }
755
756        assert!(monitor.needs_quantization_fallback("node-a"));
757    }
758
759    #[test]
760    fn health_metrics_aggregates() {
761        let mut metrics = HealthMetrics::new();
762
763        metrics.record(1.0, 0.98);
764        metrics.record(2.0, 0.95);
765        metrics.record(1.5, 0.97);
766
767        assert!((metrics.avg_latency_us - 1.5).abs() < 0.1);
768        assert!(metrics.min_latency_us <= 1.0);
769        assert!(metrics.max_latency_us >= 2.0);
770    }
771
772    #[test]
773    fn fault_detector_detects_failures() {
774        let cluster = Arc::new(ClusterState::new());
775        cluster.register(NodeResources::new("node-a", 24.0, 64.0, "8.9", None));
776
777        // Wait for timeout
778        std::thread::sleep(Duration::from_secs(6));
779
780        let detector = FaultDetector::new(cluster.clone(), Duration::from_secs(1));
781        let failed = detector.detect_failures();
782
783        assert!(failed.contains(&"node-a".to_string()));
784    }
785
786    #[test]
787    fn fault_detector_detects_failures_for_degraded_nodes() {
788        // Regression: detect_failures() used to only transition a node from
789        // Active -> Failed on heartbeat timeout. A node already marked
790        // Degraded (e.g. by NetworkHealthMonitor.check_all) that then went
791        // completely silent was never picked up, so it stayed Degraded
792        // forever and was never reported in the failed_nodes list.
793        let cluster = Arc::new(ClusterState::new());
794        cluster.register(NodeResources::new("node-a", 24.0, 64.0, "8.9", None));
795        cluster.get_metrics_mut("node-a", |metrics| {
796            metrics.status = NodeStatus::Degraded;
797        });
798
799        std::thread::sleep(Duration::from_secs(6));
800
801        let detector = FaultDetector::new(cluster.clone(), Duration::from_secs(1));
802        let failed = detector.detect_failures();
803
804        assert!(failed.contains(&"node-a".to_string()));
805        assert_eq!(
806            cluster.get_metrics("node-a").unwrap().status,
807            NodeStatus::Failed
808        );
809    }
810
811    #[test]
812    fn fault_detector_recovers_nodes() {
813        let cluster = Arc::new(ClusterState::new());
814        cluster.register(NodeResources::new("node-a", 24.0, 64.0, "8.9", None));
815
816        std::thread::sleep(Duration::from_secs(6));
817
818        let detector = FaultDetector::new(cluster.clone(), Duration::from_secs(1));
819        detector.detect_failures();
820
821        assert!(cluster.get_metrics("node-a").unwrap().status == NodeStatus::Failed);
822
823        detector.recover_node("node-a");
824        assert_eq!(
825            cluster.get_metrics("node-a").unwrap().status,
826            NodeStatus::Active
827        );
828    }
829
830    #[test]
831    fn health_monitor_gets_recommendation() {
832        let mut metrics = HealthMetrics::new();
833
834        // Good cluster
835        metrics.record(1.0, 0.98);
836        metrics.record(2.0, 0.95);
837
838        let recommendation = metrics.get_recommendation();
839        assert!(recommendation.contains("healthy"));
840    }
841
842    #[test]
843    fn get_recommendation_uses_average_latency_not_minimum() {
844        // Regression: get_recommendation() used to key off `min_latency_us`
845        // instead of `avg_latency_us`, paired against `avg_delivery_ratio`.
846        // Since min_latency_us only ever decreases (or holds), a single fast
847        // sample early on would make the recommendation report "healthy"
848        // forever, no matter how bad subsequent (averaged) latency became.
849        let mut metrics = HealthMetrics::new();
850        metrics.record(1.0, 0.98);
851        metrics.record(200.0, 0.98);
852
853        let recommendation = metrics.get_recommendation();
854        assert!(!recommendation.contains("healthy"));
855    }
856
857    #[test]
858    fn health_config_autotunes_for_gpu_profiles() {
859        let profile = RuntimeProfile {
860            node_resources: NodeResources::new("node-a", 24.0, 64.0, "8.9", None),
861            logical_cores: 16,
862            recommended_workers: 8,
863            acceleration_mode: AccelerationMode::Gpu,
864            gpu_backend: crate::host::GpuBackend::Cuda,
865            xdp_supported: true,
866            detection_source: String::from("test"),
867            probe_mode: crate::host::ProbeMode::Fast,
868        };
869
870        let config = HealthConfig::autotuned(&profile);
871        assert!(config.healthy_latency_us < 10.0);
872        assert!(config.healthy_delivery_ratio > 0.95);
873    }
874
875    #[test]
876    fn get_health_status_fails_on_either_metric_crossing_floor() {
877        // Regression: Degraded used to require only ONE metric within its
878        // floor (an OR), so a node could never reach Failed unless BOTH
879        // metrics were simultaneously catastrophic. Either one alone crossing
880        // its floor must be enough.
881        let cluster = Arc::new(ClusterState::new());
882        let monitor = NetworkHealthMonitor::new(cluster, HealthConfig::default());
883        let cfg = HealthConfig::default();
884
885        // Delivery ratio fine, latency catastrophic — must be Failed, not Degraded.
886        assert_eq!(
887            monitor.get_health_status(cfg.degraded_latency_us * 10.0, 0.99),
888            HealthStatus::Failed
889        );
890        // Latency fine, delivery ratio catastrophic — must be Failed, not Degraded.
891        assert_eq!(
892            monitor.get_health_status(1.0, cfg.degraded_delivery_ratio / 2.0),
893            HealthStatus::Failed
894        );
895        // Both within their degraded floor but not healthy — genuinely Degraded.
896        assert_eq!(
897            monitor.get_health_status(cfg.degraded_latency_us, cfg.degraded_delivery_ratio),
898            HealthStatus::Degraded
899        );
900    }
901
902    #[test]
903    fn tcp_probe_target_can_mark_node_failed_when_unreachable() {
904        let cluster = Arc::new(ClusterState::new());
905        cluster.register(NodeResources::new("node-a", 24.0, 64.0, "8.9", None));
906
907        let listener = TcpListener::bind("127.0.0.1:0").expect("bind probe listener");
908        let addr = listener.local_addr().expect("listener local addr");
909        drop(listener);
910
911        let monitor = NetworkHealthMonitor::new(cluster.clone(), HealthConfig::default());
912        monitor.register_tcp_probe_target("node-a", addr);
913        monitor.check_all();
914
915        let node_health = monitor
916            .get_node_health("node-a")
917            .expect("expected health check result for node-a");
918        assert_eq!(node_health.status, HealthStatus::Failed);
919    }
920
921    #[test]
922    fn tcp_probe_target_healthy_when_reachable() {
923        let cluster = Arc::new(ClusterState::new());
924        cluster.register(NodeResources::new("node-a", 24.0, 64.0, "8.9", None));
925
926        cluster.get_metrics_mut("node-a", |metrics| {
927            metrics.record_latency(2.0);
928            metrics.record_delivery_ratio(0.99);
929        });
930
931        let listener = TcpListener::bind("127.0.0.1:0").expect("bind probe listener");
932        let addr = listener.local_addr().expect("listener local addr");
933
934        let monitor = NetworkHealthMonitor::new(cluster.clone(), HealthConfig::default());
935        monitor.register_tcp_probe_target("node-a", addr);
936        monitor.check_all();
937
938        let node_health = monitor
939            .get_node_health("node-a")
940            .expect("expected health check result for node-a");
941        assert_eq!(node_health.status, HealthStatus::Healthy);
942    }
943}