Skip to main content

rainier_orm/
pool.rs

1//! Connection-pool configuration — and an honest account of where pooling
2//! does and doesn't apply.
3//!
4//! Pooling lives **inside** an executor, not in the [`Executor`](crate::Executor)
5//! trait, because the backends differ fundamentally:
6//!
7//! - **`SeaOrmExecutor` (native MySQL/Postgres/SQLite)** — a
8//!   `sea_orm::DatabaseConnection` *is* an `sqlx` pool. Cloning the executor
9//!   shares that pool; `SeaOrmExecutor::connect` (in `rainier-drivers`)
10//!   applies a [`PoolConfig`]. This is a long-lived-process model: a background
11//!   task keeps idle connections warm and hands them out across concurrent
12//!   requests.
13//!
14//! - **`D1Executor` (Cloudflare D1)** — there is **no pool**. D1 is
15//!   request/response over a binding or the HTTP `/query` API; there is no
16//!   socket to keep open between calls. `PoolConfig` is meaningless here, and
17//!   that's not a gap to fill — it's the shape of the platform.
18//!
19//! ## Why serverless can't pool the usual way
20//!
21//! The classic pool assumes one long-running process owning a fixed set of
22//! sockets. Serverless breaks both halves of that assumption:
23//!
24//! - **No shared process.** Each concurrent Lambda / Cloud Run / Worker
25//!   instance is its own isolate. A pool is per-instance, so `max_connections`
26//!   multiplies by the platform's concurrency: 100 warm instances × a pool of
27//!   10 = 1000 sockets, which blows past a Postgres/MySQL `max_connections`
28//!   ceiling almost immediately. The fix is a **small** per-instance pool
29//!   (often exactly 1) plus a *server-side* pooler — RDS Proxy, PgBouncer, or
30//!   Cloudflare Hyperdrive — that does the real fan-in. [`PoolConfig::serverless`]
31//!   encodes the per-instance half.
32//!
33//! - **Frozen sockets.** Between invocations the platform may freeze the
34//!   instance; a pooled socket can be dead on the next thaw. `serverless()`
35//!   sets `test_before_acquire` so a stale connection is detected and replaced
36//!   rather than handed to a query that then fails.
37//!
38//! - **No sockets at all (Workers).** A Cloudflare Worker has no `tokio` and no
39//!   raw TCP, so `SeaOrmExecutor` cannot even compile to `wasm32` — it is
40//!   feature-gated off. Inside a Worker the only database path is
41//!   `D1Executor`, which pools nothing. Reaching a *Postgres* from a Worker
42//!   means fronting it with Hyperdrive (which pools on Cloudflare's side) and a
43//!   wasm-capable client — out of scope for this crate's native executor.
44//!
45//! So `PoolConfig` is a *server* concern. On serverless, prefer
46//! [`PoolConfig::serverless`] and push real pooling to a proxy; on Workers,
47//! pooling does not enter into it.
48
49use core::time::Duration;
50
51/// Tuning for the connection pool a native executor opens. The defaults suit a
52/// long-running server; use [`PoolConfig::serverless`] for per-instance
53/// serverless environments.
54#[derive(Debug, Clone)]
55pub struct PoolConfig {
56    /// Hard ceiling on open connections. On serverless this should be tiny
57    /// (the proxy does the real pooling); on a server, size it to the
58    /// database's connection budget divided by the number of app processes.
59    pub max_connections: u32,
60    /// Connections kept open even while idle. `0` lets the pool drain fully
61    /// between bursts.
62    pub min_connections: u32,
63    /// How long `acquire` waits for a free connection before erroring.
64    pub acquire_timeout: Duration,
65    /// Close a connection idle for longer than this. `None` keeps idle
66    /// connections indefinitely.
67    pub idle_timeout: Option<Duration>,
68    /// Recycle a connection older than this regardless of use — guards against
69    /// server-side timeouts and load-balancer connection caps. `None` disables.
70    pub max_lifetime: Option<Duration>,
71    /// Ping a connection before handing it out. Costs a round-trip but
72    /// guarantees liveness — worth it on serverless where sockets get frozen.
73    pub test_before_acquire: bool,
74}
75
76impl Default for PoolConfig {
77    /// Long-running-server defaults: up to 10 connections, drain when idle,
78    /// recycle hourly, no per-acquire ping.
79    fn default() -> Self {
80        Self {
81            max_connections: 10,
82            min_connections: 0,
83            acquire_timeout: Duration::from_secs(30),
84            idle_timeout: Some(Duration::from_secs(10 * 60)),
85            max_lifetime: Some(Duration::from_secs(30 * 60)),
86            test_before_acquire: false,
87        }
88    }
89}
90
91impl PoolConfig {
92    /// A single-connection pool tuned for a serverless instance: `max=1`,
93    /// short idle timeout, and `test_before_acquire` so a frozen-then-thawed
94    /// socket is caught. Pair this with a server-side pooler (RDS Proxy /
95    /// PgBouncer / Hyperdrive) that fans many instances onto a bounded set of
96    /// real database connections. See the [module docs](crate::pool) for why.
97    pub fn serverless() -> Self {
98        Self {
99            max_connections: 1,
100            min_connections: 0,
101            acquire_timeout: Duration::from_secs(10),
102            idle_timeout: Some(Duration::from_secs(2)),
103            max_lifetime: Some(Duration::from_secs(60)),
104            test_before_acquire: true,
105        }
106    }
107
108    /// The only configuration an **in-memory SQLite** database survives.
109    ///
110    /// Such a database exists only as long as the connection holding it, so a
111    /// pool must keep exactly one and must never reap it:
112    ///
113    /// - `max_connections: 1`, or the second connection is a second, empty
114    ///   database;
115    /// - `min_connections: 1` and **no idle timeout**, or the pool closes the
116    ///   connection while nothing is happening and takes the schema with it.
117    ///
118    /// That second half is the one that bites. [`serverless`](Self::serverless)
119    /// looks right — it is a pool of one — and works for the length of a test,
120    /// then drops the tables two seconds after the last query. The symptom is
121    /// a server that migrates cleanly at boot and answers `no such table` to
122    /// the first request a human makes.
123    pub fn in_memory() -> Self {
124        Self {
125            max_connections: 1,
126            min_connections: 1,
127            acquire_timeout: Duration::from_secs(10),
128            idle_timeout: None,
129            max_lifetime: None,
130            test_before_acquire: false,
131        }
132    }
133
134    /// Builder-style override of [`max_connections`](Self::max_connections).
135    pub fn with_max_connections(mut self, n: u32) -> Self {
136        self.max_connections = n;
137        self
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    #[test]
146    fn an_in_memory_pool_keeps_its_one_connection_forever() {
147        // Every field here is load-bearing: the database *is* the connection.
148        let pool = PoolConfig::in_memory();
149
150        assert_eq!(pool.max_connections, 1, "a second connection is a second database");
151        assert_eq!(pool.min_connections, 1, "dropping to zero drops the schema");
152        assert_eq!(pool.idle_timeout, None, "reaping it while idle loses everything");
153        assert_eq!(pool.max_lifetime, None, "so does recycling it");
154    }
155
156    #[test]
157    fn the_serverless_preset_is_a_pool_of_one_that_still_reaps() {
158        // Which is right for a serverless database and wrong for an in-memory
159        // one — the distinction `in_memory` exists to make.
160        let pool = PoolConfig::serverless();
161
162        assert_eq!(pool.max_connections, 1);
163        assert!(pool.idle_timeout.is_some());
164    }
165}