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 std::any::Any;
134
135 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 #[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 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}