Skip to main content

qail_pg/driver/pool/
connection.rs

1//! Pooled connection wrapper: struct, accessors, RLS cleanup, transaction control,
2//! COPY export, pipeline, LISTEN/NOTIFY delegation, and Drop.
3
4use super::churn::{decrement_active_count_saturating, pool_churn_record_destroy};
5use super::lifecycle::{PgPoolInner, execute_simple_with_timeout};
6use crate::driver::{PgConnection, PgError, PgResult};
7use std::sync::Arc;
8use std::sync::atomic::Ordering;
9use std::time::Instant;
10
11/// A pooled connection with creation timestamp for idle tracking.
12pub(super) struct PooledConn {
13    pub(super) conn: PgConnection,
14    pub(super) created_at: Instant,
15    pub(super) last_used: Instant,
16}
17
18/// A pooled connection handle.
19///
20/// Use [`PooledConnection::release`] for deterministic reset+return behavior.
21/// If dropped without `release()`, the pool performs best-effort bounded async
22/// cleanup; on any uncertainty it destroys the connection (fail-closed).
23pub struct PooledConnection {
24    pub(super) conn: Option<PgConnection>,
25    pub(super) pool: Arc<PgPoolInner>,
26    pub(super) rls_dirty: bool,
27    pub(super) created_at: Instant,
28}
29
30impl PooledConnection {
31    /// Get a reference to the underlying connection, returning an error
32    /// if the connection has already been released.
33    pub(super) fn conn_ref(&self) -> PgResult<&PgConnection> {
34        self.conn
35            .as_ref()
36            .ok_or_else(|| PgError::Connection("Connection already released back to pool".into()))
37    }
38
39    /// Get a mutable reference to the underlying connection, returning an error
40    /// if the connection has already been released.
41    pub(super) fn conn_mut(&mut self) -> PgResult<&mut PgConnection> {
42        self.conn
43            .as_mut()
44            .ok_or_else(|| PgError::Connection("Connection already released back to pool".into()))
45    }
46
47    /// Get a shared reference to the underlying connection.
48    ///
49    /// Returns an error if the connection has already been released.
50    pub fn get(&self) -> PgResult<&PgConnection> {
51        self.conn_ref()
52    }
53
54    /// Get a mutable reference to the underlying connection.
55    ///
56    /// Returns an error if the connection has already been released.
57    pub fn get_mut(&mut self) -> PgResult<&mut PgConnection> {
58        self.conn_mut()
59    }
60
61    /// Get a token to cancel the currently running query.
62    pub fn cancel_token(&self) -> PgResult<crate::driver::CancelToken> {
63        let conn = self.conn_ref()?;
64        let (process_id, secret_key_bytes) = conn.get_cancel_key_bytes();
65        Ok(crate::driver::CancelToken {
66            host: self.pool.config.host.clone(),
67            port: self.pool.config.port,
68            process_id,
69            secret_key_bytes: secret_key_bytes.to_vec(),
70        })
71    }
72
73    fn reject_outer_transaction_control_in_rls(&self, operation: &str) -> PgResult<()> {
74        if self.rls_dirty {
75            return Err(PgError::Connection(format!(
76                "{operation} is not allowed on an RLS-bound pooled connection; \
77                 use savepoint(), rollback_to(), and release_savepoint() for nested work, \
78                 then release() to close the pool-managed RLS transaction"
79            )));
80        }
81        Ok(())
82    }
83
84    async fn finish_with_reset(
85        mut self,
86        reset_sql: &'static str,
87        operation: &'static str,
88        failure_reason: &'static str,
89    ) -> PgResult<()> {
90        let Some(mut conn) = self.conn.take() else {
91            return Ok(());
92        };
93
94        if conn.is_io_desynced() {
95            tracing::warn!(
96                host = %self.pool.config.host,
97                port = self.pool.config.port,
98                user = %self.pool.config.user,
99                db = %self.pool.config.database,
100                "pool_release_desynced: dropping connection due to prior I/O/protocol desync"
101            );
102            decrement_active_count_saturating(&self.pool.active_count);
103            self.pool.semaphore.add_permits(1);
104            pool_churn_record_destroy(&self.pool.config, "release_desynced");
105            return Err(PgError::Connection(
106                "connection is protocol-desynced; dropped instead of returning to pool".into(),
107            ));
108        }
109
110        let reset_timeout = self.pool.config.connect_timeout;
111        if let Err(e) =
112            execute_simple_with_timeout(&mut conn, reset_sql, reset_timeout, operation).await
113        {
114            tracing::error!(
115                host = %self.pool.config.host,
116                port = self.pool.config.port,
117                user = %self.pool.config.user,
118                db = %self.pool.config.database,
119                timeout_ms = reset_timeout.as_millis() as u64,
120                error = %e,
121                "pool_release_failed: reset failed; dropping connection to prevent state leak"
122            );
123            decrement_active_count_saturating(&self.pool.active_count);
124            self.pool.semaphore.add_permits(1);
125            pool_churn_record_destroy(&self.pool.config, failure_reason);
126            return Err(e);
127        }
128
129        self.pool.return_connection(conn, self.created_at).await;
130        Ok(())
131    }
132
133    /// Deterministic connection cleanup and pool return.
134    ///
135    /// This is the **correct** way to return a connection to the pool.
136    /// ROLLBACKs raw pooled connections and COMMITs RLS-scoped connections
137    /// where transaction-local RLS session variables must be reset. Prepared
138    /// statement caches remain intact.
139    ///
140    /// If cleanup fails, the connection is destroyed (not returned to pool).
141    ///
142    /// # Usage
143    /// ```ignore
144    /// let mut conn = pool.acquire_with_rls(ctx).await?;
145    /// let result = conn.fetch_all_cached(&cmd).await;
146    /// conn.release().await; // COMMIT + return to pool
147    /// result
148    /// ```
149    pub async fn release(self) {
150        let _ = self.release_checked().await;
151    }
152
153    /// Reset and return the connection to the pool.
154    ///
155    /// This is the checked form of [`Self::release`]. It is useful for callers
156    /// that need to report reset failures rather than only logging them.
157    pub async fn release_checked(self) -> PgResult<()> {
158        let (sql, context) = if self.rls_dirty {
159            // COMMIT the transaction opened by acquire_with_rls.
160            // Transaction-local set_config values auto-reset on COMMIT;
161            // the appended scrub clears session-scoped state (SET/SET ROLE,
162            // listens, advisory locks, temp tables) that COMMIT leaves behind.
163            (
164                crate::driver::rls::pool_release_commit_sql(),
165                "pool release reset/COMMIT",
166            )
167        } else {
168            (
169                crate::driver::rls::pool_release_rollback_sql(),
170                "pool release reset/ROLLBACK",
171            )
172        };
173        self.finish_with_reset(sql, context, "release_reset_failed")
174            .await
175    }
176
177    /// Roll back the pool-managed transaction and return the connection to the pool.
178    ///
179    /// Use this for abandoned RLS-bound work, expired transaction sessions, or
180    /// request-level savepoint failures that must fail closed.
181    pub async fn rollback_and_release(self) -> PgResult<()> {
182        self.finish_with_reset(
183            crate::driver::rls::pool_release_rollback_sql(),
184            "pool release rollback/ROLLBACK",
185            "release_rollback_failed",
186        )
187        .await
188    }
189
190    // ==================== TRANSACTION CONTROL ====================
191
192    /// Begin an explicit transaction on this pooled connection.
193    ///
194    /// Use this only on raw pooled connections. Connections acquired with
195    /// `acquire_with_rls()` already run inside the pool-managed RLS
196    /// transaction; use savepoints there instead.
197    ///
198    /// # Example
199    /// ```ignore
200    /// let mut conn = pool.acquire_raw().await?;
201    /// conn.begin().await?;
202    /// conn.execute(&insert1).await?;
203    /// conn.execute(&insert2).await?;
204    /// conn.commit().await?;
205    /// conn.release().await;
206    /// ```
207    pub async fn begin(&mut self) -> PgResult<()> {
208        self.reject_outer_transaction_control_in_rls("BEGIN")?;
209        self.conn_mut()?.begin_transaction().await
210    }
211
212    /// Commit the current transaction.
213    /// Makes all changes since `begin()` permanent.
214    pub async fn commit(&mut self) -> PgResult<()> {
215        self.reject_outer_transaction_control_in_rls("COMMIT")?;
216        self.conn_mut()?.commit().await
217    }
218
219    /// Rollback the current transaction.
220    /// Discards all changes since `begin()`.
221    pub async fn rollback(&mut self) -> PgResult<()> {
222        self.reject_outer_transaction_control_in_rls("ROLLBACK")?;
223        self.conn_mut()?.rollback().await
224    }
225
226    /// Create a named savepoint within the current transaction.
227    /// Use `rollback_to()` to return to this savepoint.
228    pub async fn savepoint(&mut self, name: &str) -> PgResult<()> {
229        self.conn_mut()?.savepoint(name).await
230    }
231
232    /// Rollback to a previously created savepoint.
233    /// Discards changes since the savepoint, but keeps the transaction open.
234    pub async fn rollback_to(&mut self, name: &str) -> PgResult<()> {
235        self.conn_mut()?.rollback_to(name).await
236    }
237
238    /// Release a savepoint (free resources).
239    /// After release, the savepoint cannot be rolled back to.
240    pub async fn release_savepoint(&mut self, name: &str) -> PgResult<()> {
241        self.conn_mut()?.release_savepoint(name).await
242    }
243
244    /// Execute multiple QAIL commands in a single PG pipeline round-trip.
245    ///
246    /// Sends all queries as Parse+Bind+Execute in one write, receives all
247    /// responses in one read. Returns raw column data per query per row.
248    ///
249    /// This is the fastest path for batch operations — amortizes TCP
250    /// overhead across N queries into a single syscall pair.
251    pub async fn pipeline_execute_rows_ast(
252        &mut self,
253        cmds: &[qail_core::ast::Qail],
254    ) -> PgResult<Vec<Vec<Vec<Option<Vec<u8>>>>>> {
255        let conn = self.conn_mut()?;
256        conn.pipeline_execute_rows_ast(cmds).await
257    }
258
259    /// Run `EXPLAIN (FORMAT JSON)` on a Qail command and return cost estimates.
260    ///
261    /// Uses `simple_query` under the hood — no additional round-trips beyond
262    /// the single EXPLAIN statement. Returns `None` if parsing fails or
263    /// the EXPLAIN output is unexpected.
264    pub async fn explain_estimate(
265        &mut self,
266        cmd: &qail_core::ast::Qail,
267    ) -> PgResult<Option<crate::driver::explain::ExplainEstimate>> {
268        let (sql, params) = crate::protocol::AstEncoder::encode_cmd_sql(cmd)
269            .map_err(|e| crate::driver::PgError::Encode(e.to_string()))?;
270        let explain_sql = format!("EXPLAIN (FORMAT JSON) {}", sql);
271
272        let rows = self.conn_mut()?.query(&explain_sql, &params).await?;
273
274        // PostgreSQL returns the JSON plan as a single text column across one or more rows
275        let mut json_output = String::new();
276        for row in &rows {
277            if let Some(Some(val)) = row.first()
278                && let Ok(text) = std::str::from_utf8(val)
279            {
280                json_output.push_str(text);
281            }
282        }
283
284        Ok(crate::driver::explain::parse_explain_json(&json_output))
285    }
286
287    // ─── LISTEN / NOTIFY delegation ─────────────────────────────────
288
289    /// Subscribe to a PostgreSQL notification channel.
290    ///
291    /// Delegates to [`PgConnection::listen`].
292    pub async fn listen(&mut self, channel: &str) -> PgResult<()> {
293        self.conn_mut()?.listen(channel).await
294    }
295
296    /// Unsubscribe from a PostgreSQL notification channel.
297    ///
298    /// Delegates to [`PgConnection::unlisten`].
299    pub async fn unlisten(&mut self, channel: &str) -> PgResult<()> {
300        self.conn_mut()?.unlisten(channel).await
301    }
302
303    /// Unsubscribe from all notification channels.
304    ///
305    /// Delegates to [`PgConnection::unlisten_all`].
306    pub async fn unlisten_all(&mut self) -> PgResult<()> {
307        self.conn_mut()?.unlisten_all().await
308    }
309
310    /// Wait for the next notification, blocking until one arrives.
311    ///
312    /// Delegates to [`PgConnection::recv_notification`].
313    /// Useful for dedicated LISTEN connections in background tasks.
314    pub async fn recv_notification(
315        &mut self,
316    ) -> PgResult<crate::driver::notification::Notification> {
317        self.conn_mut()?.recv_notification().await
318    }
319}
320
321impl Drop for PooledConnection {
322    fn drop(&mut self) {
323        if let Some(mut conn) = self.conn.take() {
324            // Safety net: connection was NOT released via `release()`.
325            // Best-effort strategy:
326            // 1) If connection is already desynced, destroy immediately.
327            // 2) Else, queue bounded async rollback+return cleanup.
328            // 3) If cleanup queue/runtime unavailable, destroy.
329            //
330            // This preserves security (fail-closed) while reducing churn under
331            // accidental early-returns in handler code.
332            tracing::warn!(
333                host = %self.pool.config.host,
334                port = self.pool.config.port,
335                user = %self.pool.config.user,
336                db = %self.pool.config.database,
337                rls_dirty = self.rls_dirty,
338                "pool_connection_leaked: dropped without release()"
339            );
340            if conn.is_io_desynced() {
341                tracing::warn!(
342                    host = %self.pool.config.host,
343                    port = self.pool.config.port,
344                    user = %self.pool.config.user,
345                    db = %self.pool.config.database,
346                    "pool_connection_leaked_desynced: destroying immediately"
347                );
348                decrement_active_count_saturating(&self.pool.active_count);
349                self.pool.semaphore.add_permits(1);
350                pool_churn_record_destroy(&self.pool.config, "dropped_without_release_desynced");
351                return;
352            }
353
354            let mut inflight = self.pool.leaked_cleanup_inflight.load(Ordering::Relaxed);
355            let max_inflight = self.pool.config.leaked_cleanup_queue;
356            loop {
357                if inflight >= max_inflight {
358                    tracing::warn!(
359                        host = %self.pool.config.host,
360                        port = self.pool.config.port,
361                        user = %self.pool.config.user,
362                        db = %self.pool.config.database,
363                        max_inflight,
364                        "pool_connection_leaked_cleanup_queue_full: destroying connection"
365                    );
366                    decrement_active_count_saturating(&self.pool.active_count);
367                    self.pool.semaphore.add_permits(1);
368                    pool_churn_record_destroy(
369                        &self.pool.config,
370                        "dropped_without_release_cleanup_queue_full",
371                    );
372                    return;
373                }
374
375                match self.pool.leaked_cleanup_inflight.compare_exchange_weak(
376                    inflight,
377                    inflight + 1,
378                    Ordering::AcqRel,
379                    Ordering::Relaxed,
380                ) {
381                    Ok(_) => break,
382                    Err(actual) => inflight = actual,
383                }
384            }
385
386            let pool = std::sync::Arc::clone(&self.pool);
387            let created_at = self.created_at;
388            let reset_timeout = pool.config.connect_timeout;
389            match tokio::runtime::Handle::try_current() {
390                Ok(handle) => {
391                    handle.spawn(async move {
392                        let cleanup_ok = execute_simple_with_timeout(
393                            &mut conn,
394                            crate::driver::rls::pool_release_rollback_sql(),
395                            reset_timeout,
396                            "pool leaked cleanup ROLLBACK",
397                        )
398                        .await
399                        .is_ok();
400
401                        if cleanup_ok && !conn.is_io_desynced() {
402                            pool.return_connection(conn, created_at).await;
403                        } else {
404                            tracing::warn!(
405                                host = %pool.config.host,
406                                port = pool.config.port,
407                                user = %pool.config.user,
408                                db = %pool.config.database,
409                                timeout_ms = reset_timeout.as_millis() as u64,
410                                "pool_connection_leaked_cleanup_failed: destroying connection"
411                            );
412                            decrement_active_count_saturating(&pool.active_count);
413                            pool.semaphore.add_permits(1);
414                            pool_churn_record_destroy(
415                                &pool.config,
416                                "dropped_without_release_cleanup_failed",
417                            );
418                        }
419
420                        pool.leaked_cleanup_inflight.fetch_sub(1, Ordering::AcqRel);
421                    });
422                }
423                Err(_) => {
424                    pool.leaked_cleanup_inflight.fetch_sub(1, Ordering::AcqRel);
425                    tracing::warn!(
426                        host = %pool.config.host,
427                        port = pool.config.port,
428                        user = %pool.config.user,
429                        db = %pool.config.database,
430                        "pool_connection_leaked_no_runtime: destroying connection"
431                    );
432                    decrement_active_count_saturating(&pool.active_count);
433                    pool.semaphore.add_permits(1);
434                    pool_churn_record_destroy(&pool.config, "dropped_without_release_no_runtime");
435                }
436            }
437        }
438    }
439}