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}