Skip to main content

camel_component_sql/
pool_factory.rs

1use std::any::Any;
2use std::sync::Arc;
3use std::time::Duration;
4
5use camel_api::datasource::{CheckFuture, CloseFuture, CreatePoolFuture};
6use camel_api::datasource::{DatasourceConfig, DatasourceHandle, PoolFactory};
7use camel_api::error::CamelError;
8use camel_api::lifecycle::HealthStatus;
9use sqlx::AnyPool;
10use sqlx::any::AnyPoolOptions;
11
12use crate::config::{enrich_db_url_with_ssl_params, redact_db_url};
13
14/// True for sqlite URLs whose database lives in memory: the bare
15/// `:memory:` host forms and the named shared-cache form
16/// (`sqlite:file:memdb_x?mode=memory&cache=shared`).
17fn is_sqlite_memory_url(url: &str) -> bool {
18    let lowered = url.to_lowercase();
19    lowered.starts_with("sqlite::memory:")
20        || lowered.starts_with("sqlite://:memory:")
21        || (lowered.starts_with("sqlite:") && lowered.contains("mode=memory"))
22}
23
24/// How long `close` waits for in-flight connections to finish their
25/// async close after `pool.close()` resolved (sqlx 0.8.6 leaves them
26/// behind; bd rc-ywwz9).
27const IN_FLIGHT_DRAIN_WAIT: Duration = Duration::from_secs(10);
28/// Poll interval for that wait.
29const IN_FLIGHT_DRAIN_POLL: Duration = Duration::from_millis(5);
30
31pub struct SqlPoolFactory;
32
33impl PoolFactory for SqlPoolFactory {
34    fn create<'a>(&'a self, config: &'a DatasourceConfig) -> CreatePoolFuture<'a> {
35        Box::pin(async move {
36            // Install all compiled-in sqlx drivers so AnyPool can resolve them.
37            // This is idempotent; safe to call multiple times.
38            sqlx::any::install_default_drivers();
39
40            let max_conn = config.max_connections.unwrap_or(5);
41            // A `min_connections` maintainer on an in-memory sqlite pool
42            // fights the die-with-boot contract: sqlx 0.8.6's
43            // `try_min_connections` re-opens connections without checking
44            // `is_closed`, so a maintained pool can resurrect a connection
45            // after `close()` drains it and keep a named shared-cache
46            // database alive into the next boot in the same process
47            // (bd rc-ywwz9). Memory pools therefore never arm the
48            // maintainer, explicit setting included.
49            let min_conn = if is_sqlite_memory_url(&config.db_url) {
50                if config.min_connections.is_some_and(|m| m > 0) {
51                    // log-policy: outside-contract
52                    tracing::info!("datasource pool: min_connections ignored for in-memory sqlite");
53                }
54                0
55            } else {
56                config.min_connections.unwrap_or(1)
57            };
58            let idle_timeout = Duration::from_secs(config.idle_timeout_secs.unwrap_or(300));
59            let max_lifetime = Duration::from_secs(config.max_lifetime_secs.unwrap_or(1800));
60
61            let db_url = enrich_db_url_with_ssl_params(
62                &config.db_url,
63                config.ssl_mode.as_deref(),
64                config.ssl_root_cert.as_deref(),
65                config.ssl_cert.as_deref(),
66                config.ssl_key.as_deref(),
67            )?;
68
69            let pool = AnyPoolOptions::new()
70                .max_connections(max_conn)
71                .min_connections(min_conn)
72                .idle_timeout(idle_timeout)
73                .max_lifetime(max_lifetime)
74                .connect(&db_url)
75                .await
76                .map_err(|e| {
77                    CamelError::ProcessorError(format!(
78                        "failed to create datasource pool ({}): {}",
79                        redact_db_url(&config.db_url),
80                        e
81                    ))
82                })?;
83
84            tracing::info!("datasource pool created: max_connections={}", max_conn);
85            Ok(Arc::new(pool) as Arc<dyn Any + Send + Sync>)
86        })
87    }
88
89    fn check<'a>(&'a self, handle: &'a DatasourceHandle) -> CheckFuture<'a> {
90        Box::pin(async move {
91            match handle.downcast::<AnyPool>() {
92                Ok(pool) => match sqlx::query("SELECT 1").execute(&*pool).await {
93                    Ok(_) => HealthStatus::Healthy,
94                    Err(e) => {
95                        // log-policy: outside-contract
96                        tracing::warn!("datasource '{}' health check failed: {}", handle.name, e);
97                        HealthStatus::Unhealthy
98                    }
99                },
100                Err(e) => {
101                    // log-policy: outside-contract
102                    tracing::warn!(
103                        "datasource '{}' health check failed: pool downcast error: {}",
104                        handle.name,
105                        e
106                    );
107                    HealthStatus::Unhealthy
108                }
109            }
110        })
111    }
112
113    fn close<'a>(&'a self, handle: &'a DatasourceHandle) -> CloseFuture<'a> {
114        Box::pin(async move {
115            let pool = handle.downcast::<AnyPool>().map_err(|e| {
116                CamelError::ProcessorError(format!(
117                    "datasource '{}': pool close downcast failed: {}",
118                    handle.name, e
119                ))
120            })?;
121            // sqlx `close()` is infallible: it signals closure and drains
122            // idle connections; subsequent acquire calls fail closed.
123            pool.close().await;
124            // But `close().await` resolving does NOT mean the pool is
125            // empty (sqlx 0.8.6, verified by probe, bd rc-ywwz9): its
126            // acquire loop only blocks when every permit is held, so a
127            // connection still checked out inside a spawned
128            // `return_to_pool` task is left closing asynchronously —
129            // and it keeps a named shared-cache memory database alive
130            // into the next boot in the same process whenever the
131            // worker thread's close ack lags under load. Wait for the
132            // pool to actually reach size 0 (those tasks close their
133            // connection before returning; the min-connections clamp in
134            // `create` guarantees nothing resurrects it), bounded; a
135            // stall is an error — the pool would not be empty and the
136            // shutdown deadline still bounds the overall wait.
137            let drain_deadline = std::time::Instant::now() + IN_FLIGHT_DRAIN_WAIT;
138            while pool.size() > 0 {
139                if std::time::Instant::now() >= drain_deadline {
140                    // The convergence loop drains normal runs well inside
141                    // the bound (idle leftovers on the first extra pass,
142                    // in-flight closes within a few polls), so reaching
143                    // the cap means the pool genuinely did not drain —
144                    // report it: shutdown must not silently succeed while
145                    // a connection can keep a named shared-cache memory
146                    // database alive into the next boot (bd rc-ywwz9).
147                    return Err(CamelError::ProcessorError(format!(
148                        "datasource '{}': pool did not drain within {}s ({} connection(s) \
149                         still open) — the database may outlive its boot",
150                        handle.name,
151                        IN_FLIGHT_DRAIN_WAIT.as_secs(),
152                        pool.size()
153                    )));
154                }
155                // Yield so in-flight `return_to_pool` tasks progress, then
156                // drain again: a `close()` pass empties the idle queue
157                // (acked closes), the sleep lets checked-out connections
158                // finish their own async close. Both leftover shapes from
159                // the sqlx race converge here.
160                tokio::time::sleep(IN_FLIGHT_DRAIN_POLL).await;
161                pool.close().await;
162            }
163            Ok(())
164        })
165    }
166
167    fn supported_schemes(&self) -> &[&str] {
168        &["postgres", "postgresql", "mysql", "sqlite"]
169    }
170
171    fn name(&self) -> &'static str {
172        "sqlx"
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    #[test]
181    fn sql_pool_factory_name() {
182        let f = SqlPoolFactory;
183        assert_eq!(f.name(), "sqlx");
184    }
185
186    #[test]
187    fn sql_pool_factory_supported_schemes() {
188        let f = SqlPoolFactory;
189        assert!(f.supported_schemes().contains(&"postgres"));
190        assert!(f.supported_schemes().contains(&"mysql"));
191        assert!(f.supported_schemes().contains(&"sqlite"));
192    }
193
194    #[test]
195    fn sql_pool_factory_matches_postgres() {
196        let f = SqlPoolFactory;
197        let cfg = DatasourceConfig {
198            db_url: "postgres://localhost/test".into(),
199            provider: None,
200            max_connections: None,
201            min_connections: None,
202            idle_timeout_secs: None,
203            max_lifetime_secs: None,
204            ssl_mode: None,
205            ssl_root_cert: None,
206            ssl_cert: None,
207            ssl_key: None,
208            extra: std::collections::HashMap::new(),
209        };
210        assert!(f.matches(&cfg));
211    }
212
213    #[tokio::test]
214    async fn sql_pool_factory_close_closes_the_pool() {
215        let f = SqlPoolFactory;
216        let cfg = DatasourceConfig {
217            db_url: "sqlite::memory:?cache=shared".into(),
218            provider: None,
219            max_connections: None,
220            min_connections: None,
221            idle_timeout_secs: None,
222            max_lifetime_secs: None,
223            ssl_mode: None,
224            ssl_root_cert: None,
225            ssl_cert: None,
226            ssl_key: None,
227            extra: std::collections::HashMap::new(),
228        };
229        let inner = f.create(&cfg).await.unwrap();
230        let pool = Arc::downcast::<AnyPool>(Arc::clone(&inner)).unwrap();
231        let handle = DatasourceHandle::new("appdb".into(), f.name().into(), Arc::clone(&inner));
232
233        f.close(&handle).await.unwrap();
234        assert!(
235            pool.is_closed(),
236            "factory close must drain the sqlx pool (bd rc-25lup.4)"
237        );
238    }
239
240    /// Probe (bd rc-25lup.4 review): does the named shared-memory URI
241    /// form genuinely share state across pooled connections? The
242    /// answer decides whether a lingering boot's connection could leak
243    /// rows into a later boot over the same URI.
244    #[tokio::test]
245    async fn named_shared_memory_uri_probe() {
246        use sqlx::Row;
247
248        let f = SqlPoolFactory;
249        let cfg = DatasourceConfig {
250            db_url: "sqlite:file:memdb_probe?mode=memory&cache=shared".into(),
251            provider: None,
252            max_connections: Some(3),
253            min_connections: None,
254            idle_timeout_secs: None,
255            max_lifetime_secs: None,
256            ssl_mode: None,
257            ssl_root_cert: None,
258            ssl_cert: None,
259            ssl_key: None,
260            extra: std::collections::HashMap::new(),
261        };
262        let inner = f.create(&cfg).await.unwrap();
263        let pool = Arc::downcast::<AnyPool>(Arc::clone(&inner)).unwrap();
264
265        sqlx::query("CREATE TABLE probe (v TEXT)")
266            .execute(&*pool)
267            .await
268            .expect("create");
269        // Force a second connection: hold one acquire while running the
270        // INSERT on another.
271        let conn1 = pool.acquire().await.expect("conn1");
272        sqlx::query("INSERT INTO probe VALUES ('x')")
273            .execute(&*pool)
274            .await
275            .expect("insert on a second connection");
276        drop(conn1);
277
278        let row = sqlx::query("SELECT COUNT(*) FROM probe")
279            .fetch_one(&*pool)
280            .await
281            .expect("count");
282        let n = row.try_get::<i64, usize>(0).expect("count i64");
283        assert_eq!(
284            n, 1,
285            "named shared memory URI must share across pool connections"
286        );
287    }
288}