1use std::sync::Arc;
7
8use serde::{Deserialize, Serialize};
9use tracing::{debug, warn};
10
11use crate::service::ServiceRegistry;
12
13#[non_exhaustive]
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16pub enum HealthStatus {
17 Healthy,
19 Degraded(String),
21 Unhealthy(String),
23 Unknown,
25}
26
27impl std::fmt::Display for HealthStatus {
28 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29 match self {
30 HealthStatus::Healthy => write!(f, "healthy"),
31 HealthStatus::Degraded(msg) => write!(f, "degraded: {msg}"),
32 HealthStatus::Unhealthy(msg) => write!(f, "unhealthy: {msg}"),
33 HealthStatus::Unknown => write!(f, "unknown"),
34 }
35 }
36}
37
38#[non_exhaustive]
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub enum OverallHealth {
42 Healthy,
44 Degraded {
46 unhealthy_services: Vec<String>,
48 },
49 Down,
51}
52
53impl std::fmt::Display for OverallHealth {
54 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55 match self {
56 OverallHealth::Healthy => write!(f, "healthy"),
57 OverallHealth::Degraded { unhealthy_services } => {
58 write!(f, "degraded ({})", unhealthy_services.join(", "))
59 }
60 OverallHealth::Down => write!(f, "down"),
61 }
62 }
63}
64
65pub struct HealthSystem {
70 check_interval_secs: u64,
71}
72
73impl HealthSystem {
74 pub fn new(check_interval_secs: u64) -> Self {
76 Self {
77 check_interval_secs,
78 }
79 }
80
81 pub fn check_interval_secs(&self) -> u64 {
83 self.check_interval_secs
84 }
85
86 pub async fn aggregate(
88 &self,
89 registry: &Arc<ServiceRegistry>,
90 ) -> (OverallHealth, Vec<(String, HealthStatus)>) {
91 let results = registry.health_all().await;
92
93 if results.is_empty() {
94 return (OverallHealth::Down, results);
95 }
96
97 let mut unhealthy = Vec::new();
98 let mut all_unhealthy = true;
99
100 for (name, status) in &results {
101 match status {
102 HealthStatus::Healthy => {
103 debug!(service = %name, "health check: healthy");
104 all_unhealthy = false;
105 }
106 HealthStatus::Degraded(msg) => {
107 warn!(service = %name, reason = %msg, "health check: degraded");
108 unhealthy.push(name.clone());
109 all_unhealthy = false;
110 }
111 HealthStatus::Unhealthy(msg) => {
112 warn!(service = %name, reason = %msg, "health check: unhealthy");
113 unhealthy.push(name.clone());
114 }
115 HealthStatus::Unknown => {
116 warn!(service = %name, "health check: unknown");
117 unhealthy.push(name.clone());
118 }
119 }
120 }
121
122 let overall = if unhealthy.is_empty() {
123 OverallHealth::Healthy
124 } else if all_unhealthy {
125 OverallHealth::Down
126 } else {
127 OverallHealth::Degraded {
128 unhealthy_services: unhealthy,
129 }
130 };
131
132 (overall, results)
133 }
134}
135
136#[non_exhaustive]
140#[cfg(feature = "os-patterns")]
141#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
142pub enum ProbeResult {
143 Live,
145 NotLive { reason: String },
147 Ready,
149 NotReady { reason: String },
151}
152
153#[cfg(feature = "os-patterns")]
155#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct ProbeConfig {
157 pub liveness_interval_secs: u64,
159 pub readiness_interval_secs: u64,
161 pub failure_threshold: u32,
163 pub success_threshold: u32,
165}
166
167#[cfg(feature = "os-patterns")]
168impl Default for ProbeConfig {
169 fn default() -> Self {
170 Self {
171 liveness_interval_secs: 10,
172 readiness_interval_secs: 5,
173 failure_threshold: 3,
174 success_threshold: 1,
175 }
176 }
177}
178
179#[cfg(feature = "os-patterns")]
180impl ProbeConfig {
181 pub fn from_model(
201 model: Option<&crate::eml_kernel::HealthThresholdModel>,
202 service_type: u32,
203 history_depth: u32,
204 recent_latency_ms: f64,
205 ) -> Self {
206 match model {
207 Some(m) if m.is_trained() => {
208 let (degraded, failed) = m.predict(service_type, history_depth, recent_latency_ms);
209 Self {
210 failure_threshold: failed,
211 success_threshold: degraded.max(1),
212 ..Self::default()
213 }
214 }
215 _ => Self::default(),
216 }
217 }
218}
219
220#[cfg(feature = "os-patterns")]
222#[derive(Debug, Clone)]
223pub struct ProbeState {
224 pub liveness_failures: u32,
226 pub readiness_failures: u32,
228 pub readiness_successes: u32,
230 pub is_live: bool,
232 pub is_ready: bool,
234}
235
236#[cfg(feature = "os-patterns")]
237impl Default for ProbeState {
238 fn default() -> Self {
239 Self {
240 liveness_failures: 0,
241 readiness_failures: 0,
242 readiness_successes: 0,
243 is_live: true,
244 is_ready: true,
245 }
246 }
247}
248
249#[cfg(feature = "os-patterns")]
250impl ProbeState {
251 pub fn record_liveness(&mut self, result: &ProbeResult, config: &ProbeConfig) -> bool {
255 match result {
256 ProbeResult::Live => {
257 self.liveness_failures = 0;
258 self.is_live = true;
259 false
260 }
261 ProbeResult::NotLive { .. } => {
262 self.liveness_failures += 1;
263 if self.liveness_failures >= config.failure_threshold {
264 self.is_live = false;
265 true
266 } else {
267 false
268 }
269 }
270 _ => false, }
272 }
273
274 pub fn record_readiness(&mut self, result: &ProbeResult, config: &ProbeConfig) -> Option<bool> {
281 match result {
282 ProbeResult::Ready => {
283 self.readiness_failures = 0;
284 self.readiness_successes += 1;
285 if !self.is_ready && self.readiness_successes >= config.success_threshold {
286 self.is_ready = true;
287 Some(true) } else {
289 None
290 }
291 }
292 ProbeResult::NotReady { .. } => {
293 self.readiness_successes = 0;
294 self.readiness_failures += 1;
295 if self.is_ready && self.readiness_failures >= config.failure_threshold {
296 self.is_ready = false;
297 Some(false) } else {
299 None
300 }
301 }
302 _ => None, }
304 }
305}
306
307#[cfg(test)]
308mod tests {
309 use super::*;
310 use crate::service::{ServiceType, SystemService};
311 use async_trait::async_trait;
312
313 struct HealthyService;
314
315 #[async_trait]
316 impl SystemService for HealthyService {
317 fn name(&self) -> &str {
318 "healthy-svc"
319 }
320 fn service_type(&self) -> ServiceType {
321 ServiceType::Core
322 }
323 async fn start(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
324 Ok(())
325 }
326 async fn stop(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
327 Ok(())
328 }
329 async fn health_check(&self) -> HealthStatus {
330 HealthStatus::Healthy
331 }
332 }
333
334 struct UnhealthyService;
335
336 #[async_trait]
337 impl SystemService for UnhealthyService {
338 fn name(&self) -> &str {
339 "unhealthy-svc"
340 }
341 fn service_type(&self) -> ServiceType {
342 ServiceType::Core
343 }
344 async fn start(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
345 Ok(())
346 }
347 async fn stop(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
348 Ok(())
349 }
350 async fn health_check(&self) -> HealthStatus {
351 HealthStatus::Unhealthy("test failure".into())
352 }
353 }
354
355 #[tokio::test]
356 async fn aggregate_all_healthy() {
357 let registry = Arc::new(ServiceRegistry::new());
358 registry.register(Arc::new(HealthyService)).unwrap();
359
360 let health = HealthSystem::new(30);
361 let (overall, results) = health.aggregate(®istry).await;
362
363 assert!(matches!(overall, OverallHealth::Healthy));
364 assert_eq!(results.len(), 1);
365 }
366
367 #[tokio::test]
368 async fn aggregate_mixed() {
369 let registry = Arc::new(ServiceRegistry::new());
370 registry.register(Arc::new(HealthyService)).unwrap();
371 registry.register(Arc::new(UnhealthyService)).unwrap();
372
373 let health = HealthSystem::new(30);
374 let (overall, results) = health.aggregate(®istry).await;
375
376 assert!(matches!(overall, OverallHealth::Degraded { .. }));
377 assert_eq!(results.len(), 2);
378 }
379
380 #[tokio::test]
381 async fn aggregate_all_unhealthy() {
382 let registry = Arc::new(ServiceRegistry::new());
383 registry.register(Arc::new(UnhealthyService)).unwrap();
384
385 let health = HealthSystem::new(30);
386 let (overall, _) = health.aggregate(®istry).await;
387
388 assert!(matches!(overall, OverallHealth::Down));
389 }
390
391 #[tokio::test]
392 async fn aggregate_empty_registry() {
393 let registry = Arc::new(ServiceRegistry::new());
394 let health = HealthSystem::new(30);
395 let (overall, results) = health.aggregate(®istry).await;
396
397 assert!(matches!(overall, OverallHealth::Down));
398 assert!(results.is_empty());
399 }
400
401 #[test]
402 fn health_status_display() {
403 assert_eq!(HealthStatus::Healthy.to_string(), "healthy");
404 assert_eq!(
405 HealthStatus::Degraded("slow".into()).to_string(),
406 "degraded: slow"
407 );
408 assert_eq!(
409 HealthStatus::Unhealthy("crash".into()).to_string(),
410 "unhealthy: crash"
411 );
412 assert_eq!(HealthStatus::Unknown.to_string(), "unknown");
413 }
414
415 #[test]
416 fn overall_health_display() {
417 assert_eq!(OverallHealth::Healthy.to_string(), "healthy");
418 assert_eq!(OverallHealth::Down.to_string(), "down");
419 assert_eq!(
420 OverallHealth::Degraded {
421 unhealthy_services: vec!["svc-a".into(), "svc-b".into()]
422 }
423 .to_string(),
424 "degraded (svc-a, svc-b)"
425 );
426 }
427
428 #[test]
429 fn check_interval() {
430 let health = HealthSystem::new(15);
431 assert_eq!(health.check_interval_secs(), 15);
432 }
433
434 #[cfg(feature = "os-patterns")]
437 mod probe_tests {
438 use super::super::*;
439
440 #[test]
441 fn probe_config_default() {
442 let config = ProbeConfig::default();
443 assert_eq!(config.liveness_interval_secs, 10);
444 assert_eq!(config.readiness_interval_secs, 5);
445 assert_eq!(config.failure_threshold, 3);
446 assert_eq!(config.success_threshold, 1);
447 }
448
449 #[test]
450 fn probe_config_from_model_untrained_matches_default() {
451 let model = crate::eml_kernel::HealthThresholdModel::new();
454 assert!(!model.is_trained());
455
456 let from_none = ProbeConfig::from_model(None, 0, 0, 0.0);
457 let from_untrained = ProbeConfig::from_model(Some(&model), 0, 100, 50.0);
458 let defaults = ProbeConfig::default();
459
460 assert_eq!(from_none.failure_threshold, defaults.failure_threshold);
461 assert_eq!(from_none.success_threshold, defaults.success_threshold);
462 assert_eq!(from_untrained.failure_threshold, defaults.failure_threshold);
463 assert_eq!(from_untrained.success_threshold, defaults.success_threshold);
464 }
465
466 #[test]
467 fn probe_config_from_trained_model_uses_prediction() {
468 let model = crate::eml_kernel::HealthThresholdModel::new();
471 let mut json = serde_json::to_value(&model).unwrap();
472 if let Some(inner) = json.get_mut("inner").and_then(|v| v.as_object_mut()) {
473 inner.insert("trained".into(), serde_json::Value::Bool(true));
474 }
475 let forced: crate::eml_kernel::HealthThresholdModel =
476 serde_json::from_value(json).unwrap();
477 assert!(forced.is_trained());
478
479 let cfg = ProbeConfig::from_model(Some(&forced), 0, 100, 50.0);
480 assert!((1..=20).contains(&cfg.failure_threshold));
482 assert!((1..=20).contains(&cfg.success_threshold));
483 assert!(cfg.failure_threshold >= cfg.success_threshold);
485 }
486
487 #[test]
488 fn probe_config_serde_roundtrip() {
489 let config = ProbeConfig {
490 liveness_interval_secs: 15,
491 readiness_interval_secs: 10,
492 failure_threshold: 5,
493 success_threshold: 2,
494 };
495 let json = serde_json::to_string(&config).unwrap();
496 let restored: ProbeConfig = serde_json::from_str(&json).unwrap();
497 assert_eq!(restored.liveness_interval_secs, 15);
498 assert_eq!(restored.failure_threshold, 5);
499 }
500
501 #[test]
502 fn probe_result_serde_roundtrip() {
503 let results = vec![
504 ProbeResult::Live,
505 ProbeResult::NotLive {
506 reason: "oom".into(),
507 },
508 ProbeResult::Ready,
509 ProbeResult::NotReady {
510 reason: "init".into(),
511 },
512 ];
513 for result in results {
514 let json = serde_json::to_string(&result).unwrap();
515 let restored: ProbeResult = serde_json::from_str(&json).unwrap();
516 assert_eq!(restored, result);
517 }
518 }
519
520 #[test]
521 fn probe_state_default_is_live_and_ready() {
522 let state = ProbeState::default();
523 assert!(state.is_live);
524 assert!(state.is_ready);
525 assert_eq!(state.liveness_failures, 0);
526 assert_eq!(state.readiness_failures, 0);
527 }
528
529 #[test]
530 fn liveness_resets_on_success() {
531 let config = ProbeConfig {
532 failure_threshold: 3,
533 ..Default::default()
534 };
535 let mut state = ProbeState::default();
536 state.liveness_failures = 2;
537
538 let restart = state.record_liveness(&ProbeResult::Live, &config);
539 assert!(!restart);
540 assert_eq!(state.liveness_failures, 0);
541 assert!(state.is_live);
542 }
543
544 #[test]
545 fn liveness_triggers_restart_at_threshold() {
546 let config = ProbeConfig {
547 failure_threshold: 3,
548 ..Default::default()
549 };
550 let mut state = ProbeState::default();
551
552 assert!(!state.record_liveness(
554 &ProbeResult::NotLive {
555 reason: "hang".into()
556 },
557 &config
558 ));
559 assert!(!state.record_liveness(
560 &ProbeResult::NotLive {
561 reason: "hang".into()
562 },
563 &config
564 ));
565 assert!(state.is_live);
566
567 assert!(state.record_liveness(
569 &ProbeResult::NotLive {
570 reason: "hang".into()
571 },
572 &config
573 ));
574 assert!(!state.is_live);
575 }
576
577 #[test]
578 fn readiness_removes_at_threshold() {
579 let config = ProbeConfig {
580 failure_threshold: 2,
581 ..Default::default()
582 };
583 let mut state = ProbeState::default();
584
585 assert!(
586 state
587 .record_readiness(
588 &ProbeResult::NotReady {
589 reason: "init".into()
590 },
591 &config
592 )
593 .is_none()
594 );
595 let change = state.record_readiness(
596 &ProbeResult::NotReady {
597 reason: "init".into(),
598 },
599 &config,
600 );
601 assert_eq!(change, Some(false)); assert!(!state.is_ready);
603 }
604
605 #[test]
606 fn readiness_recovery_re_adds() {
607 let config = ProbeConfig {
608 failure_threshold: 1,
609 success_threshold: 1,
610 ..Default::default()
611 };
612 let mut state = ProbeState::default();
613
614 state.record_readiness(
616 &ProbeResult::NotReady {
617 reason: "init".into(),
618 },
619 &config,
620 );
621 assert!(!state.is_ready);
622
623 let change = state.record_readiness(&ProbeResult::Ready, &config);
625 assert_eq!(change, Some(true));
626 assert!(state.is_ready);
627 }
628
629 #[test]
630 fn threshold_prevents_flapping() {
631 let config = ProbeConfig {
632 failure_threshold: 3,
633 success_threshold: 2,
634 ..Default::default()
635 };
636 let mut state = ProbeState::default();
637
638 assert!(
640 state
641 .record_readiness(&ProbeResult::NotReady { reason: "x".into() }, &config)
642 .is_none()
643 );
644 assert!(state.is_ready);
645
646 assert!(
648 state
649 .record_readiness(&ProbeResult::Ready, &config)
650 .is_none()
651 );
652 assert!(state.is_ready);
653 }
654
655 #[test]
656 fn default_probe_returns_live_ready() {
657 let live = ProbeResult::Live;
659 let ready = ProbeResult::Ready;
660 assert_eq!(live, ProbeResult::Live);
661 assert_eq!(ready, ProbeResult::Ready);
662 }
663 }
664}