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