Skip to main content

oximedia_distributed/
node_health.rs

1//! Node health monitoring for distributed systems.
2//!
3//! This module provides health checking and status tracking for
4//! worker nodes in the distributed encoding cluster. It supports
5//! configurable check intervals, failure thresholds, and automatic
6//! node status transitions.
7
8#![allow(dead_code)]
9
10use std::collections::HashMap;
11use uuid::Uuid;
12
13/// Health status of a node.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
15pub enum HealthStatus {
16    /// Node is healthy and accepting work
17    Healthy,
18    /// Node is responsive but showing warning signs
19    Degraded,
20    /// Node has not responded within expected interval
21    Suspect,
22    /// Node is confirmed unreachable
23    Unreachable,
24    /// Node is undergoing maintenance
25    Maintenance,
26    /// Node is draining (finishing current work, not accepting new)
27    Draining,
28}
29
30impl HealthStatus {
31    /// Returns true if the node can accept new work.
32    #[must_use]
33    pub fn can_accept_work(&self) -> bool {
34        matches!(self, Self::Healthy | Self::Degraded)
35    }
36
37    /// Returns true if the node is considered alive (responsive).
38    #[must_use]
39    pub fn is_alive(&self) -> bool {
40        !matches!(self, Self::Unreachable)
41    }
42}
43
44/// Configuration for health checks.
45#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
46pub struct HealthCheckConfig {
47    /// Interval between health checks in seconds
48    pub check_interval_secs: u64,
49    /// Number of consecutive failures before marking unreachable
50    pub failure_threshold: u32,
51    /// Number of consecutive successes to recover from degraded
52    pub recovery_threshold: u32,
53    /// Timeout for a single health check in milliseconds
54    pub timeout_ms: u64,
55}
56
57impl Default for HealthCheckConfig {
58    fn default() -> Self {
59        Self {
60            check_interval_secs: 10,
61            failure_threshold: 3,
62            recovery_threshold: 2,
63            timeout_ms: 5000,
64        }
65    }
66}
67
68/// Health check result from probing a single node.
69#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
70pub struct NodeHealthCheck {
71    /// Node that was checked
72    pub node_id: Uuid,
73    /// Whether the check succeeded
74    pub success: bool,
75    /// Response latency in milliseconds
76    pub latency_ms: u64,
77    /// CPU utilization (0.0-1.0)
78    pub cpu_utilization: f64,
79    /// Memory utilization (0.0-1.0)
80    pub memory_utilization: f64,
81    /// Number of active tasks on the node
82    pub active_tasks: u32,
83    /// Unix timestamp of the check
84    pub checked_at: i64,
85    /// Optional error message if the check failed
86    pub error: Option<String>,
87}
88
89impl NodeHealthCheck {
90    /// Creates a successful health check result.
91    #[must_use]
92    #[allow(clippy::too_many_arguments)]
93    pub fn success(
94        node_id: Uuid,
95        latency_ms: u64,
96        cpu_utilization: f64,
97        memory_utilization: f64,
98        active_tasks: u32,
99        checked_at: i64,
100    ) -> Self {
101        Self {
102            node_id,
103            success: true,
104            latency_ms,
105            cpu_utilization,
106            memory_utilization,
107            active_tasks,
108            checked_at,
109            error: None,
110        }
111    }
112
113    /// Creates a failed health check result.
114    #[must_use]
115    pub fn failure(node_id: Uuid, error: &str, checked_at: i64) -> Self {
116        Self {
117            node_id,
118            success: false,
119            latency_ms: 0,
120            cpu_utilization: 0.0,
121            memory_utilization: 0.0,
122            active_tasks: 0,
123            checked_at,
124            error: Some(error.to_string()),
125        }
126    }
127
128    /// Returns true if the node appears overloaded.
129    #[must_use]
130    pub fn is_overloaded(&self) -> bool {
131        self.cpu_utilization > 0.9 || self.memory_utilization > 0.9
132    }
133}
134
135/// Tracked state for a single node in the registry.
136#[derive(Debug, Clone)]
137struct NodeState {
138    /// Current health status
139    status: HealthStatus,
140    /// Consecutive failure count
141    consecutive_failures: u32,
142    /// Consecutive success count
143    consecutive_successes: u32,
144    /// Last health check result
145    last_check: Option<NodeHealthCheck>,
146    /// Timestamp of the last status change
147    status_changed_at: i64,
148}
149
150impl NodeState {
151    fn new() -> Self {
152        Self {
153            status: HealthStatus::Healthy,
154            consecutive_failures: 0,
155            consecutive_successes: 0,
156            last_check: None,
157            status_changed_at: 0,
158        }
159    }
160}
161
162/// Registry tracking health state for all nodes in the cluster.
163///
164/// Processes health check results and automatically transitions
165/// node statuses based on configurable thresholds.
166#[derive(Debug)]
167pub struct HealthRegistry {
168    /// Configuration
169    config: HealthCheckConfig,
170    /// Per-node tracked state
171    nodes: HashMap<Uuid, NodeState>,
172}
173
174impl HealthRegistry {
175    /// Creates a new health registry with default configuration.
176    #[must_use]
177    pub fn new() -> Self {
178        Self {
179            config: HealthCheckConfig::default(),
180            nodes: HashMap::new(),
181        }
182    }
183
184    /// Creates a new health registry with custom configuration.
185    #[must_use]
186    pub fn with_config(config: HealthCheckConfig) -> Self {
187        Self {
188            config,
189            nodes: HashMap::new(),
190        }
191    }
192
193    /// Registers a new node in the registry.
194    pub fn register_node(&mut self, node_id: Uuid) {
195        self.nodes.entry(node_id).or_insert_with(NodeState::new);
196    }
197
198    /// Removes a node from the registry.
199    pub fn unregister_node(&mut self, node_id: &Uuid) -> bool {
200        self.nodes.remove(node_id).is_some()
201    }
202
203    /// Processes a health check result and updates node status.
204    pub fn process_check(&mut self, check: NodeHealthCheck) {
205        let node = self
206            .nodes
207            .entry(check.node_id)
208            .or_insert_with(NodeState::new);
209
210        if check.success {
211            node.consecutive_failures = 0;
212            node.consecutive_successes += 1;
213
214            if check.is_overloaded() {
215                node.consecutive_successes = 0;
216                self.transition(check.node_id, HealthStatus::Degraded, check.checked_at);
217            } else if node.consecutive_successes >= self.config.recovery_threshold {
218                // Only recover if currently degraded or suspect
219                if matches!(node.status, HealthStatus::Degraded | HealthStatus::Suspect) {
220                    self.transition(check.node_id, HealthStatus::Healthy, check.checked_at);
221                }
222            }
223        } else {
224            node.consecutive_successes = 0;
225            node.consecutive_failures += 1;
226
227            if node.consecutive_failures >= self.config.failure_threshold {
228                self.transition(check.node_id, HealthStatus::Unreachable, check.checked_at);
229            } else if node.consecutive_failures >= 1 {
230                self.transition(check.node_id, HealthStatus::Suspect, check.checked_at);
231            }
232        }
233
234        // Update last check (re-borrow after transitions)
235        if let Some(ns) = self.nodes.get_mut(&check.node_id) {
236            ns.last_check = Some(check);
237        }
238    }
239
240    /// Manually sets a node to maintenance mode.
241    pub fn set_maintenance(&mut self, node_id: Uuid, now: i64) {
242        self.register_node(node_id);
243        self.transition(node_id, HealthStatus::Maintenance, now);
244    }
245
246    /// Manually sets a node to draining mode.
247    pub fn set_draining(&mut self, node_id: Uuid, now: i64) {
248        self.register_node(node_id);
249        self.transition(node_id, HealthStatus::Draining, now);
250    }
251
252    /// Gets the current status of a node.
253    #[must_use]
254    pub fn get_status(&self, node_id: &Uuid) -> Option<HealthStatus> {
255        self.nodes.get(node_id).map(|n| n.status)
256    }
257
258    /// Returns all nodes that can accept work.
259    #[must_use]
260    pub fn available_nodes(&self) -> Vec<Uuid> {
261        self.nodes
262            .iter()
263            .filter(|(_, state)| state.status.can_accept_work())
264            .map(|(id, _)| *id)
265            .collect()
266    }
267
268    /// Returns total number of registered nodes.
269    #[must_use]
270    pub fn node_count(&self) -> usize {
271        self.nodes.len()
272    }
273
274    /// Returns nodes in a specific status.
275    #[must_use]
276    pub fn nodes_with_status(&self, status: HealthStatus) -> Vec<Uuid> {
277        self.nodes
278            .iter()
279            .filter(|(_, state)| state.status == status)
280            .map(|(id, _)| *id)
281            .collect()
282    }
283
284    /// Transitions a node to a new status.
285    fn transition(&mut self, node_id: Uuid, new_status: HealthStatus, now: i64) {
286        if let Some(node) = self.nodes.get_mut(&node_id) {
287            if node.status != new_status {
288                node.status = new_status;
289                node.status_changed_at = now;
290            }
291        }
292    }
293}
294
295impl Default for HealthRegistry {
296    fn default() -> Self {
297        Self::new()
298    }
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304
305    fn nid() -> Uuid {
306        Uuid::new_v4()
307    }
308
309    #[test]
310    fn test_health_status_can_accept_work() {
311        assert!(HealthStatus::Healthy.can_accept_work());
312        assert!(HealthStatus::Degraded.can_accept_work());
313        assert!(!HealthStatus::Unreachable.can_accept_work());
314        assert!(!HealthStatus::Maintenance.can_accept_work());
315        assert!(!HealthStatus::Draining.can_accept_work());
316    }
317
318    #[test]
319    fn test_health_status_is_alive() {
320        assert!(HealthStatus::Healthy.is_alive());
321        assert!(HealthStatus::Degraded.is_alive());
322        assert!(HealthStatus::Suspect.is_alive());
323        assert!(!HealthStatus::Unreachable.is_alive());
324    }
325
326    #[test]
327    fn test_health_check_config_defaults() {
328        let cfg = HealthCheckConfig::default();
329        assert_eq!(cfg.check_interval_secs, 10);
330        assert_eq!(cfg.failure_threshold, 3);
331        assert_eq!(cfg.recovery_threshold, 2);
332    }
333
334    #[test]
335    fn test_node_health_check_success() {
336        let id = nid();
337        let check = NodeHealthCheck::success(id, 15, 0.5, 0.6, 2, 1000);
338        assert!(check.success);
339        assert_eq!(check.latency_ms, 15);
340        assert!(!check.is_overloaded());
341    }
342
343    #[test]
344    fn test_node_health_check_failure() {
345        let id = nid();
346        let check = NodeHealthCheck::failure(id, "timeout", 1000);
347        assert!(!check.success);
348        assert_eq!(check.error.as_deref(), Some("timeout"));
349    }
350
351    #[test]
352    fn test_overloaded_detection() {
353        let check = NodeHealthCheck::success(nid(), 100, 0.95, 0.5, 5, 1000);
354        assert!(check.is_overloaded());
355        let check2 = NodeHealthCheck::success(nid(), 10, 0.5, 0.95, 1, 1000);
356        assert!(check2.is_overloaded());
357    }
358
359    #[test]
360    fn test_registry_register_and_status() {
361        let mut reg = HealthRegistry::new();
362        let id = nid();
363        reg.register_node(id);
364        assert_eq!(reg.get_status(&id), Some(HealthStatus::Healthy));
365        assert_eq!(reg.node_count(), 1);
366    }
367
368    #[test]
369    fn test_registry_unregister() {
370        let mut reg = HealthRegistry::new();
371        let id = nid();
372        reg.register_node(id);
373        assert!(reg.unregister_node(&id));
374        assert!(reg.get_status(&id).is_none());
375    }
376
377    #[test]
378    fn test_registry_failure_escalation() {
379        let config = HealthCheckConfig {
380            failure_threshold: 2,
381            ..Default::default()
382        };
383        let mut reg = HealthRegistry::with_config(config);
384        let id = nid();
385        reg.register_node(id);
386
387        // First failure => Suspect
388        reg.process_check(NodeHealthCheck::failure(id, "err", 100));
389        assert_eq!(reg.get_status(&id), Some(HealthStatus::Suspect));
390
391        // Second failure => Unreachable (threshold = 2)
392        reg.process_check(NodeHealthCheck::failure(id, "err", 200));
393        assert_eq!(reg.get_status(&id), Some(HealthStatus::Unreachable));
394    }
395
396    #[test]
397    fn test_registry_recovery() {
398        let config = HealthCheckConfig {
399            recovery_threshold: 2,
400            ..Default::default()
401        };
402        let mut reg = HealthRegistry::with_config(config);
403        let id = nid();
404        reg.register_node(id);
405
406        // Make it degraded
407        reg.process_check(NodeHealthCheck::success(id, 10, 0.95, 0.5, 5, 100));
408        assert_eq!(reg.get_status(&id), Some(HealthStatus::Degraded));
409
410        // First success — not enough to recover
411        reg.process_check(NodeHealthCheck::success(id, 10, 0.5, 0.5, 2, 200));
412        // Still degraded (need 2 consecutive successes)
413        // Actually after the overloaded check sets it to Degraded, consecutive_successes
414        // resets. Let's verify:
415        assert_eq!(reg.get_status(&id), Some(HealthStatus::Degraded));
416
417        // Second success => recovery
418        reg.process_check(NodeHealthCheck::success(id, 10, 0.5, 0.5, 2, 300));
419        assert_eq!(reg.get_status(&id), Some(HealthStatus::Healthy));
420    }
421
422    #[test]
423    fn test_available_nodes() {
424        let mut reg = HealthRegistry::new();
425        let h = nid();
426        let u = nid();
427        reg.register_node(h);
428        reg.register_node(u);
429        reg.set_maintenance(u, 100);
430        let available = reg.available_nodes();
431        assert_eq!(available.len(), 1);
432        assert_eq!(available[0], h);
433    }
434
435    #[test]
436    fn test_set_maintenance() {
437        let mut reg = HealthRegistry::new();
438        let id = nid();
439        reg.set_maintenance(id, 100);
440        assert_eq!(reg.get_status(&id), Some(HealthStatus::Maintenance));
441    }
442
443    #[test]
444    fn test_set_draining() {
445        let mut reg = HealthRegistry::new();
446        let id = nid();
447        reg.set_draining(id, 100);
448        assert_eq!(reg.get_status(&id), Some(HealthStatus::Draining));
449        assert!(!HealthStatus::Draining.can_accept_work());
450    }
451
452    #[test]
453    fn test_nodes_with_status() {
454        let mut reg = HealthRegistry::new();
455        let a = nid();
456        let b = nid();
457        let c = nid();
458        reg.register_node(a);
459        reg.register_node(b);
460        reg.register_node(c);
461        reg.set_maintenance(c, 100);
462        let healthy = reg.nodes_with_status(HealthStatus::Healthy);
463        assert_eq!(healthy.len(), 2);
464        let maint = reg.nodes_with_status(HealthStatus::Maintenance);
465        assert_eq!(maint.len(), 1);
466    }
467
468    #[test]
469    fn test_overloaded_marks_degraded() {
470        let mut reg = HealthRegistry::new();
471        let id = nid();
472        reg.register_node(id);
473        reg.process_check(NodeHealthCheck::success(id, 50, 0.95, 0.4, 10, 100));
474        assert_eq!(reg.get_status(&id), Some(HealthStatus::Degraded));
475    }
476}