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 checks
347 .values()
348 .flat_map(|checks| checks.iter().cloned())
349 .collect()
350 }
351
352 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 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 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 avg_delivery_ratio < cfg.healthy_delivery_ratio
399 || avg_latency_us > cfg.healthy_latency_us
400 } else {
401 false
402 }
403 }
404
405 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 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 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 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#[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#[derive(Clone, Debug, Default)]
510pub struct HealthMetrics {
511 pub avg_latency_us: f32,
513 pub min_latency_us: f32,
515 pub max_latency_us: f32,
517 pub avg_delivery_ratio: f32,
519 pub min_delivery_ratio: f32,
521 pub sample_count: usize,
523}
524
525impl HealthMetrics {
526 pub fn new() -> Self {
528 Self::default()
529 }
530
531 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 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 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#[derive(Clone, Debug)]
580pub struct FaultDetector {
581 cluster: Arc<ClusterState>,
583 detection_interval: Duration,
585}
586
587impl FaultDetector {
588 pub fn new(cluster: Arc<ClusterState>, detection_interval: Duration) -> Self {
590 Self {
591 cluster,
592 detection_interval,
593 }
594 }
595
596 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 if m.status != NodeStatus::Failed {
622 m.status = NodeStatus::Failed;
623 failed_nodes.push(node.id.clone());
624 }
625 }
626 } else {
627 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 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 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 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 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 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 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 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 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 let cluster = Arc::new(ClusterState::new());
868 let monitor = NetworkHealthMonitor::new(cluster, HealthConfig::default());
869 let cfg = HealthConfig::default();
870
871 assert_eq!(
873 monitor.get_health_status(cfg.degraded_latency_us * 10.0, 0.99),
874 HealthStatus::Failed
875 );
876 assert_eq!(
878 monitor.get_health_status(1.0, cfg.degraded_delivery_ratio / 2.0),
879 HealthStatus::Failed
880 );
881 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}