Skip to main content

qail_pg/driver/pool/
lifecycle.rs

1//! Pool lifecycle: PgPoolInner, PgPool core (connect, maintain, close),
2//! hot statement pre-prepare, and connection creation.
3
4use super::ScopedPoolFuture;
5use super::churn::{
6    PoolStats, decrement_active_count_saturating, pool_churn_record_destroy,
7    pool_churn_remaining_open, record_pool_connection_destroy,
8};
9use super::config::PoolConfig;
10use super::connection::PooledConn;
11use super::connection::PooledConnection;
12use super::gss::*;
13use crate::driver::{
14    AstPipelineMode, AutoCountPath, AutoCountPlan, ConnectOptions, PgConnection, PgError, PgResult,
15    is_ignorable_session_message, unexpected_backend_message,
16};
17use std::sync::Arc;
18use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
19use std::time::{Duration, Instant};
20use tokio::sync::{Mutex, Semaphore};
21use tokio::task::JoinSet;
22
23/// Maximum number of hot statements to track globally.
24pub(super) const MAX_HOT_STATEMENTS: usize = 32;
25
26/// Inner pool state (shared across clones).
27pub(super) struct PgPoolInner {
28    pub(super) config: PoolConfig,
29    pub(super) connections: Mutex<Vec<PooledConn>>,
30    pub(super) semaphore: Semaphore,
31    pub(super) closed: AtomicBool,
32    pub(super) active_count: AtomicUsize,
33    pub(super) total_created: AtomicUsize,
34    pub(super) leaked_cleanup_inflight: AtomicUsize,
35    /// Global registry of frequently-used prepared statements.
36    /// Maps sql_hash → (stmt_name, sql_text).
37    /// New connections pre-prepare these on checkout for instant cache hits.
38    pub(super) hot_statements: std::sync::RwLock<std::collections::HashMap<u64, (String, String)>>,
39}
40
41pub(super) fn handle_hot_preprepare_message(
42    msg: &crate::protocol::BackendMessage,
43    parse_complete_count: &mut usize,
44    error: &mut Option<PgError>,
45) -> PgResult<bool> {
46    match msg {
47        crate::protocol::BackendMessage::ParseComplete => {
48            *parse_complete_count += 1;
49            Ok(false)
50        }
51        crate::protocol::BackendMessage::ErrorResponse(err) => {
52            if error.is_none() {
53                *error = Some(PgError::QueryServer(err.clone().into()));
54            }
55            Ok(false)
56        }
57        crate::protocol::BackendMessage::ReadyForQuery(_) => Ok(true),
58        msg if is_ignorable_session_message(msg) => Ok(false),
59        other => Err(unexpected_backend_message("pool hot pre-prepare", other)),
60    }
61}
62
63fn evict_failed_hot_preprepare_entries(
64    pool: &PgPoolInner,
65    missing: &[(u64, String, String)],
66) -> usize {
67    let Ok(mut hot) = pool.hot_statements.write() else {
68        return 0;
69    };
70
71    let mut evicted = 0usize;
72    for (hash, _, _) in missing {
73        if hot.remove(hash).is_some() {
74            evicted += 1;
75        }
76    }
77    evicted
78}
79
80impl PgPoolInner {
81    pub(super) async fn return_connection(&self, conn: PgConnection, created_at: Instant) {
82        decrement_active_count_saturating(&self.active_count);
83
84        if conn.is_io_desynced() {
85            tracing::warn!(
86                host = %self.config.host,
87                port = self.config.port,
88                user = %self.config.user,
89                db = %self.config.database,
90                "pool_return_desynced: dropping connection due to prior I/O/protocol desync"
91            );
92            record_pool_connection_destroy("pool_desynced_drop");
93            self.semaphore.add_permits(1);
94            pool_churn_record_destroy(&self.config, "return_desynced");
95            return;
96        }
97
98        if self.closed.load(Ordering::Relaxed) {
99            record_pool_connection_destroy("pool_closed_drop");
100            self.semaphore.add_permits(1);
101            return;
102        }
103
104        let mut connections = self.connections.lock().await;
105        if connections.len() < self.config.max_connections {
106            connections.push(PooledConn {
107                conn,
108                created_at,
109                last_used: Instant::now(),
110            });
111        } else {
112            record_pool_connection_destroy("pool_overflow_drop");
113        }
114
115        self.semaphore.add_permits(1);
116    }
117
118    /// Get a healthy connection from the pool, or None if pool is empty.
119    async fn get_healthy_connection(&self) -> Option<PooledConn> {
120        let mut connections = self.connections.lock().await;
121
122        while let Some(pooled) = connections.pop() {
123            if pooled.last_used.elapsed() > self.config.idle_timeout {
124                tracing::debug!(
125                    idle_secs = pooled.last_used.elapsed().as_secs(),
126                    timeout_secs = self.config.idle_timeout.as_secs(),
127                    "pool_checkout_evict: connection exceeded idle timeout"
128                );
129                record_pool_connection_destroy("idle_timeout_evict");
130                continue;
131            }
132
133            if let Some(max_life) = self.config.max_lifetime
134                && pooled.created_at.elapsed() > max_life
135            {
136                tracing::debug!(
137                    age_secs = pooled.created_at.elapsed().as_secs(),
138                    max_lifetime_secs = max_life.as_secs(),
139                    "pool_checkout_evict: connection exceeded max lifetime"
140                );
141                record_pool_connection_destroy("max_lifetime_evict");
142                continue;
143            }
144
145            return Some(pooled);
146        }
147
148        None
149    }
150}
151
152/// # Example
153/// ```ignore
154/// let config = PoolConfig::new("localhost", 5432, "user", "db")
155///     .password("secret")
156///     .max_connections(20);
157/// let pool = PgPool::connect(config).await?;
158/// // Get a connection from the pool
159/// let mut conn = pool.acquire_raw().await?;
160/// conn.simple_query("SELECT 1").await?;
161/// ```
162#[derive(Clone)]
163pub struct PgPool {
164    pub(super) inner: Arc<PgPoolInner>,
165}
166
167impl PgPool {
168    /// Create a pool from `qail.toml` (loads and parses automatically).
169    ///
170    /// # Example
171    /// ```ignore
172    /// let pool = PgPool::from_config().await?;
173    /// ```
174    pub async fn from_config() -> PgResult<Self> {
175        let qail = qail_core::config::QailConfig::load()
176            .map_err(|e| PgError::Connection(format!("Config error: {}", e)))?;
177        let config = PoolConfig::from_qail_config(&qail)?;
178        Self::connect(config).await
179    }
180
181    /// Create a new connection pool.
182    pub async fn connect(config: PoolConfig) -> PgResult<Self> {
183        validate_pool_config(&config)?;
184
185        // Semaphore starts with max_connections permits
186        let semaphore = Semaphore::new(config.max_connections);
187
188        let mut initial_connections = Vec::new();
189        for _ in 0..config.min_connections {
190            let conn = Self::create_connection(&config).await?;
191            initial_connections.push(PooledConn {
192                conn,
193                created_at: Instant::now(),
194                last_used: Instant::now(),
195            });
196        }
197
198        let initial_count = initial_connections.len();
199
200        let inner = Arc::new(PgPoolInner {
201            config,
202            connections: Mutex::new(initial_connections),
203            semaphore,
204            closed: AtomicBool::new(false),
205            active_count: AtomicUsize::new(0),
206            total_created: AtomicUsize::new(initial_count),
207            leaked_cleanup_inflight: AtomicUsize::new(0),
208            hot_statements: std::sync::RwLock::new(std::collections::HashMap::new()),
209        });
210
211        Ok(Self { inner })
212    }
213
214    /// Acquire a raw connection from the pool (crate-internal only).
215    ///
216    /// # Safety (not `unsafe` in the Rust sense, but security-critical)
217    ///
218    /// This returns a connection with **no RLS context**. All tenant data
219    /// queries on this connection will bypass row-level security.
220    ///
221    /// **Safe usage**: Pair with `fetch_all_with_rls()` for pipelined
222    /// RLS+query execution (single roundtrip). Or use `acquire_with_rls()`
223    /// / `acquire_with_rls_timeout()` for the 2-roundtrip path.
224    ///
225    /// **Unsafe usage**: Running queries directly on a raw connection
226    /// without RLS context. Every call site MUST include a `// SAFETY:`
227    /// comment explaining why raw acquisition is justified.
228    pub async fn acquire_raw(&self) -> PgResult<PooledConnection> {
229        if self.inner.closed.load(Ordering::Relaxed) {
230            return Err(PgError::PoolClosed);
231        }
232
233        if let Some(remaining) = pool_churn_remaining_open(&self.inner.config) {
234            metrics::counter!("qail_pg_pool_churn_circuit_reject_total").increment(1);
235            tracing::warn!(
236                host = %self.inner.config.host,
237                port = self.inner.config.port,
238                user = %self.inner.config.user,
239                db = %self.inner.config.database,
240                remaining_ms = remaining.as_millis() as u64,
241                "pool_connection_churn_circuit_open"
242            );
243            return Err(PgError::PoolExhausted {
244                max: self.inner.config.max_connections,
245            });
246        }
247
248        // Wait for available slot with timeout
249        let acquire_timeout = self.inner.config.acquire_timeout;
250        let permit =
251            match tokio::time::timeout(acquire_timeout, self.inner.semaphore.acquire()).await {
252                Ok(permit) => permit.map_err(|_| PgError::PoolClosed)?,
253                Err(_) => {
254                    metrics::counter!("qail_pg_pool_acquire_timeouts_total").increment(1);
255                    return Err(PgError::Timeout(format!(
256                        "pool acquire after {}s ({} max connections)",
257                        acquire_timeout.as_secs(),
258                        self.inner.config.max_connections
259                    )));
260                }
261            };
262
263        if self.inner.closed.load(Ordering::Relaxed) {
264            return Err(PgError::PoolClosed);
265        }
266
267        // Try to get existing healthy connection
268        let (mut conn, mut created_at) =
269            if let Some(pooled) = self.inner.get_healthy_connection().await {
270                (pooled.conn, pooled.created_at)
271            } else {
272                let conn = Self::create_connection(&self.inner.config).await?;
273                self.inner.total_created.fetch_add(1, Ordering::Relaxed);
274                (conn, Instant::now())
275            };
276
277        if self.inner.config.test_on_acquire
278            && let Err(e) = execute_simple_with_timeout(
279                &mut conn,
280                "SELECT 1",
281                self.inner.config.connect_timeout,
282                "pool checkout health check",
283            )
284            .await
285        {
286            tracing::warn!(
287                host = %self.inner.config.host,
288                port = self.inner.config.port,
289                user = %self.inner.config.user,
290                db = %self.inner.config.database,
291                error = %e,
292                "pool_health_check_failed: checkout probe failed, creating replacement connection"
293            );
294            pool_churn_record_destroy(&self.inner.config, "health_check_failed");
295            conn = Self::create_connection(&self.inner.config).await?;
296            self.inner.total_created.fetch_add(1, Ordering::Relaxed);
297            created_at = Instant::now();
298        }
299
300        // Pre-prepare hot statements that this connection doesn't have yet.
301        // Collect data synchronously (guard dropped before async work).
302        let missing: Vec<(u64, String, String)> = {
303            if let Ok(hot) = self.inner.hot_statements.read() {
304                hot.iter()
305                    .filter(|(hash, _)| !conn.stmt_cache.contains(hash))
306                    .map(|(hash, (name, sql))| (*hash, name.clone(), sql.clone()))
307                    .collect()
308            } else {
309                Vec::new()
310            }
311        }; // RwLockReadGuard dropped here — safe across .await
312
313        if !missing.is_empty() {
314            use crate::protocol::PgEncoder;
315            let mut buf = bytes::BytesMut::new();
316            for (_, name, sql) in &missing {
317                let parse_msg = PgEncoder::try_encode_parse(name, sql, &[])?;
318                buf.extend_from_slice(&parse_msg);
319            }
320            PgEncoder::encode_sync_to(&mut buf);
321            let preprepare_timeout = self.inner.config.connect_timeout;
322            let preprepare_result: PgResult<()> = match tokio::time::timeout(
323                preprepare_timeout,
324                async {
325                    conn.send_bytes(&buf).await?;
326                    // Drain responses and fail closed on any parse error.
327                    let mut parse_complete_count = 0usize;
328                    let mut parse_error: Option<PgError> = None;
329                    loop {
330                        let msg = conn.recv().await?;
331                        if handle_hot_preprepare_message(
332                            &msg,
333                            &mut parse_complete_count,
334                            &mut parse_error,
335                        )? {
336                            if let Some(err) = parse_error {
337                                return Err(err);
338                            }
339                            if parse_complete_count != missing.len() {
340                                return Err(PgError::Protocol(format!(
341                                    "hot pre-prepare completed with {} ParseComplete messages (expected {})",
342                                    parse_complete_count,
343                                    missing.len()
344                                )));
345                            }
346                            break;
347                        }
348                    }
349                    Ok::<(), PgError>(())
350                },
351            )
352            .await
353            {
354                Ok(res) => res,
355                Err(_) => Err(PgError::Timeout(format!(
356                    "hot statement pre-prepare timeout after {:?} (pool config connect_timeout)",
357                    preprepare_timeout
358                ))),
359            };
360
361            if let Err(e) = preprepare_result {
362                let evicted_hot_statements =
363                    evict_failed_hot_preprepare_entries(&self.inner, &missing);
364                tracing::warn!(
365                    host = %self.inner.config.host,
366                    port = self.inner.config.port,
367                    user = %self.inner.config.user,
368                    db = %self.inner.config.database,
369                    timeout_ms = preprepare_timeout.as_millis() as u64,
370                    evicted_hot_statements,
371                    error = %e,
372                    "pool_hot_prepare_failed: replacing connection to avoid handing out uncertain protocol state"
373                );
374                pool_churn_record_destroy(&self.inner.config, "hot_prepare_failed");
375                conn = Self::create_connection(&self.inner.config).await?;
376                self.inner.total_created.fetch_add(1, Ordering::Relaxed);
377                created_at = Instant::now();
378            } else {
379                // Register in local cache
380                for (hash, name, sql) in &missing {
381                    conn.stmt_cache.put(*hash, name.clone());
382                    conn.prepared_statements.insert(name.clone(), sql.clone());
383                }
384            }
385        }
386
387        self.inner.active_count.fetch_add(1, Ordering::Relaxed);
388        // Permit is intentionally detached here; returned by `release()` / pool return.
389        permit.forget();
390
391        Ok(PooledConnection {
392            conn: Some(conn),
393            pool: std::sync::Arc::clone(&self.inner),
394            rls_dirty: false,
395            created_at,
396        })
397    }
398
399    /// Acquire a connection with RLS context pre-configured.
400    ///
401    /// Sets PostgreSQL session variables for tenant isolation before
402    /// returning the connection. When the connection is dropped, it
403    /// automatically clears the RLS context before returning to the pool.
404    ///
405    /// # Example
406    /// ```ignore
407    /// use qail_core::rls::RlsContext;
408    ///
409    /// let mut conn = pool.acquire_with_rls(
410    ///     RlsContext::tenant("550e8400-e29b-41d4-a716-446655440000")
411    /// ).await?;
412    /// // All queries through `conn` are now scoped to this tenant
413    /// ```
414    pub async fn acquire_with_rls(
415        &self,
416        ctx: qail_core::rls::RlsContext,
417    ) -> PgResult<PooledConnection> {
418        // SAFETY: RLS context is set immediately below via context_to_sql().
419        let mut conn = self.acquire_raw().await?;
420
421        // Set RLS context on the raw connection
422        let sql = crate::driver::rls::context_to_sql(&ctx);
423        let pg_conn = conn.get_mut()?;
424        if let Err(e) = execute_simple_with_timeout(
425            pg_conn,
426            &sql,
427            self.inner.config.connect_timeout,
428            "pool acquire_with_rls setup",
429        )
430        .await
431        {
432            // Attempt recovery ROLLBACK to salvage the connection rather than
433            // letting Drop destroy it (which wastes a TCP connection).
434            if let Ok(pg_conn) = conn.get_mut() {
435                let _ = pg_conn.execute_simple("ROLLBACK").await;
436            }
437            conn.release().await;
438            return Err(e);
439        }
440
441        // Mark dirty so Drop resets context before pool return
442        conn.rls_dirty = true;
443
444        Ok(conn)
445    }
446
447    /// Scoped connection helper that guarantees `release()` after closure execution.
448    ///
449    /// Prefer this over manual `acquire_with_rls()` in normal request handlers.
450    pub async fn with_rls<T, F>(&self, ctx: qail_core::rls::RlsContext, f: F) -> PgResult<T>
451    where
452        F: for<'a> FnOnce(&'a mut PooledConnection) -> ScopedPoolFuture<'a, T>,
453    {
454        let mut conn = self.acquire_with_rls(ctx).await?;
455        let out = f(&mut conn).await;
456        match out {
457            Ok(value) => {
458                conn.release_checked().await?;
459                Ok(value)
460            }
461            Err(err) => {
462                let _ = conn.rollback_and_release().await;
463                Err(err)
464            }
465        }
466    }
467
468    /// Scoped helper for system-level operations (`RlsContext::empty()`).
469    pub async fn with_system<T, F>(&self, f: F) -> PgResult<T>
470    where
471        F: for<'a> FnOnce(&'a mut PooledConnection) -> ScopedPoolFuture<'a, T>,
472    {
473        self.with_rls(qail_core::rls::RlsContext::empty(), f).await
474    }
475
476    /// Scoped helper for global/platform row access (`tenant_id IS NULL`).
477    pub async fn with_global<T, F>(&self, f: F) -> PgResult<T>
478    where
479        F: for<'a> FnOnce(&'a mut PooledConnection) -> ScopedPoolFuture<'a, T>,
480    {
481        self.with_rls(qail_core::rls::RlsContext::global(), f).await
482    }
483
484    /// Scoped helper for single-tenant access.
485    pub async fn with_tenant<T, F>(&self, tenant_id: &str, f: F) -> PgResult<T>
486    where
487        F: for<'a> FnOnce(&'a mut PooledConnection) -> ScopedPoolFuture<'a, T>,
488    {
489        self.with_rls(qail_core::rls::RlsContext::tenant(tenant_id), f)
490            .await
491    }
492
493    /// Acquire a connection with RLS context AND statement timeout.
494    ///
495    /// Like `acquire_with_rls()`, but also sets `statement_timeout` to prevent
496    /// runaway queries from holding pool connections indefinitely.
497    pub async fn acquire_with_rls_timeout(
498        &self,
499        ctx: qail_core::rls::RlsContext,
500        timeout_ms: u32,
501    ) -> PgResult<PooledConnection> {
502        // SAFETY: RLS context + timeout set immediately below via context_to_sql_with_timeout().
503        let mut conn = self.acquire_raw().await?;
504
505        // Set RLS context + statement_timeout atomically
506        let sql = crate::driver::rls::context_to_sql_with_timeout(&ctx, timeout_ms);
507        let pg_conn = conn.get_mut()?;
508        if let Err(e) = execute_simple_with_timeout(
509            pg_conn,
510            &sql,
511            self.inner.config.connect_timeout,
512            "pool acquire_with_rls_timeout setup",
513        )
514        .await
515        {
516            if let Ok(pg_conn) = conn.get_mut() {
517                let _ = pg_conn.execute_simple("ROLLBACK").await;
518            }
519            conn.release().await;
520            return Err(e);
521        }
522
523        // Mark dirty so Drop resets context + timeout before pool return
524        conn.rls_dirty = true;
525
526        Ok(conn)
527    }
528
529    /// Scoped connection helper that guarantees `release()` after closure execution.
530    pub async fn with_rls_timeout<T, F>(
531        &self,
532        ctx: qail_core::rls::RlsContext,
533        timeout_ms: u32,
534        f: F,
535    ) -> PgResult<T>
536    where
537        F: for<'a> FnOnce(&'a mut PooledConnection) -> ScopedPoolFuture<'a, T>,
538    {
539        let mut conn = self.acquire_with_rls_timeout(ctx, timeout_ms).await?;
540        let out = f(&mut conn).await;
541        match out {
542            Ok(value) => {
543                conn.release_checked().await?;
544                Ok(value)
545            }
546            Err(err) => {
547                let _ = conn.rollback_and_release().await;
548                Err(err)
549            }
550        }
551    }
552
553    /// Acquire a connection with RLS context, statement timeout, AND lock timeout.
554    ///
555    /// Like `acquire_with_rls_timeout()`, but also sets `lock_timeout` to prevent
556    /// queries from blocking indefinitely on row/table locks.
557    /// When `lock_timeout_ms` is 0, the lock_timeout clause is omitted.
558    pub async fn acquire_with_rls_timeouts(
559        &self,
560        ctx: qail_core::rls::RlsContext,
561        statement_timeout_ms: u32,
562        lock_timeout_ms: u32,
563    ) -> PgResult<PooledConnection> {
564        // SAFETY: RLS context + timeouts set immediately below via context_to_sql_with_timeouts().
565        let mut conn = self.acquire_raw().await?;
566
567        let sql = crate::driver::rls::context_to_sql_with_timeouts(
568            &ctx,
569            statement_timeout_ms,
570            lock_timeout_ms,
571        );
572        let pg_conn = conn.get_mut()?;
573        if let Err(e) = execute_simple_with_timeout(
574            pg_conn,
575            &sql,
576            self.inner.config.connect_timeout,
577            "pool acquire_with_rls_timeouts setup",
578        )
579        .await
580        {
581            if let Ok(pg_conn) = conn.get_mut() {
582                let _ = pg_conn.execute_simple("ROLLBACK").await;
583            }
584            conn.release().await;
585            return Err(e);
586        }
587
588        conn.rls_dirty = true;
589
590        Ok(conn)
591    }
592
593    /// Scoped connection helper that guarantees `release()` after closure execution.
594    pub async fn with_rls_timeouts<T, F>(
595        &self,
596        ctx: qail_core::rls::RlsContext,
597        statement_timeout_ms: u32,
598        lock_timeout_ms: u32,
599        f: F,
600    ) -> PgResult<T>
601    where
602        F: for<'a> FnOnce(&'a mut PooledConnection) -> ScopedPoolFuture<'a, T>,
603    {
604        let mut conn = self
605            .acquire_with_rls_timeouts(ctx, statement_timeout_ms, lock_timeout_ms)
606            .await?;
607        let out = f(&mut conn).await;
608        match out {
609            Ok(value) => {
610                conn.release_checked().await?;
611                Ok(value)
612            }
613            Err(err) => {
614                let _ = conn.rollback_and_release().await;
615                Err(err)
616            }
617        }
618    }
619
620    /// Acquire a connection for system-level operations (no tenant context).
621    ///
622    /// Sets RLS session variables to maximally restrictive values:
623    /// - `app.current_tenant_id` = nil UUID
624    /// - `app.is_super_admin = false`
625    ///
626    /// Use this for startup introspection, migrations, and health checks
627    /// that must not operate within any tenant scope.
628    pub async fn acquire_system(&self) -> PgResult<PooledConnection> {
629        let ctx = qail_core::rls::RlsContext::empty();
630        self.acquire_with_rls(ctx).await
631    }
632
633    /// Acquire a connection scoped to global/platform rows.
634    ///
635    /// Shorthand for `acquire_with_rls(RlsContext::global())`.
636    /// Use this for shared reference data (for example: currencies, ports,
637    /// vessel types) stored as `tenant_id IS NULL`.
638    pub async fn acquire_global(&self) -> PgResult<PooledConnection> {
639        self.acquire_with_rls(qail_core::rls::RlsContext::global())
640            .await
641    }
642
643    /// Acquire a connection scoped to a specific tenant.
644    ///
645    /// Shorthand for `acquire_with_rls(RlsContext::tenant(tenant_id))`.
646    /// Use this when you already know the tenant UUID and want a
647    /// tenant-scoped connection in a single call.
648    ///
649    /// # Example
650    /// ```ignore
651    /// let mut conn = pool.acquire_for_tenant("550e8400-...").await?;
652    /// // All queries through `conn` are now scoped to this tenant
653    /// ```
654    pub async fn acquire_for_tenant(&self, tenant_id: &str) -> PgResult<PooledConnection> {
655        self.acquire_with_rls(qail_core::rls::RlsContext::tenant(tenant_id))
656            .await
657    }
658
659    /// Acquire a connection with branch context pre-configured.
660    ///
661    /// Sets PostgreSQL session variable `app.branch_id` for data virtualization.
662    /// When the connection is dropped, it automatically clears the branch context.
663    ///
664    /// # Example
665    /// ```ignore
666    /// use qail_core::branch::BranchContext;
667    ///
668    /// let ctx = BranchContext::branch("feature-auth");
669    /// let mut conn = pool.acquire_with_branch(&ctx).await?;
670    /// // All queries through `conn` are now branch-aware
671    /// ```
672    pub async fn acquire_with_branch(
673        &self,
674        ctx: &qail_core::branch::BranchContext,
675    ) -> PgResult<PooledConnection> {
676        // SAFETY: Branch context is set immediately below via branch_context_sql().
677        let mut conn = self.acquire_raw().await?;
678
679        if let Some(branch_name) = ctx.branch_name() {
680            let sql = crate::driver::branch_sql::branch_context_sql(branch_name);
681            let pg_conn = conn.get_mut()?;
682            if let Err(e) = execute_simple_with_timeout(
683                pg_conn,
684                &sql,
685                self.inner.config.connect_timeout,
686                "pool acquire_with_branch setup",
687            )
688            .await
689            {
690                if let Ok(pg_conn) = conn.get_mut() {
691                    let _ = pg_conn.execute_simple("ROLLBACK").await;
692                }
693                conn.release().await;
694                return Err(e);
695            }
696            conn.rls_dirty = true; // Reuse dirty flag for auto-reset
697        }
698
699        Ok(conn)
700    }
701
702    /// Get the current number of idle connections.
703    pub async fn idle_count(&self) -> usize {
704        self.inner.connections.lock().await.len()
705    }
706
707    /// Get the number of connections currently in use.
708    pub fn active_count(&self) -> usize {
709        self.inner.active_count.load(Ordering::Relaxed)
710    }
711
712    /// Get the maximum number of connections.
713    pub fn max_connections(&self) -> usize {
714        self.inner.config.max_connections
715    }
716
717    /// Plan auto count strategy for a given batch length.
718    pub fn plan_auto_count(&self, batch_len: usize) -> AutoCountPlan {
719        AutoCountPlan::for_pool(
720            batch_len,
721            self.inner.config.max_connections,
722            self.inner.semaphore.available_permits(),
723        )
724    }
725
726    /// Execute commands with runtime auto strategy and return count + plan.
727    pub async fn execute_count_auto_with_plan(
728        &self,
729        cmds: &[qail_core::ast::Qail],
730    ) -> PgResult<(usize, AutoCountPlan)> {
731        let plan = self.plan_auto_count(cmds.len());
732
733        let completed = match plan.path {
734            AutoCountPath::SingleCached => {
735                if cmds.is_empty() {
736                    0
737                } else {
738                    let mut conn = self.acquire_system().await?;
739                    let run_result = conn.fetch_all_cached(&cmds[0]).await;
740                    conn.release().await;
741                    let _ = run_result?;
742                    1
743                }
744            }
745            AutoCountPath::PipelineOneShot | AutoCountPath::PipelineCached => {
746                let mode = if matches!(plan.path, AutoCountPath::PipelineOneShot) {
747                    AstPipelineMode::OneShot
748                } else {
749                    AstPipelineMode::Cached
750                };
751
752                let mut pooled = self.acquire_system().await?;
753                let run_result = {
754                    let conn = pooled.get_mut()?;
755                    conn.pipeline_execute_count_ast_with_mode(cmds, mode).await
756                };
757                pooled.release().await;
758                run_result?
759            }
760            AutoCountPath::PoolParallel => {
761                if cmds.is_empty() {
762                    0
763                } else {
764                    let all_cmds = Arc::new(cmds.to_vec());
765                    let mut tasks: JoinSet<PgResult<usize>> = JoinSet::new();
766
767                    for worker in 0..plan.workers {
768                        let start = worker * plan.chunk_size;
769                        if start >= all_cmds.len() {
770                            break;
771                        }
772                        let end = (start + plan.chunk_size).min(all_cmds.len());
773                        let pool = self.clone();
774                        let all_cmds = Arc::clone(&all_cmds);
775
776                        tasks.spawn(async move {
777                            let mut pooled = pool.acquire_system().await?;
778                            let run_result = {
779                                let conn = pooled.get_mut()?;
780                                conn.pipeline_execute_count_ast_with_mode(
781                                    &all_cmds[start..end],
782                                    AstPipelineMode::Auto,
783                                )
784                                .await
785                            };
786                            pooled.release().await;
787                            run_result
788                        });
789                    }
790
791                    let mut total = 0usize;
792                    while let Some(joined) = tasks.join_next().await {
793                        match joined {
794                            Ok(Ok(count)) => {
795                                total += count;
796                            }
797                            Ok(Err(err)) => return Err(err),
798                            Err(err) => {
799                                return Err(PgError::Connection(format!(
800                                    "auto pool worker join failed: {err}"
801                                )));
802                            }
803                        }
804                    }
805                    total
806                }
807            }
808        };
809
810        Ok((completed, plan))
811    }
812
813    /// Execute commands with runtime auto strategy.
814    #[inline]
815    pub async fn execute_count_auto(&self, cmds: &[qail_core::ast::Qail]) -> PgResult<usize> {
816        let (completed, _plan) = self.execute_count_auto_with_plan(cmds).await?;
817        Ok(completed)
818    }
819
820    /// Get comprehensive pool statistics.
821    pub async fn stats(&self) -> PoolStats {
822        let idle = self.inner.connections.lock().await.len();
823        let active = self.inner.active_count.load(Ordering::Relaxed);
824        let used_slots = self
825            .inner
826            .config
827            .max_connections
828            .saturating_sub(self.inner.semaphore.available_permits());
829        PoolStats {
830            active,
831            idle,
832            pending: used_slots.saturating_sub(active),
833            max_size: self.inner.config.max_connections,
834            total_created: self.inner.total_created.load(Ordering::Relaxed),
835        }
836    }
837
838    /// Check if the pool is closed.
839    pub fn is_closed(&self) -> bool {
840        self.inner.closed.load(Ordering::Relaxed)
841    }
842
843    /// Close the pool gracefully.
844    ///
845    /// Rejects new acquires immediately, then waits up to `acquire_timeout`
846    /// for in-flight connections to be released before dropping idle
847    /// connections. Connections released after closure are destroyed by
848    /// `return_connection` and not returned to the idle queue.
849    pub async fn close(&self) {
850        self.close_graceful(self.inner.config.acquire_timeout).await;
851    }
852
853    /// Close the pool gracefully with an explicit drain timeout.
854    pub async fn close_graceful(&self, drain_timeout: Duration) {
855        self.inner.closed.store(true, Ordering::Relaxed);
856        // Wake blocked acquires immediately so shutdown doesn't wait on acquire_timeout.
857        self.inner.semaphore.close();
858
859        let deadline = Instant::now() + drain_timeout;
860        loop {
861            let active = self.inner.active_count.load(Ordering::Relaxed);
862            if active == 0 {
863                break;
864            }
865            if Instant::now() >= deadline {
866                tracing::warn!(
867                    active_connections = active,
868                    timeout_ms = drain_timeout.as_millis() as u64,
869                    "pool_close_drain_timeout: forcing idle cleanup while active connections remain"
870                );
871                break;
872            }
873            tokio::time::sleep(Duration::from_millis(25)).await;
874        }
875
876        let mut connections = self.inner.connections.lock().await;
877        let dropped_idle = connections.len();
878        connections.clear();
879        tracing::info!(
880            dropped_idle_connections = dropped_idle,
881            active_connections = self.inner.active_count.load(Ordering::Relaxed),
882            "pool_closed"
883        );
884    }
885
886    /// Create a new connection using the pool configuration.
887    async fn create_connection(config: &PoolConfig) -> PgResult<PgConnection> {
888        if !config.auth_settings.has_any_password_method()
889            && config.mtls.is_none()
890            && config.password.is_some()
891        {
892            return Err(PgError::Auth(
893                "Invalid PoolConfig: all password auth methods are disabled".to_string(),
894            ));
895        }
896
897        let options = ConnectOptions {
898            tls_mode: config.tls_mode,
899            gss_enc_mode: config.gss_enc_mode,
900            tls_ca_cert_pem: config.tls_ca_cert_pem.clone(),
901            mtls: config.mtls.clone(),
902            gss_token_provider: config.gss_token_provider.clone(),
903            auth: config.auth_settings,
904            io_uring: config.io_uring,
905            startup_params: Vec::new(),
906        };
907
908        if let Some(remaining) = gss_circuit_remaining_open(config) {
909            metrics::counter!("qail_pg_gss_circuit_open_total").increment(1);
910            tracing::warn!(
911                host = %config.host,
912                port = config.port,
913                user = %config.user,
914                db = %config.database,
915                remaining_ms = remaining.as_millis() as u64,
916                "gss_connect_circuit_open"
917            );
918            return Err(PgError::Connection(format!(
919                "GSS connection circuit is open; retry after {:?}",
920                remaining
921            )));
922        }
923
924        let mut attempt = 0usize;
925        loop {
926            let connect_result = tokio::time::timeout(
927                config.connect_timeout,
928                PgConnection::connect_with_options(
929                    &config.host,
930                    config.port,
931                    &config.user,
932                    &config.database,
933                    config.password.as_deref(),
934                    options.clone(),
935                ),
936            )
937            .await;
938
939            let connect_result = match connect_result {
940                Ok(result) => result,
941                Err(_) => Err(PgError::Timeout(format!(
942                    "connect timeout after {:?} (pool config connect_timeout)",
943                    config.connect_timeout
944                ))),
945            };
946
947            match connect_result {
948                Ok(conn) => {
949                    metrics::counter!("qail_pg_pool_connect_success_total").increment(1);
950                    gss_circuit_record_success(config);
951                    return Ok(conn);
952                }
953                Err(err) if should_retry_gss_connect_error(config, attempt, &err) => {
954                    metrics::counter!("qail_pg_gss_connect_retries_total").increment(1);
955                    gss_circuit_record_failure(config);
956                    let delay = gss_retry_delay(config.gss_retry_base_delay, attempt);
957                    tracing::warn!(
958                        host = %config.host,
959                        port = config.port,
960                        user = %config.user,
961                        db = %config.database,
962                        attempt = attempt + 1,
963                        delay_ms = delay.as_millis() as u64,
964                        error = %err,
965                        "gss_connect_retry"
966                    );
967                    tokio::time::sleep(delay).await;
968                    attempt += 1;
969                }
970                Err(err) => {
971                    metrics::counter!("qail_pg_pool_connect_failures_total").increment(1);
972                    if should_track_gss_circuit_error(config, &err) {
973                        metrics::counter!("qail_pg_gss_connect_failures_total").increment(1);
974                        gss_circuit_record_failure(config);
975                    }
976                    return Err(err);
977                }
978            }
979        }
980    }
981
982    /// Run one maintenance cycle: evict stale idle connections and backfill
983    /// to `min_connections`. Called periodically by `spawn_pool_maintenance`.
984    pub async fn maintain(&self) {
985        if self.inner.closed.load(Ordering::Relaxed) {
986            return;
987        }
988
989        // Phase 1: Evict idle and expired connections from the pool.
990        let evicted = {
991            let mut connections = self.inner.connections.lock().await;
992            let before = connections.len();
993            connections.retain(|pooled| {
994                if pooled.last_used.elapsed() > self.inner.config.idle_timeout {
995                    record_pool_connection_destroy("idle_sweep_evict");
996                    return false;
997                }
998                if let Some(max_life) = self.inner.config.max_lifetime
999                    && pooled.created_at.elapsed() > max_life
1000                {
1001                    record_pool_connection_destroy("lifetime_sweep_evict");
1002                    return false;
1003                }
1004                true
1005            });
1006            before - connections.len()
1007        };
1008
1009        if evicted > 0 {
1010            tracing::debug!(evicted, "pool_maintenance: evicted stale idle connections");
1011        }
1012
1013        // Phase 2: Backfill to min_connections if below threshold.
1014        let min = self.inner.config.min_connections;
1015        if min == 0 {
1016            return;
1017        }
1018
1019        let idle_count = self.inner.connections.lock().await.len();
1020        let checked_out_slots = self
1021            .inner
1022            .config
1023            .max_connections
1024            .saturating_sub(self.inner.semaphore.available_permits());
1025        let deficit = maintenance_backfill_deficit(
1026            self.inner.config.max_connections,
1027            min,
1028            idle_count,
1029            checked_out_slots,
1030        );
1031        if deficit == 0 {
1032            return;
1033        }
1034        let mut created = 0usize;
1035        for _ in 0..deficit {
1036            match Self::create_connection(&self.inner.config).await {
1037                Ok(conn) => {
1038                    self.inner.total_created.fetch_add(1, Ordering::Relaxed);
1039                    let mut connections = self.inner.connections.lock().await;
1040                    if connections.len() < self.inner.config.max_connections {
1041                        connections.push(PooledConn {
1042                            conn,
1043                            created_at: Instant::now(),
1044                            last_used: Instant::now(),
1045                        });
1046                        created += 1;
1047                    } else {
1048                        // Pool filled by concurrent acquires; stop backfill.
1049                        break;
1050                    }
1051                }
1052                Err(e) => {
1053                    tracing::warn!(error = %e, "pool_maintenance: backfill connection failed");
1054                    break; // Transient failure — retry next cycle.
1055                }
1056            }
1057        }
1058
1059        if created > 0 {
1060            tracing::debug!(
1061                created,
1062                min_connections = min,
1063                "pool_maintenance: backfilled idle connections"
1064            );
1065        }
1066    }
1067}
1068
1069/// Spawn a background task that periodically maintains pool health.
1070///
1071/// Runs every `idle_timeout / 2` (min 5s): evicts stale idle connections and
1072/// backfills to `min_connections`. Call once after `PgPool::connect`.
1073pub fn spawn_pool_maintenance(pool: PgPool) {
1074    let interval_secs = std::cmp::max(pool.inner.config.idle_timeout.as_secs() / 2, 5);
1075    tokio::spawn(async move {
1076        let mut interval = tokio::time::interval(Duration::from_secs(interval_secs));
1077        loop {
1078            interval.tick().await;
1079            if pool.is_closed() {
1080                break;
1081            }
1082            pool.maintain().await;
1083        }
1084    });
1085}
1086
1087pub(super) fn maintenance_backfill_deficit(
1088    max_connections: usize,
1089    min_connections: usize,
1090    idle_count: usize,
1091    checked_out_slots: usize,
1092) -> usize {
1093    let target_idle = min_connections.min(max_connections);
1094    if idle_count >= target_idle {
1095        return 0;
1096    }
1097
1098    let needed_idle = target_idle - idle_count;
1099    let available_slots =
1100        max_connections.saturating_sub(idle_count.saturating_add(checked_out_slots));
1101    needed_idle.min(available_slots)
1102}
1103
1104pub(super) fn validate_pool_config(config: &PoolConfig) -> PgResult<()> {
1105    if config.max_connections == 0 {
1106        return Err(PgError::Connection(
1107            "Invalid PoolConfig: max_connections must be >= 1".to_string(),
1108        ));
1109    }
1110    if config.min_connections > config.max_connections {
1111        return Err(PgError::Connection(format!(
1112            "Invalid PoolConfig: min_connections ({}) must be <= max_connections ({})",
1113            config.min_connections, config.max_connections
1114        )));
1115    }
1116    if config.acquire_timeout.is_zero() {
1117        return Err(PgError::Connection(
1118            "Invalid PoolConfig: acquire_timeout must be > 0".to_string(),
1119        ));
1120    }
1121    if config.connect_timeout.is_zero() {
1122        return Err(PgError::Connection(
1123            "Invalid PoolConfig: connect_timeout must be > 0".to_string(),
1124        ));
1125    }
1126    if config.leaked_cleanup_queue == 0 {
1127        return Err(PgError::Connection(
1128            "Invalid PoolConfig: leaked_cleanup_queue must be >= 1".to_string(),
1129        ));
1130    }
1131    Ok(())
1132}
1133
1134pub(super) async fn execute_simple_with_timeout(
1135    conn: &mut PgConnection,
1136    sql: &str,
1137    timeout: Duration,
1138    operation: &str,
1139) -> PgResult<()> {
1140    match tokio::time::timeout(timeout, conn.execute_simple(sql)).await {
1141        Ok(result) => result,
1142        Err(_) => {
1143            conn.mark_io_desynced();
1144            Err(PgError::Timeout(format!(
1145                "{} timeout after {:?} (pool config connect_timeout)",
1146                operation, timeout
1147            )))
1148        }
1149    }
1150}