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    ///
58    /// When mounted via [`router`](super::router) with no checks registered,
59    /// `/_ready` returns `200 OK`.
60    pub fn new() -> Self {
61        Self { checks: Vec::new() }
62    }
63
64    /// Registers a [`HealthCheck`] implementation under the given name.
65    ///
66    /// The name is recorded with the failure in the `check_name` tracing field
67    /// when `/_ready` reports a failure. Names are not required to be unique,
68    /// but uniqueness makes log triage easier.
69    pub fn check(mut self, name: &str, c: impl HealthCheck) -> Self {
70        self.checks.push((name.to_owned(), Arc::new(c)));
71        self
72    }
73
74    /// Registers a named health check from an async closure.
75    ///
76    /// The closure must return [`crate::Result<()>`] — `Ok(())` means healthy,
77    /// any `Err` marks the service unhealthy and causes `/_ready` to return
78    /// `503 Service Unavailable`.
79    pub fn check_fn<F, Fut>(mut self, name: &str, f: F) -> Self
80    where
81        F: Fn() -> Fut + Send + Sync + 'static,
82        Fut: Future<Output = Result<()>> + Send + 'static,
83    {
84        self.checks
85            .push((name.to_owned(), Arc::new(FnHealthCheck(f))));
86        self
87    }
88
89    /// Returns a slice of all registered checks.
90    pub(crate) fn as_slice(&self) -> &[(String, Arc<dyn HealthCheck>)] {
91        &self.checks
92    }
93}
94
95impl Default for HealthChecks {
96    /// Returns an empty [`HealthChecks`] collection.
97    fn default() -> Self {
98        Self::new()
99    }
100}
101
102impl HealthCheck for crate::db::Database {
103    fn check(&self) -> Pin<Box<dyn Future<Output = Result<()>> + Send + '_>> {
104        Box::pin(async {
105            self.conn()
106                .query("SELECT 1", ())
107                .await
108                .map_err(|e| crate::Error::internal("db health check failed").chain(e))?;
109            Ok(())
110        })
111    }
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117
118    #[tokio::test]
119    async fn database_health_check() {
120        let config = crate::db::Config {
121            path: ":memory:".to_string(),
122            ..Default::default()
123        };
124        let db = crate::db::connect(&config).await.unwrap();
125        db.check().await.unwrap();
126    }
127
128    #[tokio::test]
129    async fn fn_health_check_succeeds() {
130        let checks = HealthChecks::new().check_fn("ok", || async { Ok(()) });
131        let (_, c) = &checks.as_slice()[0];
132        c.check().await.unwrap();
133    }
134
135    #[tokio::test]
136    async fn fn_health_check_fails() {
137        let checks =
138            HealthChecks::new().check_fn("fail", || async { Err(crate::Error::internal("down")) });
139        let (_, c) = &checks.as_slice()[0];
140        assert!(c.check().await.is_err());
141    }
142
143    #[tokio::test]
144    async fn chaining_preserves_order() {
145        let checks = HealthChecks::new()
146            .check_fn("a", || async { Ok(()) })
147            .check_fn("b", || async { Ok(()) })
148            .check_fn("c", || async { Ok(()) });
149        let names: Vec<&str> = checks.as_slice().iter().map(|(n, _)| n.as_str()).collect();
150        assert_eq!(names, vec!["a", "b", "c"]);
151    }
152
153    #[tokio::test]
154    async fn health_checks_default_is_empty() {
155        let checks = HealthChecks::default();
156        assert!(checks.as_slice().is_empty());
157    }
158}