Skip to main content

modo/health/
check.rs

1use std::pin::Pin;
2use std::sync::Arc;
3
4use crate::Result;
5
6/// A health check that can verify the readiness of a service.
7///
8/// Implement this trait for types that can verify their own health (e.g.,
9/// database pools, cache connections). The check should be fast and
10/// non-destructive.
11///
12/// [`crate::db::Database`] implements this trait automatically, verifying
13/// health by executing `SELECT 1` on the connection.
14pub trait HealthCheck: Send + Sync + 'static {
15    /// Run the health check.
16    ///
17    /// # Errors
18    ///
19    /// Returns [`crate::Error`] if the service is unhealthy or unreachable.
20    fn check(&self) -> Pin<Box<dyn Future<Output = Result<()>> + Send + '_>>;
21}
22
23/// Internal adapter that wraps a closure into a [`HealthCheck`].
24struct FnHealthCheck<F>(F);
25
26impl<F, Fut> HealthCheck for FnHealthCheck<F>
27where
28    F: Fn() -> Fut + Send + Sync + 'static,
29    Fut: Future<Output = Result<()>> + Send + 'static,
30{
31    fn check(&self) -> Pin<Box<dyn Future<Output = Result<()>> + Send + '_>> {
32        Box::pin((self.0)())
33    }
34}
35
36/// A collection of named health checks.
37///
38/// Built with a fluent API and registered in the
39/// [`service::Registry`](crate::service::Registry). The readiness endpoint
40/// runs all checks concurrently and reports failures.
41///
42/// # Example
43///
44/// ```
45/// use modo::health::HealthChecks;
46///
47/// let checks = HealthChecks::new()
48///     .check_fn("database", || async { Ok(()) })
49///     .check_fn("redis", || async { Ok(()) });
50/// ```
51pub struct HealthChecks {
52    checks: Vec<(String, Arc<dyn HealthCheck>)>,
53}
54
55impl HealthChecks {
56    /// Creates an empty collection with no registered checks.
57    pub fn new() -> Self {
58        Self { checks: Vec::new() }
59    }
60
61    /// Registers a [`HealthCheck`] implementation under the given name.
62    ///
63    /// The name is used in error logs when the check fails.
64    pub fn check(mut self, name: &str, c: impl HealthCheck) -> Self {
65        self.checks.push((name.to_owned(), Arc::new(c)));
66        self
67    }
68
69    /// Registers a named health check from an async closure.
70    ///
71    /// The closure must return [`crate::Result<()>`] — `Ok(())` means healthy,
72    /// any `Err` marks the service unhealthy and causes `/_ready` to return
73    /// `503`.
74    pub fn check_fn<F, Fut>(mut self, name: &str, f: F) -> Self
75    where
76        F: Fn() -> Fut + Send + Sync + 'static,
77        Fut: Future<Output = Result<()>> + Send + 'static,
78    {
79        self.checks
80            .push((name.to_owned(), Arc::new(FnHealthCheck(f))));
81        self
82    }
83
84    /// Returns a slice of all registered checks.
85    pub(crate) fn as_slice(&self) -> &[(String, Arc<dyn HealthCheck>)] {
86        &self.checks
87    }
88}
89
90impl Default for HealthChecks {
91    /// Returns an empty [`HealthChecks`] collection.
92    fn default() -> Self {
93        Self::new()
94    }
95}
96
97impl HealthCheck for crate::db::Database {
98    fn check(&self) -> Pin<Box<dyn Future<Output = Result<()>> + Send + '_>> {
99        Box::pin(async {
100            self.conn()
101                .query("SELECT 1", ())
102                .await
103                .map_err(|e| crate::Error::internal("db health check failed").chain(e))?;
104            Ok(())
105        })
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112
113    #[tokio::test]
114    async fn database_health_check() {
115        let config = crate::db::Config {
116            path: ":memory:".to_string(),
117            ..Default::default()
118        };
119        let db = crate::db::connect(&config).await.unwrap();
120        db.check().await.unwrap();
121    }
122
123    #[tokio::test]
124    async fn fn_health_check_succeeds() {
125        let checks = HealthChecks::new().check_fn("ok", || async { Ok(()) });
126        let (_, c) = &checks.as_slice()[0];
127        c.check().await.unwrap();
128    }
129
130    #[tokio::test]
131    async fn fn_health_check_fails() {
132        let checks =
133            HealthChecks::new().check_fn("fail", || async { Err(crate::Error::internal("down")) });
134        let (_, c) = &checks.as_slice()[0];
135        assert!(c.check().await.is_err());
136    }
137
138    #[tokio::test]
139    async fn chaining_preserves_order() {
140        let checks = HealthChecks::new()
141            .check_fn("a", || async { Ok(()) })
142            .check_fn("b", || async { Ok(()) })
143            .check_fn("c", || async { Ok(()) });
144        let names: Vec<&str> = checks.as_slice().iter().map(|(n, _)| n.as_str()).collect();
145        assert_eq!(names, vec!["a", "b", "c"]);
146    }
147
148    #[tokio::test]
149    async fn health_checks_default_is_empty() {
150        let checks = HealthChecks::default();
151        assert!(checks.as_slice().is_empty());
152    }
153}