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