1use 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#[derive(Clone, Copy, Debug)]
24pub struct HealthConfig {
25 pub check_interval: Duration,
27 pub timeout: Duration,
29 pub min_successes: usize,
31 pub max_failures: usize,
33 pub healthy_latency_us: f32,
35 pub degraded_latency_us: f32,
37 pub healthy_delivery_ratio: f32,
39 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 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
95pub enum HealthStatus {
96 Healthy,
98 Degraded,
100 Failed,
102 #[default]
104 Unknown,
105}
106
107#[derive(Clone, Debug)]
109pub struct HealthCheckResult {
110 pub node_id: String,
112 pub latency_us: f32,
114 pub delivery_ratio: f32,
116 pub status: HealthStatus,
118 pub timestamp: Instant,
120}
121
122#[derive(Clone, Debug)]
124pub struct NetworkHealthMonitor {
125 cluster: Arc<ClusterState>,
127 config: Arc<ArcSwap<HealthConfig>>,
129 last_check: Arc<Mutex<Option<Instant>>>,
131 recent_checks: Arc<Mutex<HashMap<String, VecDeque<HealthCheckResult>>>>,
133 tcp_probe_targets: Arc<Mutex<HashMap<String, SocketAddr>>>,
135}
136
137impl NetworkHealthMonitor {
138 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 pub fn with_runtime_profile(cluster: Arc<ClusterState>, profile: &RuntimeProfile) -> Self {
151 Self::new(cluster, HealthConfig::autotuned(profile))
152 }
153
154 pub fn config(&self) -> HealthConfig {
156 **self.config.load()
157 }
158
159 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 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 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 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 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 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 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 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 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 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 #[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 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 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 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 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 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 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 avg_delivery_ratio < cfg.healthy_delivery_ratio
404 || avg_latency_us > cfg.healthy_latency_us
405 } else {
406 false
407 }
408 }
409
410 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 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 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 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#[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#[derive(Clone, Debug, Default)]
515pub struct HealthMetrics {
516 pub avg_latency_us: f32,
518 pub min_latency_us: f32,
520 pub max_latency_us: f32,
522 pub avg_delivery_ratio: f32,
524 pub min_delivery_ratio: f32,
526 pub sample_count: usize,
528}
529
530impl HealthMetrics {
531 pub fn new() -> Self {
533 Self::default()
534 }
535
536 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 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 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#[derive(Clone, Debug)]
585pub struct FaultDetector {
586 cluster: Arc<ClusterState>,
588 detection_interval: Duration,
590}
591
592impl FaultDetector {
593 pub fn new(cluster: Arc<ClusterState>, detection_interval: Duration) -> Self {
595 Self {
596 cluster,
597 detection_interval,
598 }
599 }
600
601 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 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 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 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 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 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 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 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 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 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 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 let cluster = Arc::new(ClusterState::new());
882 let monitor = NetworkHealthMonitor::new(cluster, HealthConfig::default());
883 let cfg = HealthConfig::default();
884
885 assert_eq!(
887 monitor.get_health_status(cfg.degraded_latency_us * 10.0, 0.99),
888 HealthStatus::Failed
889 );
890 assert_eq!(
892 monitor.get_health_status(1.0, cfg.degraded_delivery_ratio / 2.0),
893 HealthStatus::Failed
894 );
895 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}