1use std::collections::{HashMap, VecDeque};
11use std::net::{SocketAddr, TcpStream};
12use std::sync::{Arc, Mutex};
13use std::time::{Duration, Instant};
14
15use crate::cluster::{ClusterState, NodeStatus};
16use crate::host::{AccelerationMode, RuntimeProfile};
17use crate::system_profile::SystemProfile;
18use crate::watcher::ProfileChange;
19
20#[derive(Clone, Copy, Debug)]
22pub struct HealthConfig {
23 pub check_interval: Duration,
25 pub timeout: Duration,
27 pub min_successes: usize,
29 pub max_failures: usize,
31 pub healthy_latency_us: f32,
33 pub degraded_latency_us: f32,
35 pub healthy_delivery_ratio: f32,
37 pub degraded_delivery_ratio: f32,
39}
40
41impl Default for HealthConfig {
42 fn default() -> Self {
43 Self {
44 check_interval: Duration::from_secs(5),
45 timeout: Duration::from_secs(3),
46 min_successes: 2,
47 max_failures: 3,
48 healthy_latency_us: 10.0,
49 degraded_latency_us: 20.0,
50 healthy_delivery_ratio: 0.95,
51 degraded_delivery_ratio: 0.80,
52 }
53 }
54}
55
56impl HealthConfig {
57 pub fn autotuned(profile: &RuntimeProfile) -> Self {
59 let (healthy_latency_us, degraded_latency_us, timeout, max_failures) =
60 match profile.acceleration_mode {
61 AccelerationMode::Gpu => (6.0, 14.0, Duration::from_secs(2), 4),
62 AccelerationMode::Avx512 => (8.0, 18.0, Duration::from_secs(2), 3),
63 _ => (10.0, 20.0, Duration::from_secs(3), 3),
64 };
65
66 Self {
67 check_interval: Duration::from_secs(5),
68 timeout,
69 min_successes: if profile.recommended_workers >= 8 {
70 3
71 } else {
72 2
73 },
74 max_failures,
75 healthy_latency_us,
76 degraded_latency_us,
77 healthy_delivery_ratio: if profile.node_resources.vram_gb > 0.0 {
78 0.97
79 } else {
80 0.95
81 },
82 degraded_delivery_ratio: if profile.node_resources.vram_gb > 0.0 {
83 0.88
84 } else {
85 0.80
86 },
87 }
88 }
89}
90
91#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
93pub enum HealthStatus {
94 Healthy,
96 Degraded,
98 Failed,
100 #[default]
102 Unknown,
103}
104
105#[derive(Clone, Debug)]
107pub struct HealthCheckResult {
108 pub node_id: String,
110 pub latency_us: f32,
112 pub delivery_ratio: f32,
114 pub status: HealthStatus,
116 pub timestamp: Instant,
118}
119
120#[derive(Clone, Debug)]
122pub struct NetworkHealthMonitor {
123 cluster: Arc<ClusterState>,
125 config: Arc<Mutex<HealthConfig>>,
127 last_check: Arc<Mutex<Option<Instant>>>,
129 recent_checks: Arc<Mutex<HashMap<String, VecDeque<HealthCheckResult>>>>,
131 tcp_probe_targets: Arc<Mutex<HashMap<String, SocketAddr>>>,
133}
134
135impl NetworkHealthMonitor {
136 pub fn new(cluster: Arc<ClusterState>, config: HealthConfig) -> Self {
138 Self {
139 cluster,
140 config: Arc::new(Mutex::new(config)),
141 last_check: Arc::new(Mutex::new(None)),
142 recent_checks: Arc::new(Mutex::new(HashMap::new())),
143 tcp_probe_targets: Arc::new(Mutex::new(HashMap::new())),
144 }
145 }
146
147 pub fn with_runtime_profile(cluster: Arc<ClusterState>, profile: &RuntimeProfile) -> Self {
149 Self::new(cluster, HealthConfig::autotuned(profile))
150 }
151
152 pub fn config(&self) -> HealthConfig {
154 self.config
155 .lock()
156 .ok()
157 .as_deref()
158 .copied()
159 .unwrap_or_default()
160 }
161
162 pub fn register_tcp_probe_target(&self, node_id: impl Into<String>, target: SocketAddr) {
164 self.tcp_probe_targets
165 .lock()
166 .unwrap_or_else(|poison| poison.into_inner())
167 .insert(node_id.into(), target);
168 }
169
170 pub fn clear_tcp_probe_target(&self, node_id: &str) -> bool {
172 self.tcp_probe_targets
173 .lock()
174 .unwrap_or_else(|poison| poison.into_inner())
175 .remove(node_id)
176 .is_some()
177 }
178
179 pub fn check_all(&self) {
181 let cfg = self.config();
182 let now = Instant::now();
183 let nodes_snapshot = self.cluster.nodes_snapshot();
184
185 let targets: Vec<(String, SocketAddr)> = {
187 let probe_targets = self
188 .tcp_probe_targets
189 .lock()
190 .unwrap_or_else(|poison| poison.into_inner());
191 nodes_snapshot
192 .iter()
193 .filter_map(|node| {
194 probe_targets
195 .get(&node.id)
196 .map(|addr| (node.id.clone(), *addr))
197 })
198 .collect()
199 };
200
201 let mut probe_results = HashMap::with_capacity(targets.len());
203 for (node_id, addr) in targets {
204 let ok = TcpStream::connect_timeout(&addr, cfg.timeout).is_ok();
205 probe_results.insert(node_id, ok);
206 }
207
208 let mut metrics_guard = self
210 .cluster
211 .metrics
212 .lock()
213 .unwrap_or_else(|poison| poison.into_inner());
214
215 let mut checks = self
216 .recent_checks
217 .lock()
218 .unwrap_or_else(|poison| poison.into_inner());
219
220 for node in nodes_snapshot.iter() {
221 let tcp_probe_ok = probe_results.get(&node.id).copied();
222
223 let result = if let Some(m) = metrics_guard.get_mut(&node.id) {
224 let timeout =
225 now.saturating_duration_since(m.last_heartbeat) >= m.heartbeat_timeout;
226 let latency_us = if m.latency_samples > 0 {
227 m.avg_latency_us
228 } else {
229 0.0
230 };
231
232 let delivery_ratio = if m.delivery_ratio_initialized {
233 m.delivery_ratio
234 } else {
235 1.0
236 };
237
238 let status =
239 if tcp_probe_ok == Some(false) || timeout || m.status == NodeStatus::Failed {
240 HealthStatus::Failed
241 } else if m.latency_samples == 0 && !m.delivery_ratio_initialized {
242 HealthStatus::Unknown
243 } else {
244 self.get_health_status(latency_us, delivery_ratio)
245 };
246
247 let measured_latency_us = if tcp_probe_ok == Some(false) {
248 cfg.timeout.as_secs_f32() * 1_000_000.0
249 } else {
250 latency_us
251 };
252
253 m.status = match status {
255 HealthStatus::Healthy => NodeStatus::Active,
256 HealthStatus::Degraded => NodeStatus::Degraded,
257 HealthStatus::Failed => NodeStatus::Failed,
258 HealthStatus::Unknown => m.status,
259 };
260
261 HealthCheckResult {
262 node_id: node.id.clone(),
263 latency_us: measured_latency_us,
264 delivery_ratio,
265 status,
266 timestamp: now,
267 }
268 } else {
269 HealthCheckResult {
270 node_id: node.id.clone(),
271 latency_us: 0.0,
272 delivery_ratio: 1.0,
273 status: HealthStatus::Unknown,
274 timestamp: now,
275 }
276 };
277
278 let node_checks = checks.entry(node.id.clone()).or_default();
280 node_checks.push_back(result);
281 if node_checks.len() > 10 {
282 node_checks.pop_front();
283 }
284 }
285
286 *self
287 .last_check
288 .lock()
289 .unwrap_or_else(|poison| poison.into_inner()) = Some(now);
290 }
291
292 fn get_health_status(&self, latency_us: f32, delivery_ratio: f32) -> HealthStatus {
302 let cfg = self.config();
303 if delivery_ratio >= cfg.healthy_delivery_ratio && latency_us <= cfg.healthy_latency_us {
304 HealthStatus::Healthy
305 } else if delivery_ratio >= cfg.degraded_delivery_ratio
306 && latency_us <= cfg.degraded_latency_us
307 {
308 HealthStatus::Degraded
309 } else {
310 HealthStatus::Failed
311 }
312 }
313
314 pub fn get_node_health(&self, node_id: &str) -> Option<HealthCheckResult> {
316 let checks = self
317 .recent_checks
318 .lock()
319 .unwrap_or_else(|poison| poison.into_inner());
320 checks
321 .get(node_id)
322 .and_then(|checks| checks.back())
323 .cloned()
324 }
325
326 pub fn get_all_health(&self) -> Vec<HealthCheckResult> {
328 let checks = self
329 .recent_checks
330 .lock()
331 .unwrap_or_else(|poison| poison.into_inner());
332 checks
333 .values()
334 .flat_map(|checks| checks.iter().cloned())
335 .collect()
336 }
337
338 pub fn healthy_node_count(&self) -> usize {
340 let checks = self
341 .recent_checks
342 .lock()
343 .unwrap_or_else(|poison| poison.into_inner());
344 checks
345 .values()
346 .filter(|node_checks| {
347 node_checks
348 .back()
349 .map(|c| c.status == HealthStatus::Healthy)
350 .unwrap_or(false)
351 })
352 .count()
353 }
354
355 pub fn needs_quantization_fallback(&self, node_id: &str) -> bool {
357 let cfg = self.config();
358 let checks = self
359 .recent_checks
360 .lock()
361 .unwrap_or_else(|poison| poison.into_inner());
362
363 if let Some(node_checks) = checks.get(node_id) {
364 let recent: Vec<_> = node_checks.iter().rev().take(3).collect();
366
367 if recent.len() < 3 {
368 return false;
369 }
370
371 let avg_delivery_ratio: f32 =
373 recent.iter().map(|r| r.delivery_ratio).sum::<f32>() / 3.0;
374
375 let avg_latency_us: f32 = recent.iter().map(|r| r.latency_us).sum::<f32>() / 3.0;
377
378 avg_delivery_ratio < cfg.healthy_delivery_ratio
380 || avg_latency_us > cfg.healthy_latency_us
381 } else {
382 false
383 }
384 }
385
386 pub fn get_health_summary(&self) -> String {
388 let checks = self
389 .recent_checks
390 .lock()
391 .unwrap_or_else(|poison| poison.into_inner());
392
393 let total_nodes = self.cluster.node_count();
394 let healthy_count = checks
395 .values()
396 .filter(|node_checks| {
397 node_checks
398 .back()
399 .map(|c| c.status == HealthStatus::Healthy)
400 .unwrap_or(false)
401 })
402 .count();
403
404 let degraded_count = checks
405 .values()
406 .filter(|node_checks| {
407 node_checks
408 .back()
409 .map(|c| c.status == HealthStatus::Degraded)
410 .unwrap_or(false)
411 })
412 .count();
413
414 let failed_count = checks
415 .values()
416 .filter(|node_checks| {
417 node_checks
418 .back()
419 .map(|c| c.status == HealthStatus::Failed)
420 .unwrap_or(false)
421 })
422 .count();
423
424 format!(
425 "Network Health Summary\n\
426 =================\n\
427 Total nodes: {total_nodes}\n\
428 Healthy: {healthy_count}\n\
429 Degraded: {degraded_count}\n\
430 Failed: {failed_count}\n"
431 )
432 }
433
434 pub fn start_periodic_checks(&self) {
436 let check_interval = self.config().check_interval;
437 let this = self.clone();
438
439 std::thread::spawn(move || loop {
440 this.check_all();
441 std::thread::sleep(check_interval);
442 });
443 }
444
445 pub fn reconfigure_from_system_profile(&self, profile: &SystemProfile) {
455 let rp: RuntimeProfile = profile.into();
456 if let Ok(mut guard) = self.config.lock() {
457 *guard = HealthConfig::autotuned(&rp);
458 }
459 }
460
461 pub fn subscribe_to_watcher(
466 self: &Arc<Self>,
467 watcher: &crate::watcher::SystemProfileWatcher,
468 ) -> tokio::task::JoinHandle<()> {
469 let this = self.clone();
470 let mut rx = watcher.subscribe();
471 tokio::spawn(async move {
472 while let Ok(event) = rx.recv().await {
473 if let ProfileChange::Updated(profile) = event {
474 this.reconfigure_from_system_profile(&profile);
475 tracing::info!(
476 "health monitor re-tuned from profile: {} workers, {:?} accel",
477 profile.recommended_workers,
478 profile.acceleration_mode,
479 );
480 }
481 }
482 })
483 }
484}
485
486#[derive(Clone, Debug, Default)]
488pub struct HealthMetrics {
489 pub avg_latency_us: f32,
491 pub min_latency_us: f32,
493 pub max_latency_us: f32,
495 pub avg_delivery_ratio: f32,
497 pub min_delivery_ratio: f32,
499 pub sample_count: usize,
501}
502
503impl HealthMetrics {
504 pub fn new() -> Self {
506 Self::default()
507 }
508
509 pub fn record(&mut self, latency_us: f32, delivery_ratio: f32) {
511 if self.sample_count == 0 {
512 self.min_latency_us = latency_us;
513 self.max_latency_us = latency_us;
514 self.min_delivery_ratio = delivery_ratio;
515 } else {
516 if latency_us < self.min_latency_us {
517 self.min_latency_us = latency_us;
518 }
519 if latency_us > self.max_latency_us {
520 self.max_latency_us = latency_us;
521 }
522 if delivery_ratio < self.min_delivery_ratio {
523 self.min_delivery_ratio = delivery_ratio;
524 }
525 }
526
527 let n = self.sample_count as f32;
529 self.avg_latency_us = (self.avg_latency_us * n + latency_us) / (n + 1.0);
530 self.avg_delivery_ratio = (self.avg_delivery_ratio * n + delivery_ratio) / (n + 1.0);
531
532 self.sample_count += 1;
533 }
534
535 pub fn get_recommendation(&self) -> String {
537 if self.sample_count == 0 {
538 return "No data available".to_string();
539 }
540
541 match (self.avg_delivery_ratio, self.avg_latency_us) {
542 (ratio, latency) if ratio >= 0.95 && latency <= 10.0 => {
543 "Cluster is healthy. No action needed.".to_string()
544 }
545 (ratio, latency) if ratio >= 0.90 && latency <= 20.0 => {
546 "Cluster is degraded. Consider load balancing.".to_string()
547 }
548 (ratio, latency) if ratio >= 0.80 && latency <= 50.0 => {
549 "Cluster performance is poor. Recommend quantization fallback.".to_string()
550 }
551 _ => "Cluster has failed nodes. Investigate and recover.".to_string(),
552 }
553 }
554}
555
556#[derive(Clone, Debug)]
558pub struct FaultDetector {
559 cluster: Arc<ClusterState>,
561 detection_interval: Duration,
563}
564
565impl FaultDetector {
566 pub fn new(cluster: Arc<ClusterState>, detection_interval: Duration) -> Self {
568 Self {
569 cluster,
570 detection_interval,
571 }
572 }
573
574 pub fn detect_failures(&self) -> Vec<String> {
580 let nodes_snapshot = self.cluster.nodes_snapshot();
581 let now = Instant::now();
582 let mut failed_nodes = Vec::new();
583
584 let mut metrics = self
585 .cluster
586 .metrics
587 .lock()
588 .unwrap_or_else(|poison| poison.into_inner());
589
590 for node in nodes_snapshot.iter() {
591 if let Some(m) = metrics.get_mut(&node.id) {
592 if now.saturating_duration_since(m.last_heartbeat) >= m.heartbeat_timeout {
593 if m.status != NodeStatus::Failed {
600 m.status = NodeStatus::Failed;
601 failed_nodes.push(node.id.clone());
602 }
603 }
604 } else {
605 let mut m = crate::cluster::NodeMetrics::new(
608 node.vram_gb,
609 node.system_memory_gb,
610 node.compute_capability.clone(),
611 Duration::from_secs(5),
612 );
613 m.name = node.id.clone();
614 m.gpu_name = node.gpu_name.clone();
615 m.status = NodeStatus::Failed;
616 metrics.insert(node.id.clone(), m);
617 failed_nodes.push(node.id.clone());
618 }
619 }
620
621 failed_nodes
622 }
623
624 pub fn recover_node(&self, node_id: &str) {
626 self.cluster.get_metrics_mut(node_id, |metrics| {
627 metrics.status = NodeStatus::Active;
628 metrics.last_heartbeat = Instant::now();
629
630 metrics.avg_latency_us = 0.0;
632 metrics.min_latency_us = f32::MAX;
633 metrics.max_latency_us = 0.0;
634 metrics.latency_samples = 0;
635 metrics.delivery_ratio = 1.0;
636 metrics.throughput_gbps = 0.0;
637 });
638 }
639
640 pub fn start_periodic_detection(&self) {
642 let this = self.clone();
643
644 std::thread::spawn(move || loop {
645 let failed = this.detect_failures();
646 if !failed.is_empty() {
647 tracing::warn!(
648 "Detected {} failed nodes: {:?}",
649 failed.len(),
650 failed.iter().map(|s| s.as_str()).collect::<Vec<_>>()
651 );
652 }
653 std::thread::sleep(this.detection_interval);
654 });
655 }
656}
657
658#[cfg(test)]
659mod tests {
660 use super::*;
661 use crate::cluster::ClusterState;
662 use crate::protocol::NodeResources;
663 use std::net::TcpListener;
664 use std::sync::Arc;
665 use std::time::Duration;
666
667 #[test]
668 fn health_monitor_checks_all_nodes() {
669 let cluster = Arc::new(ClusterState::new());
670 cluster.register(NodeResources::new("node-a", 24.0, 64.0, "8.9", None));
671 cluster.register(NodeResources::new("node-b", 12.0, 32.0, "8.6", None));
672
673 let monitor = NetworkHealthMonitor::new(cluster.clone(), HealthConfig::default());
674 monitor.check_all();
675
676 let summary = monitor.get_health_summary();
677 assert!(summary.contains("Total nodes: 2"));
678 }
679
680 #[test]
681 fn health_monitor_marks_fresh_nodes_unknown_without_samples() {
682 let cluster = Arc::new(ClusterState::new());
683 cluster.register(NodeResources::new("node-a", 24.0, 64.0, "8.9", None));
684
685 let monitor = NetworkHealthMonitor::new(cluster, HealthConfig::default());
686 monitor.check_all();
687
688 let node_health = monitor
689 .get_node_health("node-a")
690 .expect("expected health check result for node-a");
691 assert_eq!(node_health.status, HealthStatus::Unknown);
692 }
693
694 #[test]
695 fn health_monitor_detects_degraded_nodes() {
696 let cluster = Arc::new(ClusterState::new());
697 cluster.register(NodeResources::new("node-a", 24.0, 64.0, "8.9", None));
698
699 let monitor = NetworkHealthMonitor::new(cluster.clone(), HealthConfig::default());
700
701 for _ in 0..3 {
704 let result = HealthCheckResult {
705 node_id: "node-a".to_string(),
706 latency_us: 15.0,
707 delivery_ratio: 0.85,
708 status: HealthStatus::Degraded,
709 timestamp: std::time::Instant::now(),
710 };
711 monitor
712 .recent_checks
713 .lock()
714 .unwrap()
715 .entry("node-a".to_string())
716 .or_default()
717 .push_back(result);
718 }
719
720 assert!(monitor.needs_quantization_fallback("node-a"));
721 }
722
723 #[test]
724 fn health_metrics_aggregates() {
725 let mut metrics = HealthMetrics::new();
726
727 metrics.record(1.0, 0.98);
728 metrics.record(2.0, 0.95);
729 metrics.record(1.5, 0.97);
730
731 assert!((metrics.avg_latency_us - 1.5).abs() < 0.1);
732 assert!(metrics.min_latency_us <= 1.0);
733 assert!(metrics.max_latency_us >= 2.0);
734 }
735
736 #[test]
737 fn fault_detector_detects_failures() {
738 let cluster = Arc::new(ClusterState::new());
739 cluster.register(NodeResources::new("node-a", 24.0, 64.0, "8.9", None));
740
741 std::thread::sleep(Duration::from_secs(6));
743
744 let detector = FaultDetector::new(cluster.clone(), Duration::from_secs(1));
745 let failed = detector.detect_failures();
746
747 assert!(failed.contains(&"node-a".to_string()));
748 }
749
750 #[test]
751 fn fault_detector_detects_failures_for_degraded_nodes() {
752 let cluster = Arc::new(ClusterState::new());
758 cluster.register(NodeResources::new("node-a", 24.0, 64.0, "8.9", None));
759 cluster.get_metrics_mut("node-a", |metrics| {
760 metrics.status = NodeStatus::Degraded;
761 });
762
763 std::thread::sleep(Duration::from_secs(6));
764
765 let detector = FaultDetector::new(cluster.clone(), Duration::from_secs(1));
766 let failed = detector.detect_failures();
767
768 assert!(failed.contains(&"node-a".to_string()));
769 assert_eq!(
770 cluster.get_metrics("node-a").unwrap().status,
771 NodeStatus::Failed
772 );
773 }
774
775 #[test]
776 fn fault_detector_recovers_nodes() {
777 let cluster = Arc::new(ClusterState::new());
778 cluster.register(NodeResources::new("node-a", 24.0, 64.0, "8.9", None));
779
780 std::thread::sleep(Duration::from_secs(6));
781
782 let detector = FaultDetector::new(cluster.clone(), Duration::from_secs(1));
783 detector.detect_failures();
784
785 assert!(cluster.get_metrics("node-a").unwrap().status == NodeStatus::Failed);
786
787 detector.recover_node("node-a");
788 assert_eq!(
789 cluster.get_metrics("node-a").unwrap().status,
790 NodeStatus::Active
791 );
792 }
793
794 #[test]
795 fn health_monitor_gets_recommendation() {
796 let mut metrics = HealthMetrics::new();
797
798 metrics.record(1.0, 0.98);
800 metrics.record(2.0, 0.95);
801
802 let recommendation = metrics.get_recommendation();
803 assert!(recommendation.contains("healthy"));
804 }
805
806 #[test]
807 fn get_recommendation_uses_average_latency_not_minimum() {
808 let mut metrics = HealthMetrics::new();
814 metrics.record(1.0, 0.98);
815 metrics.record(200.0, 0.98);
816
817 let recommendation = metrics.get_recommendation();
818 assert!(!recommendation.contains("healthy"));
819 }
820
821 #[test]
822 fn health_config_autotunes_for_gpu_profiles() {
823 let profile = RuntimeProfile {
824 node_resources: NodeResources::new("node-a", 24.0, 64.0, "8.9", None),
825 logical_cores: 16,
826 recommended_workers: 8,
827 acceleration_mode: AccelerationMode::Gpu,
828 gpu_backend: crate::host::GpuBackend::Cuda,
829 xdp_supported: true,
830 detection_source: String::from("test"),
831 probe_mode: crate::host::ProbeMode::Fast,
832 };
833
834 let config = HealthConfig::autotuned(&profile);
835 assert!(config.healthy_latency_us < 10.0);
836 assert!(config.healthy_delivery_ratio > 0.95);
837 }
838
839 #[test]
840 fn get_health_status_fails_on_either_metric_crossing_floor() {
841 let cluster = Arc::new(ClusterState::new());
846 let monitor = NetworkHealthMonitor::new(cluster, HealthConfig::default());
847 let cfg = HealthConfig::default();
848
849 assert_eq!(
851 monitor.get_health_status(cfg.degraded_latency_us * 10.0, 0.99),
852 HealthStatus::Failed
853 );
854 assert_eq!(
856 monitor.get_health_status(1.0, cfg.degraded_delivery_ratio / 2.0),
857 HealthStatus::Failed
858 );
859 assert_eq!(
861 monitor.get_health_status(cfg.degraded_latency_us, cfg.degraded_delivery_ratio),
862 HealthStatus::Degraded
863 );
864 }
865
866 #[test]
867 fn tcp_probe_target_can_mark_node_failed_when_unreachable() {
868 let cluster = Arc::new(ClusterState::new());
869 cluster.register(NodeResources::new("node-a", 24.0, 64.0, "8.9", None));
870
871 let listener = TcpListener::bind("127.0.0.1:0").expect("bind probe listener");
872 let addr = listener.local_addr().expect("listener local addr");
873 drop(listener);
874
875 let monitor = NetworkHealthMonitor::new(cluster.clone(), HealthConfig::default());
876 monitor.register_tcp_probe_target("node-a", addr);
877 monitor.check_all();
878
879 let node_health = monitor
880 .get_node_health("node-a")
881 .expect("expected health check result for node-a");
882 assert_eq!(node_health.status, HealthStatus::Failed);
883 }
884
885 #[test]
886 fn tcp_probe_target_healthy_when_reachable() {
887 let cluster = Arc::new(ClusterState::new());
888 cluster.register(NodeResources::new("node-a", 24.0, 64.0, "8.9", None));
889
890 cluster.get_metrics_mut("node-a", |metrics| {
891 metrics.record_latency(2.0);
892 metrics.record_delivery_ratio(0.99);
893 });
894
895 let listener = TcpListener::bind("127.0.0.1:0").expect("bind probe listener");
896 let addr = listener.local_addr().expect("listener local addr");
897
898 let monitor = NetworkHealthMonitor::new(cluster.clone(), HealthConfig::default());
899 monitor.register_tcp_probe_target("node-a", addr);
900 monitor.check_all();
901
902 let node_health = monitor
903 .get_node_health("node-a")
904 .expect("expected health check result for node-a");
905 assert_eq!(node_health.status, HealthStatus::Healthy);
906 }
907}