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    /// Contract (openspec pooldrain): `SqlPoolFactory::close` returns
241    /// successfully only after the pool has drained to zero connections,
242    /// verified against a pool that exercised two concurrent connections.
243    #[tokio::test]
244    async fn sql_pool_factory_close_drains_pool_to_zero() {
245        let f = SqlPoolFactory;
246        let cfg = DatasourceConfig {
247            db_url: "sqlite:file:memdb_drain_zero?mode=memory&cache=shared".into(),
248            provider: None,
249            max_connections: Some(3),
250            min_connections: None,
251            idle_timeout_secs: None,
252            max_lifetime_secs: None,
253            ssl_mode: None,
254            ssl_root_cert: None,
255            ssl_cert: None,
256            ssl_key: None,
257            extra: std::collections::HashMap::new(),
258        };
259        let inner = f.create(&cfg).await.unwrap();
260        let pool = Arc::downcast::<AnyPool>(Arc::clone(&inner)).unwrap();
261
262        // Hold the first acquired connection while acquiring a second,
263        // so both pooled connections exist concurrently.
264        let mut conn1 = pool.acquire().await.expect("first connection");
265        let mut conn2 = pool.acquire().await.expect("second connection");
266        sqlx::query("SELECT 1")
267            .execute(&mut *conn1)
268            .await
269            .expect("SELECT 1 through first connection");
270        sqlx::query("SELECT 1")
271            .execute(&mut *conn2)
272            .await
273            .expect("SELECT 1 through second connection");
274        drop(conn1);
275        drop(conn2);
276
277        let handle = DatasourceHandle::new("appdb".into(), f.name().into(), inner);
278        f.close(&handle).await.expect("factory close must succeed");
279        assert!(pool.is_closed(), "factory close must leave the pool closed");
280        assert_eq!(
281            pool.size(),
282            0,
283            "factory close must drain the pool to zero connections"
284        );
285    }
286
287    /// Probe (bd rc-25lup.4 review): does the named shared-memory URI
288    /// form genuinely share state across pooled connections? The
289    /// answer decides whether a lingering boot's connection could leak
290    /// rows into a later boot over the same URI.
291    #[tokio::test]
292    async fn named_shared_memory_uri_probe() {
293        use sqlx::Row;
294
295        let f = SqlPoolFactory;
296        let cfg = DatasourceConfig {
297            db_url: "sqlite:file:memdb_probe?mode=memory&cache=shared".into(),
298            provider: None,
299            max_connections: Some(3),
300            min_connections: None,
301            idle_timeout_secs: None,
302            max_lifetime_secs: None,
303            ssl_mode: None,
304            ssl_root_cert: None,
305            ssl_cert: None,
306            ssl_key: None,
307            extra: std::collections::HashMap::new(),
308        };
309        let inner = f.create(&cfg).await.unwrap();
310        let pool = Arc::downcast::<AnyPool>(Arc::clone(&inner)).unwrap();
311
312        sqlx::query("CREATE TABLE probe (v TEXT)")
313            .execute(&*pool)
314            .await
315            .expect("create");
316        // Force a second connection: hold one acquire while running the
317        // INSERT on another.
318        let conn1 = pool.acquire().await.expect("conn1");
319        sqlx::query("INSERT INTO probe VALUES ('x')")
320            .execute(&*pool)
321            .await
322            .expect("insert on a second connection");
323        drop(conn1);
324
325        let row = sqlx::query("SELECT COUNT(*) FROM probe")
326            .fetch_one(&*pool)
327            .await
328            .expect("count");
329        let n = row.try_get::<i64, usize>(0).expect("count i64");
330        assert_eq!(
331            n, 1,
332            "named shared memory URI must share across pool connections"
333        );
334    }
335
336    /// Truth table for the memory URL classifier (openspec pooldrain):
337    /// bare `:memory:` forms, named shared-cache `mode=memory` URLs,
338    /// uppercase scheme/query variants, and non-memory near misses.
339    #[test]
340    fn sqlite_memory_url_classifier_table() {
341        let cases: &[(&str, bool)] = &[
342            // True: bare memory forms and named shared-cache memory URLs.
343            ("sqlite::memory:", true),
344            ("sqlite://:memory:", true),
345            ("sqlite:file:memdb1?mode=memory&cache=shared", true),
346            // Uppercase scheme/query variants classify the same.
347            ("SQLITE::MEMORY:", true),
348            ("Sqlite:file:MEMDB2?MODE=MEMORY", true),
349            // Contrived: `mode=memory` inside a filename still matches.
350            ("sqlite:file:demo_mode=memory.db", true),
351            // False: ordinary paths, a `memory.db` filename, wrong scheme.
352            ("sqlite:data.db", false),
353            ("sqlite:file:memory.db", false),
354            ("postgres://host/db?mode=memory", false),
355            // rc-acrek boundary: `::memory:` inside a file path is not
356            // the bare `sqlite::memory:` form.
357            ("sqlite:file::memory:?cache=shared", false),
358        ];
359        for (url, expected) in cases {
360            assert_eq!(
361                is_sqlite_memory_url(url),
362                *expected,
363                "classifier mismatch for {url:?}"
364            );
365        }
366    }
367}