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;
9use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
10use std::sync::{Arc, Mutex};
11use std::time::{Duration, Instant};
12
13use arc_swap::ArcSwap;
14
15pub use crate::protocol::NodeResources;
16
17/// Node status enumeration
18#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
19pub enum NodeStatus {
20    /// Node is healthy and accepting traffic
21    #[default]
22    Active,
23    /// Node is degraded (below threshold)
24    Degraded,
25    /// Node has failed or timed out
26    Failed,
27}
28
29/// Metrics for a single node
30#[derive(Clone, Debug)]
31pub struct NodeMetrics {
32    /// Node display name
33    pub name: String,
34    /// Current status
35    pub status: NodeStatus,
36    /// Total VRAM in GB
37    pub vram_gb: f32,
38    /// Total VRAM (alias for vram_gb, used by display/balancer)
39    pub total_vram_gb: f32,
40    /// System memory in GB
41    pub system_memory_gb: f32,
42    /// Compute capability
43    pub compute_capability: String,
44    /// GPU name/model
45    pub gpu_name: Option<String>,
46
47    /// Last heartbeat time
48    pub last_heartbeat: Instant,
49    /// Heartbeat interval threshold
50    pub heartbeat_timeout: Duration,
51
52    /// Average latency in microseconds
53    pub avg_latency_us: f32,
54    /// Minimum observed latency
55    pub min_latency_us: f32,
56    /// Maximum observed latency
57    pub max_latency_us: f32,
58    /// Number of latency samples
59    pub latency_samples: u64,
60
61    /// Delivery ratio (0.0 to 1.0)
62    pub delivery_ratio: f32,
63    /// Throughput in GB/s
64    pub throughput_gbps: f32,
65
66    /// Current used VRAM in GB
67    pub used_vram_gb: f32,
68    /// Available VRAM in GB
69    pub available_vram_gb: f32,
70
71    /// Number of layers streaming on this node
72    pub streaming_layers: Option<(usize, usize)>,
73
74    /// AF_XDP throughput in Gbps (for display)
75    pub af_xdp_gbps: f32,
76    /// Per-packet latency in microseconds (for display)
77    pub latency_micros: f32,
78    /// Whether delivery_ratio has been initialized (first sample sets directly)
79    pub(crate) delivery_ratio_initialized: bool,
80}
81
82impl Default for NodeMetrics {
83    fn default() -> Self {
84        Self {
85            name: String::new(),
86            status: NodeStatus::Active,
87            vram_gb: 0.0,
88            total_vram_gb: 0.0,
89            system_memory_gb: 0.0,
90            compute_capability: String::new(),
91            gpu_name: None,
92            last_heartbeat: Instant::now(),
93            heartbeat_timeout: Duration::from_secs(5),
94            avg_latency_us: 0.0,
95            min_latency_us: f32::MAX,
96            max_latency_us: 0.0,
97            latency_samples: 0,
98            delivery_ratio: 1.0,
99            throughput_gbps: 0.0,
100            used_vram_gb: 0.0,
101            available_vram_gb: 0.0,
102            streaming_layers: None,
103            af_xdp_gbps: 0.0,
104            latency_micros: 0.0,
105            delivery_ratio_initialized: false,
106        }
107    }
108}
109
110impl NodeMetrics {
111    /// Create new metrics for a node
112    pub fn new(
113        vram_gb: f32,
114        system_memory_gb: f32,
115        compute_capability: String,
116        heartbeat_timeout: Duration,
117    ) -> Self {
118        Self {
119            name: String::new(),
120            status: NodeStatus::Active,
121            vram_gb,
122            total_vram_gb: vram_gb,
123            system_memory_gb,
124            compute_capability,
125            gpu_name: None,
126            last_heartbeat: Instant::now(),
127            heartbeat_timeout,
128            avg_latency_us: 0.0,
129            min_latency_us: f32::MAX,
130            max_latency_us: 0.0,
131            latency_samples: 0,
132            delivery_ratio: 1.0,
133            throughput_gbps: 0.0,
134            used_vram_gb: 0.0,
135            available_vram_gb: vram_gb,
136            streaming_layers: None,
137            af_xdp_gbps: 0.0,
138            latency_micros: 0.0,
139            delivery_ratio_initialized: false,
140        }
141    }
142
143    /// Update metrics with new latency sample (EMA with alpha=0.1)
144    pub fn record_latency(&mut self, latency_us: f32) {
145        if latency_us < self.min_latency_us {
146            self.min_latency_us = latency_us;
147        }
148        if latency_us > self.max_latency_us {
149            self.max_latency_us = latency_us;
150        }
151
152        self.latency_samples += 1;
153        if self.latency_samples == 1 {
154            self.avg_latency_us = latency_us;
155        } else {
156            self.avg_latency_us = self.avg_latency_us * 0.9 + latency_us * 0.1;
157        }
158    }
159
160    /// Update metrics with new delivery ratio sample
161    pub fn record_delivery_ratio(&mut self, ratio: f32) {
162        if !self.delivery_ratio_initialized {
163            self.delivery_ratio = ratio;
164            self.delivery_ratio_initialized = true;
165        } else {
166            // Exponential moving average with alpha=0.1
167            self.delivery_ratio = self.delivery_ratio * 0.9 + ratio * 0.1;
168        }
169    }
170
171    /// Update metrics with new throughput sample
172    pub fn record_throughput(&mut self, throughput_gbps: f32) {
173        // Exponential moving average with alpha=0.1
174        self.throughput_gbps = self.throughput_gbps * 0.9 + throughput_gbps * 0.1;
175    }
176
177    /// Update used VRAM
178    pub fn record_vram_usage(&mut self, used_vram_gb: f32) {
179        self.used_vram_gb = used_vram_gb;
180        self.available_vram_gb = self.total_vram_gb - used_vram_gb;
181    }
182
183    /// Set streaming layers
184    pub fn set_streaming_layers(&mut self, start: usize, end: usize) {
185        self.streaming_layers = Some((start, end));
186    }
187
188    /// Clear streaming layers
189    pub fn clear_streaming_layers(&mut self) {
190        self.streaming_layers = None;
191    }
192}
193
194/// Cluster state with thread-safe metrics collection
195#[derive(Debug)]
196pub struct ClusterState {
197    /// Map of node ID to resources and metrics
198    nodes: Arc<Mutex<HashMap<String, NodeResources>>>,
199    /// Cached shared snapshot of nodes for read-heavy paths
200    nodes_snapshot: Arc<ArcSwap<Vec<NodeResources>>>,
201    /// Indicates whether the shared snapshot needs to be refreshed
202    nodes_snapshot_dirty: Arc<AtomicBool>,
203    /// Map of node ID to live metrics
204    metrics: Arc<Mutex<HashMap<String, NodeMetrics>>>,
205    /// Last cluster update timestamp
206    last_update: Arc<AtomicU64>,
207    /// Cached total VRAM across all registered nodes
208    total_vram_cache: Arc<AtomicU64>,
209}
210
211impl Clone for ClusterState {
212    fn clone(&self) -> Self {
213        Self {
214            nodes: Arc::clone(&self.nodes),
215            nodes_snapshot: Arc::clone(&self.nodes_snapshot),
216            nodes_snapshot_dirty: Arc::clone(&self.nodes_snapshot_dirty),
217            metrics: Arc::clone(&self.metrics),
218            last_update: Arc::clone(&self.last_update),
219            total_vram_cache: Arc::clone(&self.total_vram_cache),
220        }
221    }
222}
223
224impl Default for ClusterState {
225    fn default() -> Self {
226        Self::new()
227    }
228}
229
230impl ClusterState {
231    /// Create new cluster state
232    pub fn new() -> Self {
233        Self {
234            nodes: Arc::new(Mutex::new(HashMap::new())),
235            nodes_snapshot: Arc::new(ArcSwap::from_pointee(Vec::<NodeResources>::new())),
236            nodes_snapshot_dirty: Arc::new(AtomicBool::new(false)),
237            metrics: Arc::new(Mutex::new(HashMap::new())),
238            last_update: Arc::new(AtomicU64::new(0)),
239            total_vram_cache: Arc::new(AtomicU64::new(0.0_f64.to_bits())),
240        }
241    }
242
243    /// Register a new node with the cluster
244    pub fn register(&self, node: NodeResources) {
245        let mut nodes = self.nodes.lock().unwrap();
246        let mut metrics = self.metrics.lock().unwrap();
247
248        let id = node.id.clone();
249        let vram_gb = node.vram_gb;
250        let system_memory_gb = node.system_memory_gb;
251        let compute_capability = node.compute_capability.clone();
252        let mut vram_delta = vram_gb;
253
254        if let Some(existing) = nodes.get_mut(&id) {
255            vram_delta = vram_gb - existing.vram_gb;
256            existing.vram_gb = vram_gb;
257            existing.system_memory_gb = system_memory_gb;
258            existing.compute_capability = compute_capability.clone();
259        } else {
260            nodes.insert(id.clone(), node);
261        }
262
263        if let Some(existing_metrics) = metrics.get_mut(&id) {
264            existing_metrics.vram_gb = vram_gb;
265            existing_metrics.total_vram_gb = vram_gb;
266            existing_metrics.system_memory_gb = system_memory_gb;
267            existing_metrics.compute_capability = compute_capability;
268            existing_metrics.heartbeat_timeout = Duration::from_secs(5);
269        } else {
270            metrics.insert(
271                id,
272                NodeMetrics::new(
273                    vram_gb,
274                    system_memory_gb,
275                    compute_capability,
276                    Duration::from_secs(5),
277                ),
278            );
279        }
280
281        self.nodes_snapshot_dirty.store(true, Ordering::Release);
282
283        let current_total_vram = f64::from_bits(self.total_vram_cache.load(Ordering::Acquire));
284        self.total_vram_cache.store(
285            (current_total_vram + vram_delta as f64).to_bits(),
286            Ordering::Release,
287        );
288
289        self.last_update.store(
290            std::time::SystemTime::now()
291                .duration_since(std::time::UNIX_EPOCH)
292                .unwrap_or(Duration::ZERO)
293                .as_millis() as u64,
294            Ordering::Release,
295        );
296    }
297
298    /// Get all nodes
299    pub fn nodes(&self) -> Vec<NodeResources> {
300        self.nodes_snapshot().as_ref().to_vec()
301    }
302
303    /// Get a shared snapshot of all nodes
304    pub fn nodes_snapshot(&self) -> Arc<Vec<NodeResources>> {
305        if self.nodes_snapshot_dirty.load(Ordering::Acquire)
306            && self.nodes_snapshot_dirty.swap(false, Ordering::AcqRel)
307        {
308            let nodes = self.nodes.lock().unwrap();
309            self.nodes_snapshot
310                .store(Arc::new(nodes.values().cloned().collect::<Vec<_>>()));
311        }
312
313        self.nodes_snapshot.load_full()
314    }
315
316    /// Get metrics for a specific node
317    pub fn get_metrics(&self, node_id: &str) -> Option<NodeMetrics> {
318        let metrics = self.metrics.lock().unwrap();
319        metrics.get(node_id).cloned()
320    }
321
322    /// Update last heartbeat for a node
323    pub fn update_heartbeat(&self, node_id: &str) {
324        self.get_metrics_mut(node_id, |metrics| {
325            metrics.last_heartbeat = Instant::now();
326        });
327    }
328
329    /// Get metrics mutable reference (for internal updates)
330    pub fn get_metrics_mut<F, R>(&self, node_id: &str, f: F) -> Option<R>
331    where
332        F: FnOnce(&mut NodeMetrics) -> R,
333    {
334        let mut metrics = self.metrics.lock().unwrap();
335        metrics.get_mut(node_id).map(f)
336    }
337
338    /// Check if a node has timed out
339    pub fn check_heartbeat_timeout(&self, node_id: &str) -> bool {
340        if let Some(metrics) = self.get_metrics(node_id) {
341            let elapsed = Instant::now().duration_since(metrics.last_heartbeat);
342            elapsed >= metrics.heartbeat_timeout
343        } else {
344            false
345        }
346    }
347
348    /// Mark a node as failed due to timeout
349    pub fn mark_failed(&self, node_id: &str) {
350        self.get_metrics_mut(node_id, |metrics| {
351            metrics.status = NodeStatus::Failed;
352        });
353    }
354
355    /// Recover a failed node
356    pub fn recover_node(&self, node_id: &str) {
357        self.get_metrics_mut(node_id, |metrics| {
358            metrics.status = NodeStatus::Active;
359        });
360    }
361
362    /// Get all active nodes
363    pub fn active_nodes(&self) -> Vec<NodeMetrics> {
364        let metrics = self.metrics.lock().unwrap();
365        metrics
366            .values()
367            .filter(|m| m.status == NodeStatus::Active)
368            .cloned()
369            .collect()
370    }
371
372    /// Get total cluster VRAM
373    pub fn total_vram_gb(&self) -> f32 {
374        f64::from_bits(self.total_vram_cache.load(Ordering::Acquire)) as f32
375    }
376
377    /// Get total system memory
378    pub fn total_system_memory_gb(&self) -> f32 {
379        let nodes = self.nodes.lock().unwrap();
380        nodes.values().map(|n| n.system_memory_gb).sum()
381    }
382}
383
384/// Cluster health monitor with periodic checks
385#[derive(Clone, Debug)]
386pub struct ClusterHealthMonitor {
387    cluster: Arc<ClusterState>,
388    /// Health check interval
389    check_interval: Duration,
390}
391
392impl ClusterHealthMonitor {
393    /// Create new health monitor
394    pub fn new(cluster: Arc<ClusterState>, check_interval: Duration) -> Self {
395        Self {
396            cluster,
397            check_interval,
398        }
399    }
400
401    /// Run health check on all nodes
402    pub fn check_health(&self) {
403        let failed_nodes: Vec<String> = self
404            .cluster
405            .nodes_snapshot()
406            .iter()
407            .filter(|n| self.cluster.check_heartbeat_timeout(&n.id))
408            .map(|n| n.id.clone())
409            .collect();
410
411        // Mark timed-out nodes as failed
412        for node_id in &failed_nodes {
413            self.cluster.mark_failed(node_id);
414        }
415
416        // Update last update timestamp
417        self.cluster.last_update.store(
418            std::time::SystemTime::now()
419                .duration_since(std::time::UNIX_EPOCH)
420                .unwrap_or(Duration::ZERO)
421                .as_millis() as u64,
422            Ordering::Release,
423        );
424    }
425
426    /// Get health report
427    pub fn health_report(&self) -> String {
428        let active_count = self.cluster.active_nodes().len();
429        let total_nodes = self.cluster.nodes_snapshot().len();
430
431        format!(
432            "Cluster Health Report\n\
433             =================\n\
434             Active nodes: {}/{}\n\
435             Total VRAM: {:.1} GB\n\
436             System memory: {:.1} GB\n",
437            active_count,
438            total_nodes,
439            self.cluster.total_vram_gb(),
440            self.cluster.total_system_memory_gb()
441        )
442    }
443
444    /// Run periodic health checks in background
445    pub fn start_periodic_checks(&self) {
446        let this = self.clone();
447
448        // Spawn health check task
449        std::thread::spawn(move || loop {
450            this.check_health();
451            std::thread::sleep(this.check_interval);
452        });
453    }
454}
455
456#[cfg(test)]
457mod tests {
458    use super::*;
459    use std::sync::Arc;
460    use std::thread;
461    use std::time::Duration;
462
463    #[test]
464    fn register_replaces_existing_nodes() {
465        let cluster = ClusterState::new();
466        cluster.register(NodeResources::new("node-a", 24.0, 64.0, "8.9", None));
467        cluster.register(NodeResources::new("node-a", 48.0, 128.0, "9.0", None));
468
469        assert_eq!(cluster.nodes().len(), 1);
470        assert_eq!(cluster.nodes()[0].vram_gb, 48.0);
471    }
472
473    #[test]
474    fn heartbeat_timeout_detection() {
475        let cluster = ClusterState::new();
476        cluster.register(NodeResources::new("node-a", 24.0, 64.0, "8.9", None));
477
478        // Simulate timeout by waiting longer than default heartbeat timeout
479        thread::sleep(Duration::from_secs(6));
480
481        assert!(cluster.check_heartbeat_timeout("node-a"));
482    }
483
484    #[test]
485    fn health_monitor_reports_active_count() {
486        let cluster = Arc::new(ClusterState::new());
487        cluster.register(NodeResources::new("node-a", 24.0, 64.0, "8.9", None));
488        cluster.register(NodeResources::new("node-b", 12.0, 32.0, "8.6", None));
489
490        let monitor = ClusterHealthMonitor::new(cluster.clone(), Duration::from_secs(1));
491        monitor.check_health();
492
493        let report = monitor.health_report();
494        assert!(report.contains("Active nodes: 2/2"));
495    }
496
497    #[test]
498    fn metrics_record_latency() {
499        let mut metrics = NodeMetrics::new(24.0, 64.0, "8.9".to_string(), Duration::from_secs(5));
500
501        metrics.record_latency(1.0);
502        assert_eq!(metrics.avg_latency_us, 1.0);
503        assert_eq!(metrics.min_latency_us, 1.0);
504        assert_eq!(metrics.max_latency_us, 1.0);
505
506        metrics.record_latency(2.0);
507        // EMA with alpha=0.1: (1.0 * 0.9) + 2.0 * 0.1 = 0.9 + 0.2 = 1.1
508        assert!((metrics.avg_latency_us - 1.1).abs() < 1e-6);
509    }
510
511    #[test]
512    fn metrics_record_delivery_ratio() {
513        let mut metrics = NodeMetrics::new(24.0, 64.0, "8.9".to_string(), Duration::from_secs(5));
514
515        metrics.record_delivery_ratio(0.98);
516        assert!((metrics.delivery_ratio - 0.98).abs() < 1e-6);
517
518        metrics.record_delivery_ratio(0.90);
519        // EMA: 0.98 * 0.9 + 0.90 * 0.1 = 0.882 + 0.09 = 0.972
520        assert!((metrics.delivery_ratio - 0.972).abs() < 1e-6);
521    }
522
523    #[test]
524    fn cluster_tracks_total_vram() {
525        let cluster = ClusterState::new();
526        cluster.register(NodeResources::new("node-a", 24.0, 64.0, "8.9", None));
527        cluster.register(NodeResources::new("node-b", 12.0, 32.0, "8.6", None));
528
529        assert_eq!(cluster.total_vram_gb(), 36.0);
530    }
531}