Skip to main content

hyperi_rustlib/health/
registry.rs

1// Project:   hyperi-rustlib
2// File:      src/health/registry.rs
3// Purpose:   Global health registry singleton for component health tracking
4// Language:  Rust
5//
6// License:   FSL-1.1-ALv2
7// Copyright: (c) 2026 HYPERI PTY LIMITED
8
9//! Global health registry for unified service health state.
10//!
11//! Modules register health check callbacks at construction. The registry
12//! aggregates component status to determine overall service health.
13//!
14//! # Design
15//!
16//! - Global singleton via `OnceLock` (consistent with config registry pattern)
17//! - Components register a closure that returns their current [`HealthStatus`]
18//! - [`is_healthy`](HealthRegistry::is_healthy) requires ALL components healthy
19//! - [`is_ready`](HealthRegistry::is_ready) requires NO components unhealthy
20//!   (degraded is acceptable for readiness)
21//! - Empty registry is considered healthy (vacuously true)
22
23use std::sync::{Arc, Mutex, OnceLock};
24
25/// Health status of a registered component.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum HealthStatus {
28    /// Component is fully operational.
29    Healthy,
30    /// Component is operational but impaired (e.g., circuit half-open,
31    /// elevated latency, fallback active).
32    Degraded,
33    /// Component is not operational. Service should not receive traffic.
34    Unhealthy,
35}
36
37impl HealthStatus {
38    /// String representation for JSON serialisation and endpoint output.
39    #[must_use]
40    pub fn as_str(self) -> &'static str {
41        match self {
42            Self::Healthy => "healthy",
43            Self::Degraded => "degraded",
44            Self::Unhealthy => "unhealthy",
45        }
46    }
47}
48
49/// Health check callback — returns current component status.
50type HealthCheck = Arc<dyn Fn() -> HealthStatus + Send + Sync>;
51
52/// A registered health check entry.
53struct HealthEntry {
54    name: String,
55    check: HealthCheck,
56}
57
58/// Global health registry singleton.
59///
60/// Modules register health check callbacks at construction. The registry
61/// aggregates all component statuses to determine overall service health.
62///
63/// # Thread Safety
64///
65/// The registry uses `Mutex<Vec<_>>` for registration (infrequent, at
66/// init time) and read access (health checks). For the typical DFE app
67/// with 3-8 registered components, lock contention is negligible.
68pub struct HealthRegistry {
69    components: Mutex<Vec<HealthEntry>>,
70}
71
72/// Global singleton instance.
73static REGISTRY: OnceLock<HealthRegistry> = OnceLock::new();
74
75impl HealthRegistry {
76    /// Create a new empty registry.
77    fn new() -> Self {
78        Self {
79            components: Mutex::new(Vec::new()),
80        }
81    }
82
83    /// Get or initialise the global registry.
84    fn global() -> &'static Self {
85        REGISTRY.get_or_init(Self::new)
86    }
87
88    /// Register a health check callback.
89    ///
90    /// Called by modules at construction time. The callback is invoked
91    /// each time health is queried, so it should be cheap (e.g., read
92    /// an `AtomicBool` or check a cached state).
93    ///
94    /// # Duplicate Names
95    ///
96    /// Multiple components may register with the same name. Each
97    /// registration is independent — the registry does not deduplicate.
98    pub fn register(
99        name: impl Into<String>,
100        check: impl Fn() -> HealthStatus + Send + Sync + 'static,
101    ) {
102        let registry = Self::global();
103        if let Ok(mut components) = registry.components.lock() {
104            components.push(HealthEntry {
105                name: name.into(),
106                check: Arc::new(check),
107            });
108        }
109    }
110
111    /// Check if ALL components are healthy.
112    ///
113    /// Returns `true` if the registry is empty (vacuously true) or
114    /// every registered component reports [`HealthStatus::Healthy`].
115    #[must_use]
116    pub fn is_healthy() -> bool {
117        let registry = Self::global();
118        let Ok(components) = registry.components.lock() else {
119            return false;
120        };
121        components
122            .iter()
123            .all(|c| (c.check)() == HealthStatus::Healthy)
124    }
125
126    /// Check if the service is ready to receive traffic.
127    ///
128    /// Ready means no component is [`HealthStatus::Unhealthy`]. Degraded
129    /// components are acceptable — the service can still serve requests,
130    /// just with reduced capability.
131    ///
132    /// Returns `true` if the registry is empty (vacuously true).
133    #[must_use]
134    pub fn is_ready() -> bool {
135        let registry = Self::global();
136        let Ok(components) = registry.components.lock() else {
137            return false;
138        };
139        components
140            .iter()
141            .all(|c| (c.check)() != HealthStatus::Unhealthy)
142    }
143
144    /// Get per-component health status.
145    ///
146    /// Returns a snapshot of all registered components and their current
147    /// status. Useful for detailed health endpoints.
148    #[must_use]
149    pub fn components() -> Vec<(String, HealthStatus)> {
150        let registry = Self::global();
151        let Ok(components) = registry.components.lock() else {
152            return Vec::new();
153        };
154        components
155            .iter()
156            .map(|c| (c.name.clone(), (c.check)()))
157            .collect()
158    }
159
160    /// Get a JSON representation of the health state.
161    ///
162    /// Suitable for a `/health/detailed` endpoint response.
163    #[cfg(feature = "serde_json")]
164    #[must_use]
165    pub fn to_json() -> serde_json::Value {
166        let components = Self::components();
167        let overall = if Self::is_healthy() {
168            "healthy"
169        } else if Self::is_ready() {
170            "degraded"
171        } else {
172            "unhealthy"
173        };
174
175        serde_json::json!({
176            "status": overall,
177            "components": components.iter().map(|(name, status)| {
178                serde_json::json!({
179                    "name": name,
180                    "status": status.as_str(),
181                })
182            }).collect::<Vec<_>>()
183        })
184    }
185
186    /// Clear all registered components (for testing only).
187    #[cfg(test)]
188    pub(crate) fn reset() {
189        let registry = Self::global();
190        if let Ok(mut components) = registry.components.lock() {
191            components.clear();
192        }
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use std::sync::atomic::{AtomicU8, Ordering};
199
200    use super::*;
201
202    /// Tests share global statics — serialise them.
203    static TEST_LOCK: Mutex<()> = Mutex::new(());
204
205    macro_rules! serial_test {
206        () => {
207            let _guard = TEST_LOCK.lock().unwrap();
208            HealthRegistry::reset();
209        };
210    }
211
212    #[test]
213    fn empty_registry_is_healthy() {
214        serial_test!();
215
216        assert!(HealthRegistry::is_healthy());
217        assert!(HealthRegistry::is_ready());
218        assert!(HealthRegistry::components().is_empty());
219    }
220
221    #[test]
222    fn register_and_check_healthy() {
223        serial_test!();
224
225        HealthRegistry::register("transport", || HealthStatus::Healthy);
226        HealthRegistry::register("database", || HealthStatus::Healthy);
227
228        assert!(HealthRegistry::is_healthy());
229        assert!(HealthRegistry::is_ready());
230
231        let components = HealthRegistry::components();
232        assert_eq!(components.len(), 2);
233        assert_eq!(components[0].0, "transport");
234        assert_eq!(components[0].1, HealthStatus::Healthy);
235        assert_eq!(components[1].0, "database");
236        assert_eq!(components[1].1, HealthStatus::Healthy);
237    }
238
239    #[test]
240    fn unhealthy_component_fails_check() {
241        serial_test!();
242
243        HealthRegistry::register("transport", || HealthStatus::Healthy);
244        HealthRegistry::register("database", || HealthStatus::Unhealthy);
245
246        assert!(!HealthRegistry::is_healthy());
247        assert!(!HealthRegistry::is_ready());
248    }
249
250    #[test]
251    fn degraded_is_ready_but_not_healthy() {
252        serial_test!();
253
254        HealthRegistry::register("transport", || HealthStatus::Healthy);
255        HealthRegistry::register("circuit_breaker", || HealthStatus::Degraded);
256
257        assert!(!HealthRegistry::is_healthy());
258        assert!(HealthRegistry::is_ready());
259    }
260
261    #[test]
262    fn dynamic_health_check_reflects_state_changes() {
263        serial_test!();
264
265        // Simulate a component whose health changes at runtime
266        let state = Arc::new(AtomicU8::new(0)); // 0=healthy, 1=degraded, 2=unhealthy
267        let state_clone = state.clone();
268
269        HealthRegistry::register("dynamic", move || {
270            match state_clone.load(Ordering::Relaxed) {
271                0 => HealthStatus::Healthy,
272                1 => HealthStatus::Degraded,
273                _ => HealthStatus::Unhealthy,
274            }
275        });
276
277        // Initially healthy
278        assert!(HealthRegistry::is_healthy());
279        assert!(HealthRegistry::is_ready());
280
281        // Transition to degraded
282        state.store(1, Ordering::Relaxed);
283        assert!(!HealthRegistry::is_healthy());
284        assert!(HealthRegistry::is_ready());
285
286        // Transition to unhealthy
287        state.store(2, Ordering::Relaxed);
288        assert!(!HealthRegistry::is_healthy());
289        assert!(!HealthRegistry::is_ready());
290
291        // Recovery back to healthy
292        state.store(0, Ordering::Relaxed);
293        assert!(HealthRegistry::is_healthy());
294        assert!(HealthRegistry::is_ready());
295    }
296
297    #[test]
298    fn health_status_as_str() {
299        assert_eq!(HealthStatus::Healthy.as_str(), "healthy");
300        assert_eq!(HealthStatus::Degraded.as_str(), "degraded");
301        assert_eq!(HealthStatus::Unhealthy.as_str(), "unhealthy");
302    }
303
304    #[test]
305    #[cfg(feature = "serde_json")]
306    fn to_json_includes_all_components() {
307        serial_test!();
308
309        HealthRegistry::register("kafka", || HealthStatus::Healthy);
310        HealthRegistry::register("clickhouse", || HealthStatus::Degraded);
311
312        let json = HealthRegistry::to_json();
313
314        assert_eq!(json["status"], "degraded");
315
316        let components = json["components"].as_array().unwrap();
317        assert_eq!(components.len(), 2);
318
319        assert_eq!(components[0]["name"], "kafka");
320        assert_eq!(components[0]["status"], "healthy");
321
322        assert_eq!(components[1]["name"], "clickhouse");
323        assert_eq!(components[1]["status"], "degraded");
324    }
325
326    #[test]
327    #[cfg(feature = "serde_json")]
328    fn to_json_empty_registry() {
329        serial_test!();
330
331        let json = HealthRegistry::to_json();
332        assert_eq!(json["status"], "healthy");
333        assert!(json["components"].as_array().unwrap().is_empty());
334    }
335
336    #[test]
337    #[cfg(feature = "serde_json")]
338    fn to_json_unhealthy_status() {
339        serial_test!();
340
341        HealthRegistry::register("broken", || HealthStatus::Unhealthy);
342
343        let json = HealthRegistry::to_json();
344        assert_eq!(json["status"], "unhealthy");
345    }
346}