Skip to main content

ghostlink_core/
cluster.rs

1//! Thread-Safe Cluster State with Metrics Collection
2//!
3//! This module provides a lock-free, thread-safe cluster state tracking:
4//! - Node capabilities (VRAM, system memory, compute capability)
5//! - Live metrics (latency, delivery ratio, throughput)
6//! - Fault detection and recovery
7
8use std::collections::{HashMap, VecDeque};
9use std::net::SocketAddr;
10use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
11use std::sync::{Arc, Mutex};
12use std::time::{Duration, Instant};
13
14use arc_swap::ArcSwap;
15
16pub use crate::protocol::NodeResources;
17
18const NODE_HISTORY_CAP: usize = 32;
19
20/// Node status enumeration
21#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
22pub enum NodeStatus {
23    /// Node is healthy and accepting traffic
24    #[default]
25    Active,
26    /// Node is degraded (below threshold)
27    Degraded,
28    /// Node has failed or timed out
29    Failed,
30}
31
32/// Metrics for a single node
33#[derive(Clone, Debug)]
34pub struct NodeMetrics {
35    /// Node display name
36    pub name: String,
37    /// Current status
38    pub status: NodeStatus,
39    /// Total VRAM in GB
40    pub vram_gb: f32,
41    /// Total VRAM (alias for vram_gb, used by display/balancer)
42    pub total_vram_gb: f32,
43    /// System memory in GB
44    pub system_memory_gb: f32,
45    /// Compute capability
46    pub compute_capability: String,
47    /// GPU name/model
48    pub gpu_name: Option<String>,
49    /// Resolved IP address of the node
50    pub ip_address: Option<SocketAddr>,
51
52    /// Last heartbeat time
53    pub last_heartbeat: Instant,
54    /// Heartbeat interval threshold
55    pub heartbeat_timeout: Duration,
56
57    /// Average latency in microseconds
58    pub avg_latency_us: f32,
59    /// Minimum observed latency
60    pub min_latency_us: f32,
61    /// Maximum observed latency
62    pub max_latency_us: f32,
63    /// Number of latency samples
64    pub latency_samples: u64,
65
66    /// Delivery ratio (0.0 to 1.0)
67    pub delivery_ratio: f32,
68    /// Throughput in GB/s
69    pub throughput_gbps: f32,
70
71    /// Recent latency history in microseconds
72    pub latency_history_us: VecDeque<f32>,
73    /// Recent throughput history in GB/s
74    pub throughput_history_gbps: VecDeque<f32>,
75
76    /// Current used VRAM in GB
77    pub used_vram_gb: f32,
78    /// Available VRAM in GB
79    pub available_vram_gb: f32,
80
81    /// Number of layers streaming on this node
82    pub streaming_layers: Option<(usize, usize)>,
83
84    /// AF_XDP throughput in Gbps (for display)
85    pub af_xdp_gbps: f32,
86    /// Per-packet latency in microseconds (for display)
87    pub latency_micros: f32,
88    /// Whether delivery_ratio has been initialized (first sample sets directly)
89    pub(crate) delivery_ratio_initialized: bool,
90}
91
92impl Default for NodeMetrics {
93    fn default() -> Self {
94        Self {
95            name: String::new(),
96            status: NodeStatus::Active,
97            vram_gb: 0.0,
98            total_vram_gb: 0.0,
99            system_memory_gb: 0.0,
100            compute_capability: String::new(),
101            gpu_name: None,
102            last_heartbeat: Instant::now(),
103            heartbeat_timeout: Duration::from_secs(5),
104            avg_latency_us: 0.0,
105            min_latency_us: f32::MAX,
106            max_latency_us: 0.0,
107            latency_samples: 0,
108            delivery_ratio: 1.0,
109            throughput_gbps: 0.0,
110            latency_history_us: VecDeque::with_capacity(NODE_HISTORY_CAP),
111            throughput_history_gbps: VecDeque::with_capacity(NODE_HISTORY_CAP),
112            used_vram_gb: 0.0,
113            available_vram_gb: 0.0,
114            streaming_layers: None,
115            af_xdp_gbps: 0.0,
116            latency_micros: 0.0,
117            delivery_ratio_initialized: false,
118            ip_address: None,
119        }
120    }
121}
122
123impl NodeMetrics {
124    /// Create new metrics for a node
125    pub fn new(
126        vram_gb: f32,
127        system_memory_gb: f32,
128        compute_capability: String,
129        heartbeat_timeout: Duration,
130    ) -> Self {
131        Self {
132            name: String::new(),
133            status: NodeStatus::Active,
134            vram_gb,
135            total_vram_gb: vram_gb,
136            system_memory_gb,
137            compute_capability,
138            gpu_name: None,
139            ip_address: None,
140            last_heartbeat: Instant::now(),
141            heartbeat_timeout,
142            avg_latency_us: 0.0,
143            min_latency_us: f32::MAX,
144            max_latency_us: 0.0,
145            latency_samples: 0,
146            delivery_ratio: 1.0,
147            throughput_gbps: 0.0,
148            latency_history_us: VecDeque::with_capacity(NODE_HISTORY_CAP),
149            throughput_history_gbps: VecDeque::with_capacity(NODE_HISTORY_CAP),
150            used_vram_gb: 0.0,
151            available_vram_gb: vram_gb,
152            streaming_layers: None,
153            af_xdp_gbps: 0.0,
154            latency_micros: 0.0,
155            delivery_ratio_initialized: false,
156        }
157    }
158
159    /// Update metrics with new latency sample (EMA with alpha=0.1)
160    pub fn record_latency(&mut self, latency_us: f32) {
161        if latency_us < self.min_latency_us {
162            self.min_latency_us = latency_us;
163        }
164        if latency_us > self.max_latency_us {
165            self.max_latency_us = latency_us;
166        }
167
168        self.latency_samples += 1;
169        if self.latency_samples == 1 {
170            self.avg_latency_us = latency_us;
171        } else {
172            self.avg_latency_us = self.avg_latency_us * 0.9 + latency_us * 0.1;
173        }
174
175        if self.latency_history_us.len() >= NODE_HISTORY_CAP {
176            self.latency_history_us.pop_front();
177        }
178        self.latency_history_us.push_back(latency_us);
179    }
180
181    /// Update metrics with new delivery ratio sample
182    pub fn record_delivery_ratio(&mut self, ratio: f32) {
183        if !self.delivery_ratio_initialized {
184            self.delivery_ratio = ratio;
185            self.delivery_ratio_initialized = true;
186        } else {
187            // Exponential moving average with alpha=0.1
188            self.delivery_ratio = self.delivery_ratio * 0.9 + ratio * 0.1;
189        }
190    }
191
192    /// Update metrics with new throughput sample
193    pub fn record_throughput(&mut self, throughput_gbps: f32) {
194        // Exponential moving average with alpha=0.1
195        self.throughput_gbps = self.throughput_gbps * 0.9 + throughput_gbps * 0.1;
196
197        if self.throughput_history_gbps.len() >= NODE_HISTORY_CAP {
198            self.throughput_history_gbps.pop_front();
199        }
200        self.throughput_history_gbps.push_back(throughput_gbps);
201    }
202
203    /// Update used VRAM
204    pub fn record_vram_usage(&mut self, used_vram_gb: f32) {
205        self.used_vram_gb = used_vram_gb;
206        self.available_vram_gb = self.total_vram_gb - used_vram_gb;
207    }
208
209    /// Set streaming layers
210    pub fn set_streaming_layers(&mut self, start: usize, end: usize) {
211        self.streaming_layers = Some((start, end));
212    }
213
214    /// Clear streaming layers
215    pub fn clear_streaming_layers(&mut self) {
216        self.streaming_layers = None;
217    }
218}
219
220/// Cluster state with thread-safe metrics collection
221#[derive(Debug)]
222pub struct ClusterState {
223    /// Map of node ID to resources and metrics
224    nodes: Arc<Mutex<HashMap<String, NodeResources>>>,
225    /// Cached shared snapshot of nodes for read-heavy paths
226    nodes_snapshot: Arc<ArcSwap<Vec<NodeResources>>>,
227    /// Indicates whether the shared snapshot needs to be refreshed
228    nodes_snapshot_dirty: Arc<AtomicBool>,
229    /// Map of node ID to live metrics
230    pub(crate) metrics: Arc<Mutex<HashMap<String, NodeMetrics>>>,
231    /// Last cluster update timestamp
232    last_update: Arc<AtomicU64>,
233    /// Cached total VRAM across all registered nodes
234    total_vram_cache: Arc<AtomicU64>,
235    /// Cached total system memory across all registered nodes
236    total_system_memory_cache: Arc<AtomicU64>,
237    /// Per-node circuit breakers for the TCP transport bridge (`runtime::spawn_tcp_bridge`).
238    /// Lazily created on first use via `circuit_breaker_for` and kept here — rather than
239    /// inside a single pipeline execution — so failures accumulate *across* pipeline runs
240    /// targeting the same node: a node that's been chronically unreachable trips open and
241    /// stays fail-fast instead of re-running the full connect/backoff dance every call.
242    circuit_breakers: Arc<Mutex<HashMap<String, crate::circuit_breaker::CircuitBreaker>>>,
243}
244
245impl Clone for ClusterState {
246    fn clone(&self) -> Self {
247        Self {
248            nodes: Arc::clone(&self.nodes),
249            nodes_snapshot: Arc::clone(&self.nodes_snapshot),
250            nodes_snapshot_dirty: Arc::clone(&self.nodes_snapshot_dirty),
251            metrics: Arc::clone(&self.metrics),
252            last_update: Arc::clone(&self.last_update),
253            total_vram_cache: Arc::clone(&self.total_vram_cache),
254            total_system_memory_cache: Arc::clone(&self.total_system_memory_cache),
255            circuit_breakers: Arc::clone(&self.circuit_breakers),
256        }
257    }
258}
259
260impl Default for ClusterState {
261    fn default() -> Self {
262        Self::new()
263    }
264}
265
266impl ClusterState {
267    /// Create new cluster state
268    pub fn new() -> Self {
269        Self {
270            nodes: Arc::new(Mutex::new(HashMap::new())),
271            nodes_snapshot: Arc::new(ArcSwap::from_pointee(Vec::<NodeResources>::new())),
272            nodes_snapshot_dirty: Arc::new(AtomicBool::new(false)),
273            metrics: Arc::new(Mutex::new(HashMap::new())),
274            last_update: Arc::new(AtomicU64::new(0)),
275            total_vram_cache: Arc::new(AtomicU64::new(0.0_f64.to_bits())),
276            total_system_memory_cache: Arc::new(AtomicU64::new(0.0_f64.to_bits())),
277            circuit_breakers: Arc::new(Mutex::new(HashMap::new())),
278        }
279    }
280
281    /// Gets (creating with default config on first use) the circuit breaker
282    /// tracking TCP transport failures for one node. The returned handle is
283    /// a cheap `Arc`-backed clone sharing state with every other caller for
284    /// the same `node_id` — recording a failure through one clone is visible
285    /// to all of them.
286    pub fn circuit_breaker_for(&self, node_id: &str) -> crate::circuit_breaker::CircuitBreaker {
287        let mut breakers = self
288            .circuit_breakers
289            .lock()
290            .unwrap_or_else(|poison| poison.into_inner());
291        breakers.entry(node_id.to_string()).or_default().clone()
292    }
293
294    /// Register a new node with the cluster
295    pub fn register(&self, node: NodeResources) {
296        self.register_with_addr(node, None);
297    }
298
299    /// Register a new node with a specific socket address
300    pub fn register_with_addr(&self, node: NodeResources, addr: Option<SocketAddr>) {
301        let mut nodes = self
302            .nodes
303            .lock()
304            .unwrap_or_else(|poison| poison.into_inner());
305        let mut metrics = self
306            .metrics
307            .lock()
308            .unwrap_or_else(|poison| poison.into_inner());
309
310        // Optimization: Zero-allocation node re-registration path.
311        // Avoid cloning `node` or its string fields when capability resources are unchanged,
312        // move owned `node` directly into existing map entries on resource updates,
313        // and skip atomic cache operations when VRAM and memory deltas are zero.
314        let vram_gb = node.vram_gb;
315        let system_memory_gb = node.system_memory_gb;
316        let mut vram_delta = vram_gb;
317        let mut system_memory_delta = system_memory_gb;
318        let mut resources_changed = true;
319
320        // Optimization: Zero-allocation, single-pass node registration path.
321        // Updates metrics and resources in-place using zero-cost ownership moves (no node.clone()),
322        // eliminating secondary HashMap lookups while avoiding atomic cache invalidations
323        // when resource capabilities remain unchanged.
324        if let Some(existing_metrics) = metrics.get_mut(&node.id) {
325            existing_metrics.vram_gb = vram_gb;
326            existing_metrics.total_vram_gb = vram_gb;
327            existing_metrics.system_memory_gb = system_memory_gb;
328            if existing_metrics.compute_capability != node.compute_capability {
329                existing_metrics
330                    .compute_capability
331                    .clone_from(&node.compute_capability);
332            }
333            if existing_metrics.gpu_name != node.gpu_name {
334                existing_metrics.gpu_name.clone_from(&node.gpu_name);
335            }
336            existing_metrics.heartbeat_timeout = Duration::from_secs(5);
337            if addr.is_some() {
338                existing_metrics.ip_address = addr;
339            }
340        } else {
341            let node_id = node.id.clone();
342            let mut node_metrics = NodeMetrics::new(
343                vram_gb,
344                system_memory_gb,
345                node.compute_capability.clone(),
346                Duration::from_secs(5),
347            );
348            node_metrics.name = node_id.clone();
349            node_metrics.gpu_name = node.gpu_name.clone();
350            node_metrics.ip_address = addr;
351            metrics.insert(node_id, node_metrics);
352        }
353
354        if let Some(existing) = nodes.get_mut(&node.id) {
355            vram_delta = vram_gb - existing.vram_gb;
356            system_memory_delta = system_memory_gb - existing.system_memory_gb;
357
358            // Check if node resource fields actually changed to avoid snapshot invalidation churn
359            if existing.vram_gb == node.vram_gb
360                && existing.system_memory_gb == node.system_memory_gb
361                && existing.compute_capability == node.compute_capability
362                && existing.gpu_name == node.gpu_name
363                && existing.rpc_port == node.rpc_port
364                && existing.rpc_build_id == node.rpc_build_id
365            {
366                resources_changed = false;
367            } else {
368                *existing = node; // Zero-cost ownership move into existing entry
369            }
370        } else {
371            let node_id = node.id.clone();
372            nodes.insert(node_id, node); // Zero-cost ownership move into new map entry
373        }
374
375        if resources_changed {
376            self.nodes_snapshot_dirty.store(true, Ordering::Release);
377        }
378
379        if vram_delta != 0.0 {
380            let current_total_vram = f64::from_bits(self.total_vram_cache.load(Ordering::Acquire));
381            self.total_vram_cache.store(
382                (current_total_vram + vram_delta as f64).to_bits(),
383                Ordering::Release,
384            );
385        }
386
387        if system_memory_delta != 0.0 {
388            let current_total_system_mem =
389                f64::from_bits(self.total_system_memory_cache.load(Ordering::Acquire));
390            self.total_system_memory_cache.store(
391                (current_total_system_mem + system_memory_delta as f64).to_bits(),
392                Ordering::Release,
393            );
394        }
395
396        self.last_update.store(
397            std::time::SystemTime::now()
398                .duration_since(std::time::UNIX_EPOCH)
399                .unwrap_or(Duration::ZERO)
400                .as_millis() as u64,
401            Ordering::Release,
402        );
403    }
404
405    /// Get all nodes
406    pub fn nodes(&self) -> Vec<NodeResources> {
407        self.nodes_snapshot().as_ref().to_vec()
408    }
409
410    /// Get total number of registered nodes.
411    pub fn node_count(&self) -> usize {
412        self.nodes_snapshot().len()
413    }
414
415    /// Get a shared snapshot of all nodes
416    pub fn nodes_snapshot(&self) -> Arc<Vec<NodeResources>> {
417        if self.nodes_snapshot_dirty.load(Ordering::Relaxed)
418            && self.nodes_snapshot_dirty.swap(false, Ordering::AcqRel)
419        {
420            let nodes_map = self
421                .nodes
422                .lock()
423                .unwrap_or_else(|poison| poison.into_inner());
424            let mut nodes: Vec<_> = nodes_map.values().cloned().collect();
425            // Sort by ID to ensure deterministic layer assignment across runs
426            nodes.sort_unstable_by(|a, b| a.id.cmp(&b.id));
427            self.nodes_snapshot.store(Arc::new(nodes));
428        }
429        self.nodes_snapshot.load_full()
430    }
431
432    /// Execute a closure with a read lock on the metrics map, avoiding clones and multiple acquisitions.
433    pub fn with_metrics<F, R>(&self, f: F) -> R
434    where
435        F: FnOnce(&HashMap<String, NodeMetrics>) -> R,
436    {
437        let metrics = self
438            .metrics
439            .lock()
440            .unwrap_or_else(|poison| poison.into_inner());
441        f(&metrics)
442    }
443
444    /// Get metrics for a specific node
445    pub fn get_metrics(&self, node_id: &str) -> Option<NodeMetrics> {
446        let metrics = self
447            .metrics
448            .lock()
449            .unwrap_or_else(|poison| poison.into_inner());
450        metrics.get(node_id).cloned()
451    }
452
453    /// Update last heartbeat for a node
454    pub fn update_heartbeat(&self, node_id: &str) {
455        self.get_metrics_mut(node_id, |metrics| {
456            metrics.last_heartbeat = Instant::now();
457        });
458    }
459
460    /// Get metrics mutable reference (for internal updates)
461    pub fn get_metrics_mut<F, R>(&self, node_id: &str, f: F) -> Option<R>
462    where
463        F: FnOnce(&mut NodeMetrics) -> R,
464    {
465        let mut metrics = self
466            .metrics
467            .lock()
468            .unwrap_or_else(|poison| poison.into_inner());
469        metrics.get_mut(node_id).map(f)
470    }
471
472    /// Check if a node has timed out
473    pub fn check_heartbeat_timeout(&self, node_id: &str) -> bool {
474        // Optimize: Inspect node metrics directly under lock without cloning NodeMetrics struct.
475        let metrics = self
476            .metrics
477            .lock()
478            .unwrap_or_else(|poison| poison.into_inner());
479        if let Some(m) = metrics.get(node_id) {
480            Instant::now().duration_since(m.last_heartbeat) >= m.heartbeat_timeout
481        } else {
482            false
483        }
484    }
485
486    /// Mark a node as failed due to timeout
487    pub fn mark_failed(&self, node_id: &str) {
488        self.get_metrics_mut(node_id, |metrics| {
489            metrics.status = NodeStatus::Failed;
490        });
491    }
492
493    /// Check heartbeat timeouts across all nodes and mark timed-out nodes as failed in a single pass.
494    ///
495    /// OPTIMIZATION: Acquires cluster metrics lock once to inspect heartbeat timeouts and
496    /// update node status in-place. Eliminates repeated Mutex acquisitions (1 + 2N lock ops),
497    /// String heap allocations, and HashMap key lookups during periodic health checks.
498    pub fn mark_timed_out_nodes_failed(&self) -> usize {
499        let now = Instant::now();
500        let mut failed_count = 0;
501        let mut metrics = self
502            .metrics
503            .lock()
504            .unwrap_or_else(|poison| poison.into_inner());
505
506        for m in metrics.values_mut() {
507            if m.status != NodeStatus::Failed
508                && now.saturating_duration_since(m.last_heartbeat) >= m.heartbeat_timeout
509            {
510                m.status = NodeStatus::Failed;
511                failed_count += 1;
512            }
513        }
514        failed_count
515    }
516
517    /// Recover a failed node
518    pub fn recover_node(&self, node_id: &str) {
519        self.get_metrics_mut(node_id, |metrics| {
520            metrics.status = NodeStatus::Active;
521        });
522    }
523
524    /// Get all active nodes
525    pub fn active_nodes(&self) -> Vec<NodeMetrics> {
526        let metrics = self
527            .metrics
528            .lock()
529            .unwrap_or_else(|poison| poison.into_inner());
530        metrics
531            .values()
532            .filter(|m| m.status == NodeStatus::Active)
533            .cloned()
534            .collect()
535    }
536
537    /// Get the count of active nodes without cloning metrics
538    pub fn active_nodes_count(&self) -> usize {
539        let metrics = self
540            .metrics
541            .lock()
542            .unwrap_or_else(|poison| poison.into_inner());
543        metrics
544            .values()
545            .filter(|m| m.status == NodeStatus::Active)
546            .count()
547    }
548
549    /// Get total cluster VRAM
550    pub fn total_vram_gb(&self) -> f32 {
551        f64::from_bits(self.total_vram_cache.load(Ordering::Acquire)) as f32
552    }
553
554    /// Get total system memory
555    pub fn total_system_memory_gb(&self) -> f32 {
556        f64::from_bits(self.total_system_memory_cache.load(Ordering::Acquire)) as f32
557    }
558}
559
560/// Cluster health monitor with periodic checks
561#[derive(Clone, Debug)]
562pub struct ClusterHealthMonitor {
563    cluster: Arc<ClusterState>,
564    /// Health check interval
565    check_interval: Duration,
566}
567
568impl ClusterHealthMonitor {
569    /// Create new health monitor
570    pub fn new(cluster: Arc<ClusterState>, check_interval: Duration) -> Self {
571        Self {
572            cluster,
573            check_interval,
574        }
575    }
576
577    /// Run health check on all nodes
578    pub fn check_health(&self) {
579        self.cluster.mark_timed_out_nodes_failed();
580
581        // Update last update timestamp
582        self.cluster.last_update.store(
583            std::time::SystemTime::now()
584                .duration_since(std::time::UNIX_EPOCH)
585                .unwrap_or(Duration::ZERO)
586                .as_millis() as u64,
587            Ordering::Release,
588        );
589    }
590
591    /// Get health report
592    pub fn health_report(&self) -> String {
593        let active_count = self.cluster.active_nodes_count();
594        let total_nodes = self.cluster.nodes_snapshot().len();
595
596        format!(
597            "Cluster Health Report\n\
598             =================\n\
599             Active nodes: {}/{}\n\
600             Total VRAM: {:.1} GB\n\
601             System memory: {:.1} GB\n",
602            active_count,
603            total_nodes,
604            self.cluster.total_vram_gb(),
605            self.cluster.total_system_memory_gb()
606        )
607    }
608
609    /// Run periodic health checks in background
610    pub fn start_periodic_checks(&self) {
611        let this = self.clone();
612
613        // Spawn health check task
614        std::thread::spawn(move || loop {
615            this.check_health();
616            std::thread::sleep(this.check_interval);
617        });
618    }
619}
620
621#[cfg(test)]
622mod tests {
623    use super::*;
624    use std::sync::Arc;
625    use std::thread;
626    use std::time::Duration;
627
628    #[test]
629    fn register_replaces_existing_nodes() {
630        let cluster = ClusterState::new();
631        cluster.register(NodeResources::new("node-a", 24.0, 64.0, "8.9", None));
632        cluster.register(NodeResources::new("node-a", 48.0, 128.0, "9.0", None));
633
634        assert_eq!(cluster.nodes().len(), 1);
635        assert_eq!(cluster.nodes()[0].vram_gb, 48.0);
636    }
637
638    #[test]
639    fn heartbeat_timeout_detection() {
640        let cluster = ClusterState::new();
641        cluster.register(NodeResources::new("node-a", 24.0, 64.0, "8.9", None));
642
643        // Simulate timeout by waiting longer than default heartbeat timeout
644        thread::sleep(Duration::from_secs(6));
645
646        assert!(cluster.check_heartbeat_timeout("node-a"));
647    }
648
649    #[test]
650    fn node_metrics_keep_recent_latency_and_throughput_history() {
651        let mut metrics = NodeMetrics::default();
652
653        for idx in 0..40 {
654            metrics.record_latency(idx as f32);
655            metrics.record_throughput(idx as f32 / 10.0);
656        }
657
658        assert_eq!(metrics.latency_history_us.len(), NODE_HISTORY_CAP);
659        assert_eq!(metrics.throughput_history_gbps.len(), NODE_HISTORY_CAP);
660        assert_eq!(metrics.latency_history_us.front().copied(), Some(8.0));
661        assert_eq!(metrics.throughput_history_gbps.back().copied(), Some(3.9));
662    }
663
664    #[test]
665    fn health_monitor_reports_active_count() {
666        let cluster = Arc::new(ClusterState::new());
667        cluster.register(NodeResources::new("node-a", 24.0, 64.0, "8.9", None));
668        cluster.register(NodeResources::new("node-b", 12.0, 32.0, "8.6", None));
669
670        let monitor = ClusterHealthMonitor::new(cluster.clone(), Duration::from_secs(1));
671        monitor.check_health();
672
673        let report = monitor.health_report();
674        assert!(report.contains("Active nodes: 2/2"));
675    }
676
677    #[test]
678    fn metrics_record_latency() {
679        let mut metrics = NodeMetrics::new(24.0, 64.0, "8.9".to_string(), Duration::from_secs(5));
680
681        metrics.record_latency(1.0);
682        assert_eq!(metrics.avg_latency_us, 1.0);
683        assert_eq!(metrics.min_latency_us, 1.0);
684        assert_eq!(metrics.max_latency_us, 1.0);
685
686        metrics.record_latency(2.0);
687        // EMA with alpha=0.1: (1.0 * 0.9) + 2.0 * 0.1 = 0.9 + 0.2 = 1.1
688        assert!((metrics.avg_latency_us - 1.1).abs() < 1e-6);
689    }
690
691    #[test]
692    fn metrics_record_delivery_ratio() {
693        let mut metrics = NodeMetrics::new(24.0, 64.0, "8.9".to_string(), Duration::from_secs(5));
694
695        metrics.record_delivery_ratio(0.98);
696        assert!((metrics.delivery_ratio - 0.98).abs() < 1e-6);
697
698        metrics.record_delivery_ratio(0.90);
699        // EMA: 0.98 * 0.9 + 0.90 * 0.1 = 0.882 + 0.09 = 0.972
700        assert!((metrics.delivery_ratio - 0.972).abs() < 1e-6);
701    }
702
703    #[test]
704    fn with_metrics_allows_single_pass_inspection() {
705        let cluster = 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 total_nodes = cluster.with_metrics(|metrics_map| {
710            assert!(metrics_map.contains_key("node-a"));
711            assert!(metrics_map.contains_key("node-b"));
712            metrics_map.len()
713        });
714
715        assert_eq!(total_nodes, 2);
716    }
717
718    #[test]
719    fn cluster_tracks_total_vram() {
720        let cluster = ClusterState::new();
721        cluster.register(NodeResources::new("node-a", 24.0, 64.0, "8.9", None));
722        cluster.register(NodeResources::new("node-b", 12.0, 32.0, "8.6", None));
723
724        assert_eq!(cluster.total_vram_gb(), 36.0);
725    }
726
727    // ========================================================================
728    // NEW: Comprehensive Health Monitoring & Failure Recovery Tests
729    // ========================================================================
730
731    #[test]
732    fn cluster_health_monitor_checks_all_nodes() {
733        let cluster = Arc::new(ClusterState::new());
734        cluster.register(NodeResources::new("node-a", 24.0, 64.0, "8.9", None));
735        cluster.register(NodeResources::new("node-b", 12.0, 32.0, "8.6", None));
736
737        let monitor = ClusterHealthMonitor::new(cluster.clone(), Duration::from_millis(100));
738        monitor.check_health();
739
740        let report = monitor.health_report();
741        assert!(
742            report.contains("Active nodes: 2/2"),
743            "Report should show both nodes as active"
744        );
745    }
746
747    #[test]
748    fn cluster_metrics_use_exponential_moving_average() {
749        let mut metrics = NodeMetrics::new(24.0, 64.0, "8.9".to_string(), Duration::from_secs(5));
750
751        // Record sequence of latencies: 1, 2, 3
752        metrics.record_latency(1.0);
753        let avg1 = metrics.avg_latency_us;
754        assert_eq!(avg1, 1.0);
755
756        metrics.record_latency(2.0);
757        let avg2 = metrics.avg_latency_us;
758        // EMA: 1.0 * 0.9 + 2.0 * 0.1 = 1.1
759        assert!(
760            (avg2 - 1.1).abs() < 1e-6,
761            "EMA calculation: expected 1.1, got {avg2}"
762        );
763
764        metrics.record_latency(3.0);
765        let avg3 = metrics.avg_latency_us;
766        // EMA: 1.1 * 0.9 + 3.0 * 0.1 = 0.99 + 0.3 = 1.29
767        assert!(
768            (avg3 - 1.29).abs() < 1e-5,
769            "EMA calculation: expected 1.29, got {avg3}"
770        );
771    }
772
773    #[test]
774    fn cluster_delivery_ratio_tracks_network_quality() {
775        let mut metrics = NodeMetrics::new(24.0, 64.0, "8.9".to_string(), Duration::from_secs(5));
776
777        metrics.record_delivery_ratio(0.98);
778        assert!((metrics.delivery_ratio - 0.98).abs() < 1e-6);
779
780        metrics.record_delivery_ratio(0.95);
781        // EMA: 0.98 * 0.9 + 0.95 * 0.1 = 0.882 + 0.095 = 0.977
782        assert!((metrics.delivery_ratio - 0.977).abs() < 1e-6);
783
784        // Simulate degradation
785        metrics.record_delivery_ratio(0.80);
786        // EMA: 0.977 * 0.9 + 0.80 * 0.1 = 0.8793 + 0.08 = 0.9593
787        assert!(
788            metrics.delivery_ratio < 0.98,
789            "Delivery ratio should degrade"
790        );
791        assert!(metrics.delivery_ratio > 0.90, "But recover somewhat");
792    }
793
794    #[test]
795    fn cluster_concurrent_metric_updates() {
796        let cluster = Arc::new(ClusterState::new());
797        cluster.register(NodeResources::new("node-a", 24.0, 64.0, "8.9", None));
798
799        let mut handles = vec![];
800
801        // Spawn 10 threads updating metrics concurrently
802        for _ in 0..10 {
803            let cluster_clone = Arc::clone(&cluster);
804            let handle = thread::spawn(move || {
805                let nodes = cluster_clone.nodes();
806                for _ in 0..100 {
807                    // Update would happen here in real usage
808                    let _ = nodes.len();
809                }
810            });
811            handles.push(handle);
812        }
813
814        for handle in handles {
815            handle.join().unwrap();
816        }
817
818        // Cluster should remain consistent
819        assert_eq!(cluster.nodes().len(), 1);
820        assert_eq!(cluster.total_vram_gb(), 24.0);
821    }
822
823    #[test]
824    fn cluster_handles_rapid_registration_churn() {
825        let cluster = Arc::new(ClusterState::new());
826
827        // Rapidly register and reregister nodes
828        for iteration in 0..5 {
829            for i in 0..10 {
830                cluster.register(NodeResources::new(
831                    format!("node-{i}"),
832                    24.0 + (iteration as f32),
833                    64.0,
834                    "8.9",
835                    None,
836                ));
837            }
838
839            let nodes = cluster.nodes();
840            assert_eq!(
841                nodes.len(),
842                10,
843                "Should have 10 nodes at iteration {iteration}"
844            );
845
846            // Verify VRAM reflects latest registration
847            let expected_vram: f32 = (0..10).map(|_| 24.0 + iteration as f32).sum();
848            let actual = cluster.total_vram_gb();
849            assert!((actual - expected_vram).abs() < 0.1);
850        }
851    }
852
853    #[test]
854    fn cluster_health_monitor_reports_accurate_status() {
855        let cluster = Arc::new(ClusterState::new());
856        cluster.register(NodeResources::new("node-a", 24.0, 64.0, "8.9", None));
857        cluster.register(NodeResources::new("node-b", 12.0, 32.0, "8.6", None));
858
859        let monitor = ClusterHealthMonitor::new(cluster.clone(), Duration::from_millis(100));
860        monitor.check_health();
861
862        let report = monitor.health_report();
863        assert!(
864            report.contains("Active nodes: 2"),
865            "Should report 2 active nodes"
866        );
867        assert!(report.contains("2/2"), "Should show 2 of 2 nodes");
868    }
869
870    #[test]
871    fn node_metrics_track_min_max_latency() {
872        let mut metrics = NodeMetrics::new(24.0, 64.0, "8.9".to_string(), Duration::from_secs(5));
873
874        metrics.record_latency(2.0);
875        assert_eq!(metrics.min_latency_us, 2.0);
876        assert_eq!(metrics.max_latency_us, 2.0);
877
878        metrics.record_latency(1.0);
879        assert_eq!(metrics.min_latency_us, 1.0);
880        assert_eq!(metrics.max_latency_us, 2.0);
881
882        metrics.record_latency(5.0);
883        assert_eq!(metrics.min_latency_us, 1.0);
884        assert_eq!(metrics.max_latency_us, 5.0);
885
886        metrics.record_latency(3.0);
887        assert_eq!(metrics.min_latency_us, 1.0);
888        assert_eq!(metrics.max_latency_us, 5.0);
889    }
890}