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