Skip to main content

prax_postgres/
pool.rs

1//! Connection pool for PostgreSQL.
2
3use std::sync::Arc;
4use std::time::Duration;
5
6use deadpool_postgres::{Manager, ManagerConfig, Pool, RecyclingMethod, Runtime};
7use tokio_postgres::NoTls;
8use tracing::{debug, info, warn};
9
10use crate::config::{PgConfig, SslMode};
11use crate::connection::PgConnection;
12use crate::error::{PgError, PgResult};
13use crate::statement::PreparedStatementCache;
14
15/// A connection pool for PostgreSQL.
16#[derive(Clone)]
17pub struct PgPool {
18    inner: Pool,
19    config: Arc<PgConfig>,
20    statement_cache: Arc<PreparedStatementCache>,
21}
22
23impl PgPool {
24    /// Create a new connection pool from configuration.
25    pub async fn new(config: PgConfig) -> PgResult<Self> {
26        Self::with_pool_config(config, PoolConfig::default()).await
27    }
28
29    /// Create a new connection pool with custom pool configuration.
30    pub async fn with_pool_config(config: PgConfig, pool_config: PoolConfig) -> PgResult<Self> {
31        let pg_config = config.to_pg_config();
32
33        let mgr_config = ManagerConfig {
34            recycling_method: RecyclingMethod::Fast,
35        };
36
37        // Select the TLS connector by ssl_mode. `Disable` is always
38        // plaintext; TLS-requiring modes use rustls when the `tls` feature is
39        // enabled and fail loudly otherwise (never a silent downgrade).
40        // `Prefer` also connects without the feature, since plaintext is
41        // within its contract.
42        #[cfg(feature = "tls")]
43        let mgr = match config.ssl_mode {
44            SslMode::Disable => Manager::from_config(pg_config, NoTls, mgr_config),
45            // `Prefer` keeps tokio-postgres's built-in behavior: TLS when the
46            // server offers it, plaintext only when the server declines.
47            //
48            // `ssl_root_cert` replaces the webpki roots when set; the error is
49            // raised here rather than at first connect so a bad path or an
50            // unreadable bundle fails pool construction with a message naming
51            // the file, instead of surfacing later as a handshake failure.
52            _ => Manager::from_config(
53                pg_config,
54                crate::tls::make_tls_connector_with_root_cert(config.ssl_root_cert.as_deref())?,
55                mgr_config,
56            ),
57        };
58        #[cfg(not(feature = "tls"))]
59        let mgr = match config.ssl_mode {
60            SslMode::Disable => Manager::from_config(pg_config, NoTls, mgr_config),
61            // Plaintext is within `Prefer`'s contract, so a no-tls build
62            // still connects — nothing is downgraded below what the mode
63            // promises. (Against a server that *offers* TLS the driver
64            // still attempts the handshake and fails with the connector's
65            // no-TLS error; use `Disable` for such servers.)
66            SslMode::Prefer => Manager::from_config(pg_config, NoTls, mgr_config),
67            other => {
68                return Err(PgError::config(format!(
69                    "ssl_mode {:?} requires TLS support; rebuild prax-postgres \
70                     with the `tls` feature (enabled by default)",
71                    other
72                )));
73            }
74        };
75
76        // Build pool - set runtime to tokio for timeout support
77        let mut builder = Pool::builder(mgr).max_size(pool_config.max_connections);
78
79        // Only set timeouts if they are configured
80        if let Some(timeout) = pool_config.connection_timeout {
81            builder = builder
82                .wait_timeout(Some(timeout))
83                .create_timeout(Some(timeout));
84        }
85        if let Some(timeout) = pool_config.idle_timeout {
86            builder = builder.recycle_timeout(Some(timeout));
87        }
88
89        // Set runtime for timeout support
90        builder = builder.runtime(Runtime::Tokio1);
91
92        let pool = builder
93            .build()
94            .map_err(|e| PgError::config(format!("failed to create pool: {}", e)))?;
95
96        // deadpool has no native min-connections knob, so pre-establish
97        // `min_connections` on a background task. Acquired connections are
98        // released back to the pool, which retains them (deadpool never
99        // reaps idle connections). Best-effort: pool creation neither
100        // blocks on nor fails because of this warmup.
101        let min_connections = pool_config.min_connections.min(pool_config.max_connections);
102        if min_connections > 0 {
103            let warm_pool = pool.clone();
104            tokio::spawn(async move {
105                let mut held = Vec::with_capacity(min_connections);
106                for _ in 0..min_connections {
107                    match warm_pool.get().await {
108                        Ok(conn) => {
109                            debug!(
110                                established = held.len() + 1,
111                                min_connections = min_connections,
112                                "min_connections warmup established connection"
113                            );
114                            held.push(conn);
115                        }
116                        Err(e) => {
117                            // Operator-visible: the pool is starting below
118                            // its configured minimum.
119                            warn!(
120                                error = %e,
121                                min_connections = min_connections,
122                                established = held.len(),
123                                "min_connections warmup could not acquire connection; \
124                                 pool starts below its configured minimum"
125                            );
126                            break;
127                        }
128                    }
129                }
130                drop(held);
131            });
132        }
133
134        info!(
135            host = %config.host,
136            port = %config.port,
137            database = %config.database,
138            max_connections = %pool_config.max_connections,
139            "PostgreSQL connection pool created"
140        );
141
142        Ok(Self {
143            inner: pool,
144            config: Arc::new(config),
145            statement_cache: Arc::new(PreparedStatementCache::new(
146                pool_config.statement_cache_size,
147            )),
148        })
149    }
150
151    /// Get a connection from the pool.
152    pub async fn get(&self) -> PgResult<PgConnection> {
153        debug!("Acquiring connection from pool");
154        let client = self.inner.get().await?;
155        Ok(PgConnection::new(client, self.statement_cache.clone()))
156    }
157
158    /// Borrow the underlying `deadpool_postgres::Pool`.
159    ///
160    /// Reserved for intra-crate paths that need a raw `Object` (e.g.
161    /// [`crate::engine::PgEngine::transaction`], which pins a single
162    /// connection for the lifetime of an in-flight transaction). The
163    /// standard path is [`PgPool::get`], which returns a
164    /// cache-wrapped [`PgConnection`].
165    pub(crate) fn inner(&self) -> &Pool {
166        &self.inner
167    }
168
169    /// Get the current pool status.
170    pub fn status(&self) -> PoolStatus {
171        let status = self.inner.status();
172        PoolStatus {
173            available: status.available,
174            size: status.size,
175            max_size: status.max_size,
176            waiting: status.waiting,
177        }
178    }
179
180    /// Get the pool configuration.
181    pub fn config(&self) -> &PgConfig {
182        &self.config
183    }
184
185    /// Check if the pool is healthy by attempting to get a connection.
186    pub async fn is_healthy(&self) -> bool {
187        match self.inner.get().await {
188            Ok(client) => {
189                // Try a simple query to verify the connection is actually working
190                client.query_one("SELECT 1", &[]).await.is_ok()
191            }
192            Err(_) => false,
193        }
194    }
195
196    /// Close the pool and all connections.
197    pub fn close(&self) {
198        self.inner.close();
199        info!("PostgreSQL connection pool closed");
200    }
201
202    /// Create a builder for configuring the pool.
203    pub fn builder() -> PgPoolBuilder {
204        PgPoolBuilder::new()
205    }
206
207    /// Warm up the connection pool by pre-establishing connections.
208    ///
209    /// This eliminates the latency of establishing connections on the first queries.
210    /// The `count` parameter specifies how many connections to pre-establish.
211    ///
212    /// # Example
213    ///
214    /// ```rust,ignore
215    /// let pool = PgPool::builder()
216    ///     .url("postgresql://localhost/db")
217    ///     .max_connections(10)
218    ///     .build()
219    ///     .await?;
220    ///
221    /// // Pre-establish 5 connections
222    /// pool.warmup(5).await?;
223    /// ```
224    pub async fn warmup(&self, count: usize) -> PgResult<()> {
225        info!(count = count, "Warming up connection pool");
226
227        let count = count.min(self.inner.status().max_size);
228        let mut connections = Vec::with_capacity(count);
229
230        // Acquire connections to force establishment
231        for i in 0..count {
232            match self.inner.get().await {
233                Ok(conn) => {
234                    // Validate the connection with a simple query
235                    if let Err(e) = conn.query_one("SELECT 1", &[]).await {
236                        debug!(error = %e, "Warmup connection {} failed validation", i);
237                    } else {
238                        debug!("Warmup connection {} established", i);
239                        connections.push(conn);
240                    }
241                }
242                Err(e) => {
243                    debug!(error = %e, "Failed to establish warmup connection {}", i);
244                }
245            }
246        }
247
248        // Connections are returned to pool when dropped
249        let established = connections.len();
250        drop(connections);
251
252        info!(
253            established = established,
254            requested = count,
255            "Connection pool warmup complete"
256        );
257
258        Ok(())
259    }
260
261    /// Warm up with common prepared statements.
262    ///
263    /// This pre-prepares common SQL statements on warmed connections,
264    /// eliminating the prepare latency on first use.
265    pub async fn warmup_with_statements(&self, count: usize, statements: &[&str]) -> PgResult<()> {
266        info!(
267            count = count,
268            statements = statements.len(),
269            "Warming up connection pool with prepared statements"
270        );
271
272        let count = count.min(self.inner.status().max_size);
273        let mut connections = Vec::with_capacity(count);
274
275        for i in 0..count {
276            match self.inner.get().await {
277                Ok(conn) => {
278                    // Pre-prepare all statements
279                    for sql in statements {
280                        if let Err(e) = conn.prepare_cached(sql).await {
281                            debug!(error = %e, sql = %sql, "Failed to prepare statement");
282                        }
283                    }
284                    debug!(
285                        connection = i,
286                        statements = statements.len(),
287                        "Prepared statements on connection"
288                    );
289                    connections.push(conn);
290                }
291                Err(e) => {
292                    debug!(error = %e, "Failed to establish warmup connection {}", i);
293                }
294            }
295        }
296
297        let established = connections.len();
298        drop(connections);
299
300        info!(
301            established = established,
302            "Connection pool warmup with statements complete"
303        );
304
305        Ok(())
306    }
307}
308
309/// Pool status information.
310#[derive(Debug, Clone)]
311pub struct PoolStatus {
312    /// Number of available (idle) connections.
313    pub available: usize,
314    /// Current total size of the pool.
315    pub size: usize,
316    /// Maximum size of the pool.
317    pub max_size: usize,
318    /// Number of tasks waiting for a connection.
319    pub waiting: usize,
320}
321
322/// Configuration for the connection pool.
323#[derive(Debug, Clone)]
324pub struct PoolConfig {
325    /// Maximum number of connections in the pool.
326    pub max_connections: usize,
327    /// Minimum number of connections to pre-establish and keep alive.
328    ///
329    /// deadpool exposes no minimum-size knob, so these are established by a
330    /// best-effort background warmup when the pool is created; the released
331    /// connections are then retained by the pool.
332    pub min_connections: usize,
333    /// Maximum time to wait for a connection.
334    pub connection_timeout: Option<Duration>,
335    /// Timeout for recycling a connection when it is returned to the pool.
336    ///
337    /// Mapped to deadpool's `recycle_timeout`, which bounds the recycle check
338    /// performed on connection return — it is **not** an idle-reaping timeout.
339    /// deadpool-postgres 0.14 exposes no true idle timeout, so connections are
340    /// not closed after a fixed idle period.
341    pub idle_timeout: Option<Duration>,
342    /// Maximum lifetime of a connection.
343    ///
344    /// **Not yet applied**: deadpool-postgres 0.14 exposes no connection
345    /// lifetime knob, so this value is stored but currently unused.
346    pub max_lifetime: Option<Duration>,
347    /// Number of SQL strings tracked for prepared-statement cache metrics.
348    ///
349    /// [`PreparedStatementCache`] records only which SQL strings have been
350    /// seen (hit/miss tracing); actual statement caching is per-connection
351    /// inside tokio-postgres and is not bounded by this value.
352    pub statement_cache_size: usize,
353}
354
355impl Default for PoolConfig {
356    fn default() -> Self {
357        Self {
358            max_connections: 10,
359            min_connections: 1,
360            connection_timeout: Some(Duration::from_secs(30)),
361            idle_timeout: Some(Duration::from_secs(600)), // 10 minutes
362            max_lifetime: Some(Duration::from_secs(1800)), // 30 minutes
363            statement_cache_size: 100,
364        }
365    }
366}
367
368/// Builder for creating a connection pool.
369#[derive(Debug, Default)]
370pub struct PgPoolBuilder {
371    config: Option<PgConfig>,
372    url: Option<String>,
373    pool_config: PoolConfig,
374}
375
376impl PgPoolBuilder {
377    /// Create a new pool builder.
378    pub fn new() -> Self {
379        Self {
380            config: None,
381            url: None,
382            pool_config: PoolConfig::default(),
383        }
384    }
385
386    /// Set the database URL.
387    pub fn url(mut self, url: impl Into<String>) -> Self {
388        self.url = Some(url.into());
389        self
390    }
391
392    /// Set the configuration.
393    pub fn config(mut self, config: PgConfig) -> Self {
394        self.config = Some(config);
395        self
396    }
397
398    /// Set the maximum number of connections.
399    pub fn max_connections(mut self, n: usize) -> Self {
400        self.pool_config.max_connections = n;
401        self
402    }
403
404    /// Set the minimum number of connections.
405    pub fn min_connections(mut self, n: usize) -> Self {
406        self.pool_config.min_connections = n;
407        self
408    }
409
410    /// Set the connection timeout.
411    pub fn connection_timeout(mut self, timeout: Duration) -> Self {
412        self.pool_config.connection_timeout = Some(timeout);
413        self
414    }
415
416    /// Set the idle timeout.
417    pub fn idle_timeout(mut self, timeout: Duration) -> Self {
418        self.pool_config.idle_timeout = Some(timeout);
419        self
420    }
421
422    /// Set the maximum connection lifetime.
423    pub fn max_lifetime(mut self, lifetime: Duration) -> Self {
424        self.pool_config.max_lifetime = Some(lifetime);
425        self
426    }
427
428    /// Set the prepared statement cache size.
429    pub fn statement_cache_size(mut self, size: usize) -> Self {
430        self.pool_config.statement_cache_size = size;
431        self
432    }
433
434    /// Build the connection pool.
435    pub async fn build(self) -> PgResult<PgPool> {
436        let config = if let Some(config) = self.config {
437            config
438        } else if let Some(url) = self.url {
439            PgConfig::from_url(url)?
440        } else {
441            return Err(PgError::config("no database URL or config provided"));
442        };
443
444        PgPool::with_pool_config(config, self.pool_config).await
445    }
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451
452    #[test]
453    fn test_pool_config_default() {
454        let config = PoolConfig::default();
455        assert_eq!(config.max_connections, 10);
456        assert_eq!(config.min_connections, 1);
457        assert_eq!(config.statement_cache_size, 100);
458    }
459
460    #[test]
461    fn test_pool_builder() {
462        let builder = PgPoolBuilder::new()
463            .url("postgresql://localhost/test")
464            .max_connections(20)
465            .statement_cache_size(200);
466
467        assert!(builder.url.is_some());
468        assert_eq!(builder.pool_config.max_connections, 20);
469        assert_eq!(builder.pool_config.statement_cache_size, 200);
470    }
471}