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::Rng;
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 jitter = rand::thread_rng().gen::<f64>();
126                    let delay = crate::db::retry::backoff_for(attempt, jitter);
127                    tracing::warn!(
128                        "DB connection attempt {} failed (backend error), retrying in {:?}: {:?}",
129                        attempt + 1,
130                        delay,
131                        last_err.as_ref().unwrap(),
132                    );
133                    tokio::time::sleep(delay).await;
134                } else if is_timeout {
135                    // 池满:立即返回,不再 sleep。
136                    break;
137                }
138            }
139        }
140    }
141    Err(last_err.unwrap())
142}
143
144/// 启动期专用:在可配置的时间窗口内反复尝试连接数据库,专治“DB 还没起来”。
145///
146/// 与运行期的 [`get_conn`] 区别:
147/// - **没有反雪崩约束**:启动时只有这一个进程在连,不会雪崩,可以放心长重试。
148/// - **固定间隔重试**(而非指数退避):启动场景下 DB 要么起来要么没起来,
149///   固定 500ms 轮询比指数退避更可预测,也更贴合 `pg_isready` 式的等待语义。
150/// - **以总时长为终止条件**(而非次数):对运维更直观——"给 DB 30 秒起来"。
151///
152/// 超时窗口由 `MIGRATE_STARTUP_TIMEOUT_SECS` 控制,默认 30 秒。窗口用尽后返回
153/// 最后一次错误,由调用方(`main.rs`)决定如何向用户报告。
154///
155/// 适用 docker-compose(无 healthcheck)、本机忘启 Postgres 等“DB 起得比 app 慢”的场景。
156pub async fn get_conn_for_startup(
157) -> Result<deadpool_postgres::Object, deadpool_postgres::PoolError> {
158    let timeout_secs = parse_migrate_startup_timeout();
159
160    let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout_secs);
161    let retry_interval = Duration::from_millis(500);
162
163    let mut attempt = 0u32;
164    loop {
165        attempt += 1;
166        match DB_POOL.get().await {
167            Ok(conn) => {
168                if attempt > 1 {
169                    tracing::info!("connected to database after {} attempt(s)", attempt);
170                }
171                return Ok(conn);
172            }
173            Err(e) => {
174                let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
175                if remaining.is_zero() {
176                    return Err(e);
177                }
178                tracing::warn!(
179                    "startup DB connection attempt {} failed, ~{}s remaining until giving up: {:?}",
180                    attempt,
181                    remaining.as_secs(),
182                    e,
183                );
184                // 不要睡过 deadline,避免超出用户配置的窗口。
185                let sleep = std::cmp::min(retry_interval, remaining);
186                tokio::time::sleep(sleep).await;
187            }
188        }
189    }
190}
191
192/// 启动期自举:连接 `postgres` 维护库,确保目标数据库存在(不存在则创建)。
193///
194/// 解决"全新部署没有目标库"的缺口——`get_conn_for_startup` 连的是 `DATABASE_URL`
195/// 里指定的目标库,库不存在时只会反复重试到超时退出。本函数在连接池首次被触碰
196/// **之前**运行,把 `scripts/migrate.sh` 里那段 `CREATE DATABASE` 逻辑内置进二进制,
197/// 让首次启动真正零手动。
198///
199/// 与 `get_conn_for_startup` 共享同一套语义:
200/// - 复用 `MIGRATE_STARTUP_TIMEOUT_SECS` 窗口(默认 30s)应对"DB 起得比 app 慢";
201/// - 固定 500ms 轮询连接 `postgres` 维护库;连上后 `EXISTS` 查询 + `CREATE` 只跑一次。
202/// - 目标库已存在是常态,此时仅一次快速往返。
203///
204/// 返回 `Result<(), String>`(而非 `MigrateError`),与 `validate_database_url` 的
205/// 报错风格一致,便于 `main.rs` 走统一的 `tracing::error!` + `exit(1)` 路径。
206///
207/// 跳过自动创建(返回 `Ok(())`)的安全场景:
208/// - 目标库名无法从 URL/用户名推断;
209/// - 目标库名不是简单标识符(含 `-`、引号等),避免拼到 `CREATE DATABASE` 后面
210///   产生 SQL 注入风险——此时把"库不存在"的错误留给后续正常连接路径去报告。
211pub async fn ensure_database() -> Result<(), String> {
212    // 1. 推断目标库名:优先 URL 里的 dbname,回退到用户名(Postgres 自身的默认行为)。
213    let pg_cfg = build_pg_config()?;
214    let db_name = pg_cfg
215        .get_dbname()
216        .or_else(|| pg_cfg.get_user())
217        .map(|s| s.to_string());
218
219    let db_name = match db_name {
220        Some(name) => name,
221        None => {
222            tracing::warn!(
223                "could not determine target database name from DATABASE_URL; \
224                 skipping auto-create (letting normal connect path surface any error)"
225            );
226            return Ok(());
227        }
228    };
229
230    // 2. 标识符安全校验:`CREATE DATABASE` 后面只能跟裸标识符(无法用 $1 参数化),
231    //    含特殊字符的库名直接跳过,避免注入。
232    if !is_simple_ident(&db_name) {
233        tracing::warn!(
234            "target database name {:?} is not a simple identifier; \
235             skipping auto-create (letting normal connect path surface any error)",
236            db_name
237        );
238        return Ok(());
239    }
240
241    // 3. 在启动超时窗口内反复尝试连接 `postgres` 维护库。
242    let timeout_secs = parse_migrate_startup_timeout();
243    let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout_secs);
244    let retry_interval = Duration::from_millis(500);
245
246    let (client, connection) = loop {
247        let admin_cfg = build_admin_config()?;
248        match admin_cfg.connect(NoTls).await {
249            Ok(joined) => break joined,
250            Err(e) => {
251                let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
252                if remaining.is_zero() {
253                    return Err(format!(
254                        "could not connect to 'postgres' maintenance database within {timeout_secs}s: {}",
255                        crate::db::format_with_sources(&e)
256                    ));
257                }
258                tracing::warn!(
259                    "ensure_database: connect to 'postgres' failed, ~{}s remaining: {}",
260                    remaining.as_secs(),
261                    crate::db::format_with_sources(&e)
262                );
263                tokio::time::sleep(std::cmp::min(retry_interval, remaining)).await;
264            }
265        }
266    };
267    // 连接的后台驱动任务:出错时仅记录,连接随即作废。
268    tokio::spawn(async move {
269        if let Err(e) = connection.await {
270            tracing::warn!(
271                "postgres maintenance connection ended: {}",
272                crate::db::format_with_sources(&e)
273            );
274        }
275    });
276
277    // 4. 查询目标库是否已存在。
278    let exists: bool = client
279        .query_one(
280            "SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = $1)",
281            &[&db_name],
282        )
283        .await
284        .map_err(|e| {
285            format!(
286                "failed to query pg_database: {}",
287                crate::db::format_with_sources(&e)
288            )
289        })?
290        .get(0);
291
292    if exists {
293        tracing::info!("target database {:?} already exists", db_name);
294        return Ok(());
295    }
296
297    // 5. 不存在则创建。db_name 已通过 is_simple_ident 校验,可安全拼到 SQL。
298    tracing::info!("target database {:?} does not exist, creating", db_name);
299    let stmt = format!("CREATE DATABASE {db_name}");
300    client.batch_execute(&stmt).await.map_err(|e| {
301        format!(
302            "failed to create database {db_name:?}: {}",
303            crate::db::format_with_sources(&e)
304        )
305    })?;
306    tracing::info!("created database {:?}", db_name);
307    Ok(())
308}
309
310/// 构建 `postgres` 维护库的连接配置:复用目标 URL 的 host/port/user/password,
311/// 仅把 dbname 换成 `postgres`。
312///
313/// `tokio_postgres::Config` 未实现 `Clone`,故逐字段拷贝到新的 `Config::new()`。
314/// `host()`/`port()` 是 `&mut self` 的借用式 builder,故按 host 逐个追加并配对端口。
315fn build_admin_config() -> Result<Config, String> {
316    let src = build_pg_config()?;
317    let mut dst = Config::new();
318
319    if let Some(user) = src.get_user() {
320        dst.user(user);
321    }
322    if let Some(password) = src.get_password() {
323        dst.password(password);
324    }
325    let hosts = src.get_hosts();
326    let ports = src.get_ports();
327    for (i, host) in hosts.iter().enumerate() {
328        // 端口与 host 按 tokio-postgres 内部配对规则取第 i 个端口,缺省回退 5432。
329        let port = ports.get(i).copied().unwrap_or(5432);
330        match host {
331            Host::Tcp(h) => {
332                dst.host(h);
333                dst.port(port);
334            }
335            Host::Unix(p) => {
336                dst.host_path(p);
337            }
338        };
339    }
340    // get_hosts()/get_ports() 为空时退回 libpq 默认(localhost:5432),与原 URL 解析一致。
341    dst.dbname("postgres");
342    Ok(dst)
343}
344
345/// 判断字符串是否为 PostgreSQL 简单标识符:`^[A-Za-z_][A-Za-z0-9_]*$`。
346///
347/// 用于在把目标库名拼进 `CREATE DATABASE <name>` 前做安全校验——SQL 里库名无法参数化,
348/// 非简单标识符(如 `my-db`、含引号)一律跳过自动创建。
349fn is_simple_ident(s: &str) -> bool {
350    let mut chars = s.chars();
351    match chars.next() {
352        Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
353        _ => return false,
354    }
355    chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
356}
357
358#[cfg(test)]
359mod tests {
360    use super::is_simple_ident;
361
362    #[test]
363    fn simple_ident_accepts_valid_names() {
364        assert!(is_simple_ident("yggdrasil"));
365        assert!(is_simple_ident("_ygg"));
366        assert!(is_simple_ident("db_1"));
367        assert!(is_simple_ident("YggDrasil09"));
368    }
369
370    #[test]
371    fn simple_ident_rejects_invalid_names() {
372        assert!(!is_simple_ident(""));
373        assert!(!is_simple_ident("my-db")); // 连字符
374        assert!(!is_simple_ident("9db")); // 数字开头
375        assert!(!is_simple_ident("db name")); // 空格
376        assert!(!is_simple_ident("db\"; --")); // 引号 / 注入
377        assert!(!is_simple_ident("db.name")); // 点
378    }
379}