camel_component_sql/
health.rs1use 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 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 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 #[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 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}