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 camel_component_api::test_support::timeout_wait;
134    use std::any::Any;
135
136    /// Catalog mock that hands out a pre-built pool for a single named datasource.
137    /// Mirrors how a real route configured with `?datasource=cartodb` resolves its pool.
138    struct CatalogWithPool {
139        name: String,
140        pool: Arc<AnyPool>,
141    }
142
143    impl DatasourceCatalog for CatalogWithPool {
144        fn get_config(&self, name: &str) -> Option<DatasourceConfig> {
145            (name == self.name).then(|| DatasourceConfig {
146                db_url: "sqlite::memory:".into(),
147                provider: None,
148                max_connections: None,
149                min_connections: None,
150                idle_timeout_secs: None,
151                max_lifetime_secs: None,
152                ssl_mode: None,
153                ssl_root_cert: None,
154                ssl_cert: None,
155                ssl_key: None,
156                extra: Default::default(),
157            })
158        }
159
160        fn get_pool<'a>(&'a self, name: &'a str) -> GetPoolFuture<'a> {
161            Box::pin(async move {
162                if name != self.name {
163                    return Err(CamelError::Config(format!("unknown datasource '{}'", name)));
164                }
165                let inner: Arc<dyn Any + Send + Sync> = self.pool.clone();
166                Ok(DatasourceHandle::new(
167                    name.to_string(),
168                    "sql".to_string(),
169                    inner,
170                ))
171            })
172        }
173
174        fn register_factory(
175            &self,
176            _kind: &str,
177            _factory: Arc<dyn PoolFactory>,
178        ) -> Result<(), CamelError> {
179            Err(CamelError::Config(
180                "mock: register_factory not supported".into(),
181            ))
182        }
183    }
184
185    struct MockProbe {
186        responder: Arc<dyn Fn() -> ProbeFuture + Send + Sync>,
187    }
188
189    impl MockProbe {
190        fn new<F>(f: F) -> Self
191        where
192            F: Fn() -> ProbeFuture + Send + Sync + 'static,
193        {
194            Self {
195                responder: Arc::new(f),
196            }
197        }
198    }
199
200    impl SqlHealthProbe for MockProbe {
201        fn probe(&self) -> ProbeFuture {
202            (self.responder)()
203        }
204    }
205
206    #[tokio::test]
207    async fn sql_health_check_healthy_when_probe_succeeds() {
208        let probe = Arc::new(MockProbe::new(|| Box::pin(async { Ok(()) })));
209        let check = SqlHealthCheck::with_probe_for_tests(probe, Duration::from_millis(50));
210
211        let result = check.check().await;
212
213        assert_eq!(result.name, "sql");
214        assert_eq!(result.status, HealthStatus::Healthy);
215        assert!(result.message.is_none());
216    }
217
218    #[tokio::test]
219    async fn sql_health_check_unhealthy_when_probe_fails() {
220        let probe = Arc::new(MockProbe::new(|| {
221            Box::pin(async {
222                Err(CamelError::ProcessorError(
223                    "simulated sql error".to_string(),
224                ))
225            })
226        }));
227        let check = SqlHealthCheck::with_probe_for_tests(probe, Duration::from_millis(50));
228
229        let result = check.check().await;
230
231        assert_eq!(result.name, "sql");
232        assert_eq!(result.status, HealthStatus::Unhealthy);
233        assert!(
234            result
235                .message
236                .as_deref()
237                .is_some_and(|m| m.contains("simulated sql error"))
238        );
239    }
240
241    #[tokio::test]
242    async fn sql_health_check_unhealthy_when_probe_times_out() {
243        let probe = Arc::new(MockProbe::new(|| {
244            Box::pin(async {
245                tokio::time::sleep(Duration::from_millis(50)).await;
246                Ok(())
247            })
248        }));
249        let check = SqlHealthCheck::with_probe_for_tests(probe, Duration::from_millis(5));
250
251        let result = check.check().await;
252
253        assert_eq!(result.name, "sql");
254        assert_eq!(result.status, HealthStatus::Unhealthy);
255        assert_eq!(result.message.as_deref(), Some("SELECT 1 timed out"));
256    }
257
258    // Regression: when a route uses `?datasource=<name>`, the producer's own
259    // pool OnceCell is never populated (writes go via the catalog's pool), so
260    // the health check must resolve the effective pool from the catalog.
261    // Otherwise it reports a permanent false-negative "pool not initialized".
262    #[tokio::test]
263    async fn sql_health_check_healthy_via_datasource_when_pool_cell_empty() {
264        sqlx::any::install_default_drivers();
265        let pool = Arc::new(
266            timeout_wait(
267                "sqlite pool connect",
268                Duration::from_secs(30),
269                sqlx::any::AnyPoolOptions::new()
270                    .max_connections(1)
271                    .connect("sqlite::memory:"),
272            )
273            .await
274            .expect("sqlite pool"),
275        );
276        let catalog: Arc<dyn DatasourceCatalog> = Arc::new(CatalogWithPool {
277            name: "cartodb".to_string(),
278            pool,
279        });
280        // Empty pool cell — simulates the datasource route where the producer's
281        // own OnceCell stays unset because real writes go through the catalog.
282        let empty_cell: Arc<OnceCell<Arc<AnyPool>>> = Arc::new(OnceCell::new());
283
284        let check = SqlHealthCheck::new(empty_cell, Some(catalog), Some("cartodb".to_string()));
285
286        let result = check.check().await;
287
288        assert_eq!(
289            result.status,
290            HealthStatus::Healthy,
291            "health check must resolve pool from catalog when datasource_name is set"
292        );
293    }
294
295    #[tokio::test]
296    async fn sql_health_check_degraded_when_pool_not_created() {
297        let empty_cell: Arc<OnceCell<Arc<AnyPool>>> = Arc::new(OnceCell::new());
298        let check = SqlHealthCheck::new(empty_cell, None, None);
299
300        let result = check.check().await;
301
302        assert_eq!(result.status, HealthStatus::Degraded);
303        assert!(
304            result
305                .message
306                .as_deref()
307                .is_some_and(|m| m.contains("created on first use"))
308        );
309    }
310
311    #[tokio::test]
312    async fn sql_health_check_unhealthy_when_datasource_unknown() {
313        let empty_cell: Arc<OnceCell<Arc<AnyPool>>> = Arc::new(OnceCell::new());
314        let catalog: Arc<dyn DatasourceCatalog> = Arc::new(CatalogWithPool {
315            name: "other".to_string(),
316            pool: Arc::new({
317                sqlx::any::install_default_drivers();
318                sqlx::any::AnyPoolOptions::new()
319                    .connect_lazy("sqlite::memory:")
320                    .expect("lazy pool")
321            }),
322        });
323        let check = SqlHealthCheck::new(empty_cell, Some(catalog), Some("missing".to_string()));
324
325        let result = check.check().await;
326
327        assert_eq!(result.status, HealthStatus::Unhealthy);
328    }
329}