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    use camel_component_api::test_support::timeout_wait;
180
181    #[test]
182    fn sql_pool_factory_name() {
183        let f = SqlPoolFactory;
184        assert_eq!(f.name(), "sqlx");
185    }
186
187    #[test]
188    fn sql_pool_factory_supported_schemes() {
189        let f = SqlPoolFactory;
190        assert!(f.supported_schemes().contains(&"postgres"));
191        assert!(f.supported_schemes().contains(&"mysql"));
192        assert!(f.supported_schemes().contains(&"sqlite"));
193    }
194
195    #[test]
196    fn sql_pool_factory_matches_postgres() {
197        let f = SqlPoolFactory;
198        let cfg = DatasourceConfig {
199            db_url: "postgres://localhost/test".into(),
200            provider: None,
201            max_connections: None,
202            min_connections: None,
203            idle_timeout_secs: None,
204            max_lifetime_secs: None,
205            ssl_mode: None,
206            ssl_root_cert: None,
207            ssl_cert: None,
208            ssl_key: None,
209            extra: std::collections::HashMap::new(),
210        };
211        assert!(f.matches(&cfg));
212    }
213
214    #[tokio::test]
215    async fn sql_pool_factory_close_closes_the_pool() {
216        let f = SqlPoolFactory;
217        let cfg = DatasourceConfig {
218            db_url: "sqlite::memory:?cache=shared".into(),
219            provider: None,
220            max_connections: None,
221            min_connections: None,
222            idle_timeout_secs: None,
223            max_lifetime_secs: None,
224            ssl_mode: None,
225            ssl_root_cert: None,
226            ssl_cert: None,
227            ssl_key: None,
228            extra: std::collections::HashMap::new(),
229        };
230        let inner = f.create(&cfg).await.unwrap();
231        let pool = Arc::downcast::<AnyPool>(Arc::clone(&inner)).unwrap();
232        let handle = DatasourceHandle::new("appdb".into(), f.name().into(), Arc::clone(&inner));
233
234        f.close(&handle).await.unwrap();
235        assert!(
236            pool.is_closed(),
237            "factory close must drain the sqlx pool (bd rc-25lup.4)"
238        );
239    }
240
241    /// Contract (openspec pooldrain): `SqlPoolFactory::close` returns
242    /// successfully only after the pool has drained to zero connections,
243    /// verified against a pool that exercised two concurrent connections.
244    #[tokio::test]
245    async fn sql_pool_factory_close_drains_pool_to_zero() {
246        let f = SqlPoolFactory;
247        let cfg = DatasourceConfig {
248            db_url: "sqlite:file:memdb_drain_zero?mode=memory&cache=shared".into(),
249            provider: None,
250            max_connections: Some(3),
251            min_connections: None,
252            idle_timeout_secs: None,
253            max_lifetime_secs: None,
254            ssl_mode: None,
255            ssl_root_cert: None,
256            ssl_cert: None,
257            ssl_key: None,
258            extra: std::collections::HashMap::new(),
259        };
260        let inner = f.create(&cfg).await.unwrap();
261        let pool = Arc::downcast::<AnyPool>(Arc::clone(&inner)).unwrap();
262
263        // Hold the first acquired connection while acquiring a second,
264        // so both pooled connections exist concurrently.
265        let mut conn1 = timeout_wait(
266            "first pool acquire",
267            Duration::from_secs(30),
268            pool.acquire(),
269        )
270        .await
271        .expect("first connection");
272        let mut conn2 = timeout_wait(
273            "second pool acquire",
274            Duration::from_secs(30),
275            pool.acquire(),
276        )
277        .await
278        .expect("second connection");
279        sqlx::query("SELECT 1")
280            .execute(&mut *conn1)
281            .await
282            .expect("SELECT 1 through first connection");
283        sqlx::query("SELECT 1")
284            .execute(&mut *conn2)
285            .await
286            .expect("SELECT 1 through second connection");
287        drop(conn1);
288        drop(conn2);
289
290        let handle = DatasourceHandle::new("appdb".into(), f.name().into(), inner);
291        f.close(&handle).await.expect("factory close must succeed");
292        assert!(pool.is_closed(), "factory close must leave the pool closed");
293        assert_eq!(
294            pool.size(),
295            0,
296            "factory close must drain the pool to zero connections"
297        );
298    }
299
300    /// Probe (bd rc-25lup.4 review): does the named shared-memory URI
301    /// form genuinely share state across pooled connections? The
302    /// answer decides whether a lingering boot's connection could leak
303    /// rows into a later boot over the same URI.
304    #[tokio::test]
305    async fn named_shared_memory_uri_probe() {
306        use sqlx::Row;
307
308        let f = SqlPoolFactory;
309        let cfg = DatasourceConfig {
310            db_url: "sqlite:file:memdb_probe?mode=memory&cache=shared".into(),
311            provider: None,
312            max_connections: Some(3),
313            min_connections: None,
314            idle_timeout_secs: None,
315            max_lifetime_secs: None,
316            ssl_mode: None,
317            ssl_root_cert: None,
318            ssl_cert: None,
319            ssl_key: None,
320            extra: std::collections::HashMap::new(),
321        };
322        let inner = f.create(&cfg).await.unwrap();
323        let pool = Arc::downcast::<AnyPool>(Arc::clone(&inner)).unwrap();
324
325        sqlx::query("CREATE TABLE probe (v TEXT)")
326            .execute(&*pool)
327            .await
328            .expect("create");
329        // Force a second connection: hold one acquire while running the
330        // INSERT on another.
331        let conn1 = timeout_wait(
332            "probe pool acquire",
333            Duration::from_secs(30),
334            pool.acquire(),
335        )
336        .await
337        .expect("conn1");
338        sqlx::query("INSERT INTO probe VALUES ('x')")
339            .execute(&*pool)
340            .await
341            .expect("insert on a second connection");
342        drop(conn1);
343
344        let row = sqlx::query("SELECT COUNT(*) FROM probe")
345            .fetch_one(&*pool)
346            .await
347            .expect("count");
348        let n = row.try_get::<i64, usize>(0).expect("count i64");
349        assert_eq!(
350            n, 1,
351            "named shared memory URI must share across pool connections"
352        );
353    }
354
355    /// Truth table for the memory URL classifier (openspec pooldrain):
356    /// bare `:memory:` forms, named shared-cache `mode=memory` URLs,
357    /// uppercase scheme/query variants, and non-memory near misses.
358    #[test]
359    fn sqlite_memory_url_classifier_table() {
360        let cases: &[(&str, bool)] = &[
361            // True: bare memory forms and named shared-cache memory URLs.
362            ("sqlite::memory:", true),
363            ("sqlite://:memory:", true),
364            ("sqlite:file:memdb1?mode=memory&cache=shared", true),
365            // Uppercase scheme/query variants classify the same.
366            ("SQLITE::MEMORY:", true),
367            ("Sqlite:file:MEMDB2?MODE=MEMORY", true),
368            // Contrived: `mode=memory` inside a filename still matches.
369            ("sqlite:file:demo_mode=memory.db", true),
370            // False: ordinary paths, a `memory.db` filename, wrong scheme.
371            ("sqlite:data.db", false),
372            ("sqlite:file:memory.db", false),
373            ("postgres://host/db?mode=memory", false),
374            // rc-acrek boundary: `::memory:` inside a file path is not
375            // the bare `sqlite::memory:` form.
376            ("sqlite:file::memory:?cache=shared", false),
377        ];
378        for (url, expected) in cases {
379            assert_eq!(
380                is_sqlite_memory_url(url),
381                *expected,
382                "classifier mismatch for {url:?}"
383            );
384        }
385    }
386}