Skip to main content

camel_component_sql/
health.rs

1use async_trait::async_trait;
2use camel_api::datasource::DatasourceCatalog;
3use camel_api::{AsyncHealthCheck, CheckResult};
4use camel_component_api::CamelError;
5use sqlx::AnyPool;
6use std::future::Future;
7use std::pin::Pin;
8use std::sync::Arc;
9use std::time::Duration;
10use tokio::sync::OnceCell;
11
12type ProbeFuture = Pin<Box<dyn Future<Output = Result<(), CamelError>> + Send>>;
13
14trait SqlHealthProbe: Send + Sync {
15    fn probe(&self) -> ProbeFuture;
16
17    /// True while the producer's own lazily created pool has not been built
18    /// yet. Readiness reports this as Degraded (200).
19    fn not_started(&self) -> bool {
20        false
21    }
22}
23
24struct SqlPoolProbe {
25    pool: Arc<OnceCell<Arc<AnyPool>>>,
26    catalog: Option<Arc<dyn DatasourceCatalog>>,
27    datasource_name: Option<String>,
28}
29
30impl SqlPoolProbe {
31    fn new(
32        pool: Arc<OnceCell<Arc<AnyPool>>>,
33        catalog: Option<Arc<dyn DatasourceCatalog>>,
34        datasource_name: Option<String>,
35    ) -> Self {
36        Self {
37            pool,
38            catalog,
39            datasource_name,
40        }
41    }
42}
43
44impl SqlHealthProbe for SqlPoolProbe {
45    fn not_started(&self) -> bool {
46        !(self.catalog.is_some() && self.datasource_name.is_some()) && self.pool.get().is_none()
47    }
48
49    fn probe(&self) -> ProbeFuture {
50        let pool = Arc::clone(&self.pool);
51        let catalog = self.catalog.clone();
52        let datasource_name = self.datasource_name.clone();
53        Box::pin(async move {
54            // Resolve the effective pool. When the route uses a named datasource,
55            // the producer's own OnceCell is never populated — writes (and the
56            // health probe) must go through the catalog's shared pool. This
57            // mirrors SqlProducer::check_connection / call pool resolution.
58            let pool: Arc<AnyPool> = if let (Some(catalog), Some(ds_name)) =
59                (catalog.as_ref(), datasource_name.as_deref())
60            {
61                let handle = catalog.get_pool(ds_name).await?;
62                handle.downcast::<AnyPool>()?
63            } else {
64                pool.get()
65                    .ok_or_else(|| {
66                        CamelError::ProcessorError(
67                            "SQL connection pool not initialized".to_string(),
68                        )
69                    })?
70                    .clone()
71            };
72
73            sqlx::query("SELECT 1")
74                .execute(pool.as_ref())
75                .await
76                .map_err(|e| {
77                    CamelError::ProcessorError(format!("SQL health check failed: {}", e))
78                })?;
79
80            Ok(())
81        })
82    }
83}
84
85pub struct SqlHealthCheck {
86    probe: Arc<dyn SqlHealthProbe>,
87    timeout: Duration,
88}
89
90impl SqlHealthCheck {
91    pub fn new(
92        pool: Arc<OnceCell<Arc<AnyPool>>>,
93        catalog: Option<Arc<dyn DatasourceCatalog>>,
94        datasource_name: Option<String>,
95    ) -> Self {
96        Self {
97            probe: Arc::new(SqlPoolProbe::new(pool, catalog, datasource_name)),
98            timeout: Duration::from_secs(2),
99        }
100    }
101
102    #[cfg(test)]
103    fn with_probe_for_tests(probe: Arc<dyn SqlHealthProbe>, timeout: Duration) -> Self {
104        Self { probe, timeout }
105    }
106}
107
108#[async_trait]
109impl AsyncHealthCheck for SqlHealthCheck {
110    fn name(&self) -> &str {
111        "sql"
112    }
113
114    async fn check(&self) -> CheckResult {
115        if self.probe.not_started() {
116            return CheckResult::degraded(self.name(), "pool not created (created on first use)");
117        }
118        match tokio::time::timeout(self.timeout, self.probe.probe()).await {
119            Ok(Ok(())) => CheckResult::healthy(self.name()),
120            Ok(Err(err)) => CheckResult::unhealthy(self.name(), &err.to_string()),
121            Err(_) => CheckResult::unhealthy(self.name(), "SELECT 1 timed out"),
122        }
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129    use camel_api::HealthStatus;
130    use camel_api::datasource::{
131        DatasourceCatalog, DatasourceConfig, DatasourceHandle, GetPoolFuture, PoolFactory,
132    };
133    use std::any::Any;
134
135    /// Catalog mock that hands out a pre-built pool for a single named datasource.
136    /// Mirrors how a real route configured with `?datasource=cartodb` resolves its pool.
137    struct CatalogWithPool {
138        name: String,
139        pool: Arc<AnyPool>,
140    }
141
142    impl DatasourceCatalog for CatalogWithPool {
143        fn get_config(&self, name: &str) -> Option<DatasourceConfig> {
144            (name == self.name).then(|| DatasourceConfig {
145                db_url: "sqlite::memory:".into(),
146                provider: None,
147                max_connections: None,
148                min_connections: None,
149                idle_timeout_secs: None,
150                max_lifetime_secs: None,
151                ssl_mode: None,
152                ssl_root_cert: None,
153                ssl_cert: None,
154                ssl_key: None,
155                extra: Default::default(),
156            })
157        }
158
159        fn get_pool<'a>(&'a self, name: &'a str) -> GetPoolFuture<'a> {
160            Box::pin(async move {
161                if name != self.name {
162                    return Err(CamelError::Config(format!("unknown datasource '{}'", name)));
163                }
164                let inner: Arc<dyn Any + Send + Sync> = self.pool.clone();
165                Ok(DatasourceHandle::new(
166                    name.to_string(),
167                    "sql".to_string(),
168                    inner,
169                ))
170            })
171        }
172
173        fn register_factory(
174            &self,
175            _kind: &str,
176            _factory: Arc<dyn PoolFactory>,
177        ) -> Result<(), CamelError> {
178            Err(CamelError::Config(
179                "mock: register_factory not supported".into(),
180            ))
181        }
182    }
183
184    struct MockProbe {
185        responder: Arc<dyn Fn() -> ProbeFuture + Send + Sync>,
186    }
187
188    impl MockProbe {
189        fn new<F>(f: F) -> Self
190        where
191            F: Fn() -> ProbeFuture + Send + Sync + 'static,
192        {
193            Self {
194                responder: Arc::new(f),
195            }
196        }
197    }
198
199    impl SqlHealthProbe for MockProbe {
200        fn probe(&self) -> ProbeFuture {
201            (self.responder)()
202        }
203    }
204
205    #[tokio::test]
206    async fn sql_health_check_healthy_when_probe_succeeds() {
207        let probe = Arc::new(MockProbe::new(|| Box::pin(async { Ok(()) })));
208        let check = SqlHealthCheck::with_probe_for_tests(probe, Duration::from_millis(50));
209
210        let result = check.check().await;
211
212        assert_eq!(result.name, "sql");
213        assert_eq!(result.status, HealthStatus::Healthy);
214        assert!(result.message.is_none());
215    }
216
217    #[tokio::test]
218    async fn sql_health_check_unhealthy_when_probe_fails() {
219        let probe = Arc::new(MockProbe::new(|| {
220            Box::pin(async {
221                Err(CamelError::ProcessorError(
222                    "simulated sql error".to_string(),
223                ))
224            })
225        }));
226        let check = SqlHealthCheck::with_probe_for_tests(probe, Duration::from_millis(50));
227
228        let result = check.check().await;
229
230        assert_eq!(result.name, "sql");
231        assert_eq!(result.status, HealthStatus::Unhealthy);
232        assert!(
233            result
234                .message
235                .as_deref()
236                .is_some_and(|m| m.contains("simulated sql error"))
237        );
238    }
239
240    #[tokio::test]
241    async fn sql_health_check_unhealthy_when_probe_times_out() {
242        let probe = Arc::new(MockProbe::new(|| {
243            Box::pin(async {
244                tokio::time::sleep(Duration::from_millis(50)).await;
245                Ok(())
246            })
247        }));
248        let check = SqlHealthCheck::with_probe_for_tests(probe, Duration::from_millis(5));
249
250        let result = check.check().await;
251
252        assert_eq!(result.name, "sql");
253        assert_eq!(result.status, HealthStatus::Unhealthy);
254        assert_eq!(result.message.as_deref(), Some("SELECT 1 timed out"));
255    }
256
257    // Regression: when a route uses `?datasource=<name>`, the producer's own
258    // pool OnceCell is never populated (writes go via the catalog's pool), so
259    // the health check must resolve the effective pool from the catalog.
260    // Otherwise it reports a permanent false-negative "pool not initialized".
261    #[tokio::test]
262    async fn sql_health_check_healthy_via_datasource_when_pool_cell_empty() {
263        sqlx::any::install_default_drivers();
264        let pool = Arc::new(
265            sqlx::any::AnyPoolOptions::new()
266                .max_connections(1)
267                .connect("sqlite::memory:")
268                .await
269                .expect("sqlite pool"),
270        );
271        let catalog: Arc<dyn DatasourceCatalog> = Arc::new(CatalogWithPool {
272            name: "cartodb".to_string(),
273            pool,
274        });
275        // Empty pool cell — simulates the datasource route where the producer's
276        // own OnceCell stays unset because real writes go through the catalog.
277        let empty_cell: Arc<OnceCell<Arc<AnyPool>>> = Arc::new(OnceCell::new());
278
279        let check = SqlHealthCheck::new(empty_cell, Some(catalog), Some("cartodb".to_string()));
280
281        let result = check.check().await;
282
283        assert_eq!(
284            result.status,
285            HealthStatus::Healthy,
286            "health check must resolve pool from catalog when datasource_name is set"
287        );
288    }
289
290    #[tokio::test]
291    async fn sql_health_check_degraded_when_pool_not_created() {
292        let empty_cell: Arc<OnceCell<Arc<AnyPool>>> = Arc::new(OnceCell::new());
293        let check = SqlHealthCheck::new(empty_cell, None, None);
294
295        let result = check.check().await;
296
297        assert_eq!(result.status, HealthStatus::Degraded);
298        assert!(
299            result
300                .message
301                .as_deref()
302                .is_some_and(|m| m.contains("created on first use"))
303        );
304    }
305
306    #[tokio::test]
307    async fn sql_health_check_unhealthy_when_datasource_unknown() {
308        let empty_cell: Arc<OnceCell<Arc<AnyPool>>> = Arc::new(OnceCell::new());
309        let catalog: Arc<dyn DatasourceCatalog> = Arc::new(CatalogWithPool {
310            name: "other".to_string(),
311            pool: Arc::new({
312                sqlx::any::install_default_drivers();
313                sqlx::any::AnyPoolOptions::new()
314                    .connect_lazy("sqlite::memory:")
315                    .expect("lazy pool")
316            }),
317        });
318        let check = SqlHealthCheck::new(empty_cell, Some(catalog), Some("missing".to_string()));
319
320        let result = check.check().await;
321
322        assert_eq!(result.status, HealthStatus::Unhealthy);
323    }
324}