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 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 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 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 #[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 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}