1#![allow(dead_code)]
9
10use std::collections::HashMap;
11use uuid::Uuid;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
15pub enum HealthStatus {
16 Healthy,
18 Degraded,
20 Suspect,
22 Unreachable,
24 Maintenance,
26 Draining,
28}
29
30impl HealthStatus {
31 #[must_use]
33 pub fn can_accept_work(&self) -> bool {
34 matches!(self, Self::Healthy | Self::Degraded)
35 }
36
37 #[must_use]
39 pub fn is_alive(&self) -> bool {
40 !matches!(self, Self::Unreachable)
41 }
42}
43
44#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
46pub struct HealthCheckConfig {
47 pub check_interval_secs: u64,
49 pub failure_threshold: u32,
51 pub recovery_threshold: u32,
53 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#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
70pub struct NodeHealthCheck {
71 pub node_id: Uuid,
73 pub success: bool,
75 pub latency_ms: u64,
77 pub cpu_utilization: f64,
79 pub memory_utilization: f64,
81 pub active_tasks: u32,
83 pub checked_at: i64,
85 pub error: Option<String>,
87}
88
89impl NodeHealthCheck {
90 #[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 #[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 #[must_use]
130 pub fn is_overloaded(&self) -> bool {
131 self.cpu_utilization > 0.9 || self.memory_utilization > 0.9
132 }
133}
134
135#[derive(Debug, Clone)]
137struct NodeState {
138 status: HealthStatus,
140 consecutive_failures: u32,
142 consecutive_successes: u32,
144 last_check: Option<NodeHealthCheck>,
146 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#[derive(Debug)]
167pub struct HealthRegistry {
168 config: HealthCheckConfig,
170 nodes: HashMap<Uuid, NodeState>,
172}
173
174impl HealthRegistry {
175 #[must_use]
177 pub fn new() -> Self {
178 Self {
179 config: HealthCheckConfig::default(),
180 nodes: HashMap::new(),
181 }
182 }
183
184 #[must_use]
186 pub fn with_config(config: HealthCheckConfig) -> Self {
187 Self {
188 config,
189 nodes: HashMap::new(),
190 }
191 }
192
193 pub fn register_node(&mut self, node_id: Uuid) {
195 self.nodes.entry(node_id).or_insert_with(NodeState::new);
196 }
197
198 pub fn unregister_node(&mut self, node_id: &Uuid) -> bool {
200 self.nodes.remove(node_id).is_some()
201 }
202
203 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 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 if let Some(ns) = self.nodes.get_mut(&check.node_id) {
236 ns.last_check = Some(check);
237 }
238 }
239
240 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 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 #[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 #[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 #[must_use]
270 pub fn node_count(&self) -> usize {
271 self.nodes.len()
272 }
273
274 #[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 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 reg.process_check(NodeHealthCheck::failure(id, "err", 100));
389 assert_eq!(reg.get_status(&id), Some(HealthStatus::Suspect));
390
391 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 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 reg.process_check(NodeHealthCheck::success(id, 10, 0.5, 0.5, 2, 200));
412 assert_eq!(reg.get_status(&id), Some(HealthStatus::Degraded));
416
417 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}