Skip to main content

yggdrasil/db/
pool.rs

1//! PostgreSQL 连接池实现。
2//!
3//! 仅在启用 `server` feature 时编译,使用 deadpool-postgres 管理连接池,
4//! 并通过 `std::sync::LazyLock` 在首次访问时延迟初始化全局连接池。
5//! `get_conn` 失败时按指数退避 + jitter 重试(见 `retry` 模块),以应对瞬时连接失败。
6//!
7//! 启动期的重试窗口更长(见 `get_conn_for_startup`),并配合 `main.rs` 的前置校验
8//!(`validate_database_url`)让所有启动期致命错误走统一友好的 `exit(1)` 路径。
9
10use std::sync::LazyLock;
11use std::time::Duration;
12
13use crate::utils::server::parse_migrate_startup_timeout;
14use deadpool_postgres::{Manager, ManagerConfig, Pool, RecyclingMethod, Runtime};
15use tokio_postgres::config::Host;
16use tokio_postgres::{Config, NoTls};
17
18/// 解析 `DATABASE_URL` 并注入 `statement_timeout`,返回配置好的 `tokio_postgres::Config`。
19///
20/// 把原本写死在 `DB_POOL` LazyLock 闭包里的逻辑抽出来,便于:
21/// - `main.rs` 启动早期做前置校验(`validate_database_url`);
22/// - LazyLock 闭包退化为不可达的防御性代码。
23///
24/// 返回 `Err(String)` 而非 panic,调用方决定如何向用户报告错误。
25fn build_pg_config() -> Result<tokio_postgres::Config, String> {
26    let db_url = std::env::var("DATABASE_URL")
27        .map_err(|_| "DATABASE_URL environment variable not set".to_string())?;
28    let mut pg_cfg = db_url
29        .parse::<tokio_postgres::Config>()
30        .map_err(|e| format!("Invalid DATABASE_URL format: {e}"))?;
31
32    // statement_timeout:防止单条慢查询(如全表扫搜索)长时间占用连接拖垮池。
33    // 默认 30s,可由 STATEMENT_TIMEOUT_SECS 覆盖。
34    let statement_timeout_secs = std::env::var("STATEMENT_TIMEOUT_SECS")
35        .ok()
36        .and_then(|s| s.parse::<u32>().ok())
37        .unwrap_or(30);
38    // 通过 libpq options 传递 GUC;tokio-postgres 在建连时执行。
39    pg_cfg.options(format!(
40        "-c statement_timeout={}",
41        statement_timeout_secs * 1000
42    ));
43
44    Ok(pg_cfg)
45}
46
47/// 启动早期校验:`DATABASE_URL` 格式合法 + `DB_POOL_SIZE` 为正数。
48///
49/// 供 `main.rs` 在 `DB_POOL` LazyLock 被触碰之前调用,让 URL 格式错误、池大小非法
50/// 这类用户可修复的配置问题走统一友好的 `tracing::error!` + `exit(1)` 路径,
51/// 而不是触发 LazyLock 闭包里的 `.expect()` panic。
52///
53/// 返回 `Err(String)` 时,字符串已是面向用户的错误描述。
54pub fn validate_database_url() -> Result<(), String> {
55    build_pg_config()?;
56
57    // 同步校验池大小,避免 LazyLock 闭包里 `unwrap_or(20)` 静默吞掉非法值。
58    if let Ok(s) = std::env::var("DB_POOL_SIZE") {
59        match s.parse::<usize>() {
60            Ok(n) if n > 0 => {}
61            Ok(_) => return Err("DB_POOL_SIZE is not a positive integer".to_string()),
62            Err(e) => return Err(format!("Invalid DB_POOL_SIZE value: {e}")),
63        }
64    }
65    Ok(())
66}
67
68/// 全局数据库连接池。
69///
70/// **不可达的防御性 panic**:`main.rs` 启动时已通过 `validate_database_url()` 前置校验
71/// `DATABASE_URL` 格式与 `DB_POOL_SIZE`,因此在真实运行路径上本闭包里的 `.expect()`
72/// 永远不会触发。保留 `.expect()` 只是为了满足 `LazyLock` 必须返回 `T`(而非 `Result`)
73/// 的类型约束——若这里真的 panic,说明 `validate_database_url` 与本闭包逻辑不一致,
74/// 属于代码 bug 而非用户错误。
75pub static DB_POOL: LazyLock<Pool> = LazyLock::new(|| {
76    // 前置校验已保证配置合法;闭包里直接 expect 以满足 LazyLock 的类型约束。
77    let pg_cfg = build_pg_config()
78        .expect("DATABASE_URL should have been validated at startup; validate_database_url() was not called");
79
80    // 使用 Fast 回收策略:归还连接时不额外发 SELECT 1 验证,直接复用。
81    // Verified 在高并发下会为每次 get() 增加一次往返;Fast 依赖 tokio-postgres
82    // 在使用时自然报错,由 get_conn 的重试层兜底。
83    let mgr_cfg = ManagerConfig {
84        recycling_method: RecyclingMethod::Fast,
85    };
86    let mgr = Manager::from_config(pg_cfg, NoTls, mgr_cfg);
87
88    Pool::builder(mgr)
89        .max_size(
90            std::env::var("DB_POOL_SIZE")
91                .ok()
92                .and_then(|s| s.parse().ok())
93                .unwrap_or(20),
94        )
95        .wait_timeout(Some(Duration::from_secs(10)))
96        .create_timeout(Some(Duration::from_secs(10)))
97        .recycle_timeout(Some(Duration::from_secs(5)))
98        .runtime(Runtime::Tokio1)
99        .build()
100        .expect("Failed to create database connection pool")
101});
102
103/// 从全局连接池获取一个数据库连接,失败时按指数退避 + jitter 重试。
104///
105/// 这是**运行期**获取连接的路径:反雪崩导向——快速失败(约 1.6s 后放弃),
106/// 让上层限流兜底。**启动期**(迁移)请用 [`get_conn_for_startup`],它有一个
107/// 更长的可配置重试窗口,专为“DB 还没起来”的场景设计。
108///
109/// 退避策略见 `retry::backoff_for`。仅对 Backend/Postgres 错误(DB 不可达)重试;
110/// Timeout(池满)直接返回,让上层限流兜底,避免雪崩。
111/// 若所有重试均失败或系统随机源不可用,返回最后一次的 PoolError。
112pub async fn get_conn() -> Result<deadpool_postgres::Object, deadpool_postgres::PoolError> {
113    use rand::TryRng;
114
115    let mut last_err = None;
116    for attempt in 0..=crate::db::retry::MAX_RETRIES {
117        match DB_POOL.get().await {
118            Ok(conn) => return Ok(conn),
119            Err(e) => {
120                // Timeout(池满)不重试:快速失败让上层限流兜底,避免雪崩。
121                // Backend/Postgres(DB 不可达)才退避重试。
122                let is_timeout = matches!(e, deadpool_postgres::PoolError::Timeout(_));
123                last_err = Some(e);
124                if !is_timeout && attempt < crate::db::retry::MAX_RETRIES {
125                    let Ok(random_bits) = rand::rngs::SysRng.try_next_u64() else {
126                        // 随机源不可用时返回原连接错误,不 panic 或使用固定 jitter。
127                        break;
128                    };
129                    // 与 rand 的 StandardUniform<f64> 一致:53 个随机位映射到 [0, 1)。
130                    let jitter = (random_bits >> 11) as f64 * (1.0 / (1u64 << 53) as f64);
131                    let delay = crate::db::retry::backoff_for(attempt, jitter);
132                    tracing::warn!(
133                        "DB connection attempt {} failed (backend error), retrying in {:?}: {:?}",
134                        attempt + 1,
135                        delay,
136                        last_err.as_ref().unwrap(),
137                    );
138                    tokio::time::sleep(delay).await;
139                } else if is_timeout {
140                    // 池满:立即返回,不再 sleep。
141                    break;
142                }
143            }
144        }
145    }
146    Err(last_err.unwrap())
147}
148
149/// 启动期专用:在可配置的时间窗口内反复尝试连接数据库,专治“DB 还没起来”。
150///
151/// 与运行期的 [`get_conn`] 区别:
152/// - **没有反雪崩约束**:启动时只有这一个进程在连,不会雪崩,可以放心长重试。
153/// - **固定间隔重试**(而非指数退避):启动场景下 DB 要么起来要么没起来,
154///   固定 500ms 轮询比指数退避更可预测,也更贴合 `pg_isready` 式的等待语义。
155/// - **以总时长为终止条件**(而非次数):对运维更直观——"给 DB 30 秒起来"。
156///
157/// 超时窗口由 `MIGRATE_STARTUP_TIMEOUT_SECS` 控制,默认 30 秒。窗口用尽后返回
158/// 最后一次错误,由调用方(`main.rs`)决定如何向用户报告。
159///
160/// 适用 docker-compose(无 healthcheck)、本机忘启 Postgres 等“DB 起得比 app 慢”的场景。
161pub async fn get_conn_for_startup(
162) -> Result<deadpool_postgres::Object, deadpool_postgres::PoolError> {
163    let timeout_secs = parse_migrate_startup_timeout();
164
165    let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout_secs);
166    let retry_interval = Duration::from_millis(500);
167
168    let mut attempt = 0u32;
169    loop {
170        attempt += 1;
171        match DB_POOL.get().await {
172            Ok(conn) => {
173                if attempt > 1 {
174                    tracing::info!("connected to database after {} attempt(s)", attempt);
175                }
176                return Ok(conn);
177            }
178            Err(e) => {
179                let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
180                if remaining.is_zero() {
181                    return Err(e);
182                }
183                tracing::warn!(
184                    "startup DB connection attempt {} failed, ~{}s remaining until giving up: {:?}",
185                    attempt,
186                    remaining.as_secs(),
187                    e,
188                );
189                // 不要睡过 deadline,避免超出用户配置的窗口。
190                let sleep = std::cmp::min(retry_interval, remaining);
191                tokio::time::sleep(sleep).await;
192            }
193        }
194    }
195}
196
197/// 启动期自举:连接 `postgres` 维护库,确保目标数据库存在(不存在则创建)。
198///
199/// 解决"全新部署没有目标库"的缺口——`get_conn_for_startup` 连的是 `DATABASE_URL`
200/// 里指定的目标库,库不存在时只会反复重试到超时退出。本函数在连接池首次被触碰
201/// **之前**运行,把 `CREATE DATABASE` 自举逻辑内置进二进制,
202/// 让首次启动真正零手动。
203///
204/// 与 `get_conn_for_startup` 共享同一套语义:
205/// - 复用 `MIGRATE_STARTUP_TIMEOUT_SECS` 窗口(默认 30s)应对"DB 起得比 app 慢";
206/// - 固定 500ms 轮询连接 `postgres` 维护库;连上后 `EXISTS` 查询 + `CREATE` 只跑一次。
207/// - 目标库已存在是常态,此时仅一次快速往返。
208///
209/// 返回 `Result<(), String>`(而非 `MigrateError`),与 `validate_database_url` 的
210/// 报错风格一致,便于 `main.rs` 走统一的 `tracing::error!` + `exit(1)` 路径。
211///
212/// 跳过自动创建(返回 `Ok(())`)的安全场景:
213/// - 目标库名无法从 URL/用户名推断;
214/// - 目标库名不是简单标识符(含 `-`、引号等),避免拼到 `CREATE DATABASE` 后面
215///   产生 SQL 注入风险——此时把"库不存在"的错误留给后续正常连接路径去报告。
216pub async fn ensure_database() -> Result<(), String> {
217    // 1. 推断目标库名:优先 URL 里的 dbname,回退到用户名(Postgres 自身的默认行为)。
218    let pg_cfg = build_pg_config()?;
219    let db_name = pg_cfg
220        .get_dbname()
221        .or_else(|| pg_cfg.get_user())
222        .map(|s| s.to_string());
223
224    let db_name = match db_name {
225        Some(name) => name,
226        None => {
227            tracing::warn!(
228                "could not determine target database name from DATABASE_URL; \
229                 skipping auto-create (letting normal connect path surface any error)"
230            );
231            return Ok(());
232        }
233    };
234
235    // 2. 标识符安全校验:`CREATE DATABASE` 后面只能跟裸标识符(无法用 $1 参数化),
236    //    含特殊字符的库名直接跳过,避免注入。
237    if !is_simple_ident(&db_name) {
238        tracing::warn!(
239            "target database name {:?} is not a simple identifier; \
240             skipping auto-create (letting normal connect path surface any error)",
241            db_name
242        );
243        return Ok(());
244    }
245
246    // 3. 在启动超时窗口内反复尝试连接 `postgres` 维护库。
247    let timeout_secs = parse_migrate_startup_timeout();
248    let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout_secs);
249    let retry_interval = Duration::from_millis(500);
250
251    let (client, connection) = loop {
252        let admin_cfg = build_admin_config()?;
253        match admin_cfg.connect(NoTls).await {
254            Ok(joined) => break joined,
255            Err(e) => {
256                let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
257                if remaining.is_zero() {
258                    return Err(format!(
259                        "could not connect to 'postgres' maintenance database within {timeout_secs}s: {}",
260                        crate::db::format_with_sources(&e)
261                    ));
262                }
263                tracing::warn!(
264                    "ensure_database: connect to 'postgres' failed, ~{}s remaining: {}",
265                    remaining.as_secs(),
266                    crate::db::format_with_sources(&e)
267                );
268                tokio::time::sleep(std::cmp::min(retry_interval, remaining)).await;
269            }
270        }
271    };
272    // 连接的后台驱动任务:出错时仅记录,连接随即作废。
273    tokio::spawn(async move {
274        if let Err(e) = connection.await {
275            tracing::warn!(
276                "postgres maintenance connection ended: {}",
277                crate::db::format_with_sources(&e)
278            );
279        }
280    });
281
282    // 4. 查询目标库是否已存在。
283    let exists: bool = client
284        .query_one(
285            "SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = $1)",
286            &[&db_name],
287        )
288        .await
289        .map_err(|e| {
290            format!(
291                "failed to query pg_database: {}",
292                crate::db::format_with_sources(&e)
293            )
294        })?
295        .get(0);
296
297    if exists {
298        tracing::info!("target database {:?} already exists", db_name);
299        return Ok(());
300    }
301
302    // 5. 不存在则创建。db_name 已通过 is_simple_ident 校验,可安全拼到 SQL。
303    tracing::info!("target database {:?} does not exist, creating", db_name);
304    let stmt = format!("CREATE DATABASE {db_name}");
305    client.batch_execute(&stmt).await.map_err(|e| {
306        format!(
307            "failed to create database {db_name:?}: {}",
308            crate::db::format_with_sources(&e)
309        )
310    })?;
311    tracing::info!("created database {:?}", db_name);
312    Ok(())
313}
314
315/// 构建 `postgres` 维护库的连接配置:复用目标 URL 的 host/port/user/password,
316/// 仅把 dbname 换成 `postgres`。
317///
318/// `tokio_postgres::Config` 未实现 `Clone`,故逐字段拷贝到新的 `Config::new()`。
319/// `host()`/`port()` 是 `&mut self` 的借用式 builder,故按 host 逐个追加并配对端口。
320fn build_admin_config() -> Result<Config, String> {
321    let src = build_pg_config()?;
322    let mut dst = Config::new();
323
324    if let Some(user) = src.get_user() {
325        dst.user(user);
326    }
327    if let Some(password) = src.get_password() {
328        dst.password(password);
329    }
330    let hosts = src.get_hosts();
331    let ports = src.get_ports();
332    for (i, host) in hosts.iter().enumerate() {
333        // 端口与 host 按 tokio-postgres 内部配对规则取第 i 个端口,缺省回退 5432。
334        let port = ports.get(i).copied().unwrap_or(5432);
335        match host {
336            Host::Tcp(h) => {
337                dst.host(h);
338                dst.port(port);
339            }
340            Host::Unix(p) => {
341                dst.host_path(p);
342            }
343        };
344    }
345    // get_hosts()/get_ports() 为空时退回 libpq 默认(localhost:5432),与原 URL 解析一致。
346    dst.dbname("postgres");
347    Ok(dst)
348}
349
350/// 判断字符串是否为 PostgreSQL 简单标识符:`^[A-Za-z_][A-Za-z0-9_]*$`。
351///
352/// 用于在把目标库名拼进 `CREATE DATABASE <name>` 前做安全校验——SQL 里库名无法参数化,
353/// 非简单标识符(如 `my-db`、含引号)一律跳过自动创建。
354fn is_simple_ident(s: &str) -> bool {
355    let mut chars = s.chars();
356    match chars.next() {
357        Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
358        _ => return false,
359    }
360    chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
361}
362
363#[cfg(test)]
364mod tests {
365    use super::is_simple_ident;
366
367    #[test]
368    fn simple_ident_accepts_valid_names() {
369        assert!(is_simple_ident("yggdrasil"));
370        assert!(is_simple_ident("_ygg"));
371        assert!(is_simple_ident("db_1"));
372        assert!(is_simple_ident("YggDrasil09"));
373    }
374
375    #[test]
376    fn simple_ident_rejects_invalid_names() {
377        assert!(!is_simple_ident(""));
378        assert!(!is_simple_ident("my-db")); // 连字符
379        assert!(!is_simple_ident("9db")); // 数字开头
380        assert!(!is_simple_ident("db name")); // 空格
381        assert!(!is_simple_ident("db\"; --")); // 引号 / 注入
382        assert!(!is_simple_ident("db.name")); // 点
383    }
384}