From b24cfdcabcc793ea8e8057248ab09a5504f5743a Mon Sep 17 00:00:00 2001 From: xfy Date: Mon, 22 Jun 2026 11:24:13 +0800 Subject: [PATCH] fix(startup): replace migration panic with friendly exit + configurable retry window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DB migration failure at startup panicked via .expect() (main.rs:223), dumping a raw backtrace for what is usually just "PostgreSQL isn't running yet". Worse, the 1.6s runtime retry budget was reused at startup, so it almost always failed against a cold/slow DB (docker-compose without healthchecks, local Postgres not started, etc.). Changes: - pool: extract build_pg_config() (returns Result, no panic); add validate_database_url() so URL-format / DB_POOL_SIZE errors surface as friendly exit(1) instead of hitting the LazyLock's unreachable .expect() - pool: add get_conn_for_startup() — a startup-only retry loop with a configurable total-duration window (MIGRATE_STARTUP_TIMEOUT_SECS, default 30s, 500ms interval), separate from the runtime get_conn() anti-avalanche fast retry (untouched) - migrate: split run() into run_on_conn(&mut conn) so callers control the connection-acquisition strategy; main.rs pairs this with the startup retry. Advisory-lock release comment updated: exit(1) terminates the process just like panic, so the session-level lock is still freed - main: startup fatal errors (bad URL, DB unreachable, migration failed) now each emit tracing::error! + eprintln! ERROR + targeted HINT, then exit(1) — no panic, no backtrace nudge - .env.example / AGENTS.md: document MIGRATE_STARTUP_TIMEOUT_SECS No runtime behavior change: get_conn() and its ~40 call sites are unchanged. Advisory-lock safety preserved (documented in migrate.rs). --- .env.example | 7 +++ AGENTS.md | 1 + src/db/migrate.rs | 42 ++++++++--------- src/db/pool.rs | 117 +++++++++++++++++++++++++++++++++++++++++++--- src/main.rs | 46 ++++++++++++++++-- 5 files changed, 179 insertions(+), 34 deletions(-) diff --git a/.env.example b/.env.example index 5100acc..daebd2f 100644 --- a/.env.example +++ b/.env.example @@ -36,6 +36,13 @@ MAX_SESSIONS_PER_USER=5 # Database connection pool size (default: 20) DB_POOL_SIZE=20 +# Startup DB connection retry window in seconds (default: 30). +# How long the server waits for PostgreSQL to become reachable at startup before giving up. +# Useful when the DB starts slower than the app (docker-compose without healthchecks, cold +# local Postgres, etc.). Only affects startup; runtime connection retries are separate and +# fast-fail to avoid cascading failures. +MIGRATE_STARTUP_TIMEOUT_SECS=30 + # SSR page cache duration in seconds (default: 3600). # src/ssr_cache.rs maintains a global generation counter bumped on every post write, but # Dioxus 0.7 does not expose an API to wire it into the incremental SSR cache key. Until such diff --git a/AGENTS.md b/AGENTS.md index f6ae935..c8a48a8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,6 +42,7 @@ RATE_LIMIT_UPLOAD_BURST=15 RATE_LIMIT_IMAGE_PER_SEC=10 RATE_LIMIT_IMAGE_BURST=50 DB_POOL_SIZE=20 # database connection pool size +MIGRATE_STARTUP_TIMEOUT_SECS=30 # how long startup waits for PostgreSQL before giving up STATEMENT_TIMEOUT_SECS=30 # per-query timeout; slow queries are canceled to protect the pool SSR_CACHE_SECS=3600 # incremental SSR cache TTL ``` diff --git a/src/db/migrate.rs b/src/db/migrate.rs index 4f8f0e9..2f50e8a 100644 --- a/src/db/migrate.rs +++ b/src/db/migrate.rs @@ -96,26 +96,21 @@ impl std::fmt::Display for MigrateError { impl std::error::Error for MigrateError {} -/// 执行所有未应用的迁移。 +/// 在**已获取**的连接上执行迁移主体逻辑(咨询锁 + 建表 + 应用迁移 + 解锁)。 /// -/// 在 `main.rs` 启动时调用一次。流程: -/// 1. 获取一个独占连接(咨询锁是 session 级,需在同一连接上 lock/unlock) -/// 2. 抢咨询锁(多实例启动时串行化) -/// 3. 确保 `schema_migrations` 表存在 -/// 4. 查询已应用版本集合 -/// 5. 按序应用未应用的迁移(每个一个事务) -/// 6. 释放咨询锁 +/// 调用方负责自行控制连接获取策略——例如 `main.rs` 启动时用 +/// [`get_conn_for_startup`](crate::db::pool::get_conn_for_startup)(长重试窗口) +/// 拿到连接后再调用本函数,以应对 DB 尚未就绪的场景。 +/// +/// 流程: +/// 1. 抢咨询锁(多实例启动时串行化) +/// 2. 确保 `schema_migrations` 表存在 +/// 3. 查询已应用版本集合 +/// 4. 按序应用未应用的迁移(每个一个事务) +/// 5. 释放咨询锁 /// /// 失败时返回错误;调用方(`main.rs`)应让进程退出,避免启动半残服务。 -pub async fn run() -> Result<(), MigrateError> { - use crate::db::pool::get_conn; - - tracing::info!("running database migrations"); - - // 咨询锁是 session 级的,必须在同一连接上 lock/unlock。 - // 因此整个迁移流程独占一个池连接。 - let mut conn = get_conn().await?; - +pub async fn run_on_conn(conn: &mut deadpool_postgres::Object) -> Result<(), MigrateError> { // 抢咨询锁:多实例滚动发布时只有一个进程能进入迁移循环, // 其余实例在此等待;锁释放后它们查版本表发现已全部应用,直接返回。 conn.execute("SELECT pg_advisory_lock($1)", &[&ADVISORY_LOCK_KEY]) @@ -124,12 +119,13 @@ pub async fn run() -> Result<(), MigrateError> { // 在已持锁连接上执行迁移主体逻辑。 // 锁释放策略: // - 正常返回 / 返回 Err:下面的显式 pg_advisory_unlock 释放锁。 - // - panic:panic 会跳过显式 unlock。此时 `conn` 在 unwind 中被 drop, - // 但 deadpool 会把连接归还池中复用(不关闭 Postgres 会话), - // 所以 session 级咨询锁不会立即释放。安全性依赖调用方(main.rs 的 - // `.expect()`)在 panic 时终止进程——进程退出会关闭所有池连接, - // Postgres 检测到会话断开后释放 session 级咨询锁。 - let result = run_inner(&mut conn).await; + // - 进程被强杀(SIGKILL 等):连接断开,Postgres 在检测到会话断开后释放 + // session 级咨询锁。 + // - `main.rs` 在迁移失败时用 `std::process::exit(1)` 终止进程(不再 panic): + // `exit(1)` 不会 unwind,但同样会关闭进程持有的所有 socket / 池连接, + // 效果等价于会话断开——Postgres 会释放 session 级咨询锁。 + // 因此把 `.expect()` 改成 `exit(1)` 不破坏原有的锁安全保证。 + let result = run_inner(conn).await; // 无论成功失败都尝试显式释放锁;释放失败不应掩盖原始错误,仅记录告警。 if let Err(unlock_err) = conn diff --git a/src/db/pool.rs b/src/db/pool.rs index d737a61..5d5c5f1 100644 --- a/src/db/pool.rs +++ b/src/db/pool.rs @@ -3,6 +3,9 @@ //! 仅在启用 `server` feature 时编译,使用 deadpool-postgres 管理连接池, //! 并通过 `std::sync::LazyLock` 在首次访问时延迟初始化全局连接池。 //! `get_conn` 失败时按指数退避 + jitter 重试(见 `retry` 模块),以应对瞬时连接失败。 +//! +//! 启动期的重试窗口更长(见 `get_conn_for_startup`),并配合 `main.rs` 的前置校验 +//!(`validate_database_url`)让所有启动期致命错误走统一友好的 `exit(1)` 路径。 use std::sync::LazyLock; use std::time::Duration; @@ -10,17 +13,23 @@ use std::time::Duration; use deadpool_postgres::{Manager, ManagerConfig, Pool, RecyclingMethod, Runtime}; use tokio_postgres::NoTls; -/// 全局数据库连接池,基于 `DATABASE_URL` 环境变量延迟初始化。 +/// 解析 `DATABASE_URL` 并注入 `statement_timeout`,返回配置好的 `tokio_postgres::Config`。 /// -/// 最大连接数可通过 `DB_POOL_SIZE` 环境变量调整,默认 20。 -pub static DB_POOL: LazyLock = LazyLock::new(|| { - let db_url = std::env::var("DATABASE_URL").expect("DATABASE_URL environment variable not set"); +/// 把原本写死在 `DB_POOL` LazyLock 闭包里的逻辑抽出来,便于: +/// - `main.rs` 启动早期做前置校验(`validate_database_url`); +/// - LazyLock 闭包退化为不可达的防御性代码。 +/// +/// 返回 `Err(String)` 而非 panic,调用方决定如何向用户报告错误。 +fn build_pg_config() -> Result { + let db_url = std::env::var("DATABASE_URL").map_err(|_| { + "DATABASE_URL environment variable not set".to_string() + })?; let mut pg_cfg = db_url .parse::() - .expect("Invalid DATABASE_URL format"); + .map_err(|e| format!("Invalid DATABASE_URL format: {e}"))?; // statement_timeout:防止单条慢查询(如全表扫搜索)长时间占用连接拖垮池。 - // 默认 30s,可由 STATEMENT_TIMEOUT_SECS 覆盖(L6)。 + // 默认 30s,可由 STATEMENT_TIMEOUT_SECS 覆盖。 let statement_timeout_secs = std::env::var("STATEMENT_TIMEOUT_SECS") .ok() .and_then(|s| s.parse::().ok()) @@ -28,6 +37,42 @@ pub static DB_POOL: LazyLock = LazyLock::new(|| { // 通过 libpq options 传递 GUC;tokio-postgres 在建连时执行。 pg_cfg.options(format!("-c statement_timeout={}", statement_timeout_secs * 1000)); + Ok(pg_cfg) +} + +/// 启动早期校验:`DATABASE_URL` 格式合法 + `DB_POOL_SIZE` 为正数。 +/// +/// 供 `main.rs` 在 `DB_POOL` LazyLock 被触碰之前调用,让 URL 格式错误、池大小非法 +/// 这类用户可修复的配置问题走统一友好的 `tracing::error!` + `exit(1)` 路径, +/// 而不是触发 LazyLock 闭包里的 `.expect()` panic。 +/// +/// 返回 `Err(String)` 时,字符串已是面向用户的错误描述。 +pub fn validate_database_url() -> Result<(), String> { + build_pg_config()?; + + // 同步校验池大小,避免 LazyLock 闭包里 `unwrap_or(20)` 静默吞掉非法值。 + if let Ok(s) = std::env::var("DB_POOL_SIZE") { + match s.parse::() { + Ok(n) if n > 0 => {} + Ok(_) => return Err("DB_POOL_SIZE is not a positive integer".to_string()), + Err(e) => return Err(format!("Invalid DB_POOL_SIZE value: {e}")), + } + } + Ok(()) +} + +/// 全局数据库连接池。 +/// +/// **不可达的防御性 panic**:`main.rs` 启动时已通过 `validate_database_url()` 前置校验 +/// `DATABASE_URL` 格式与 `DB_POOL_SIZE`,因此在真实运行路径上本闭包里的 `.expect()` +/// 永远不会触发。保留 `.expect()` 只是为了满足 `LazyLock` 必须返回 `T`(而非 `Result`) +/// 的类型约束——若这里真的 panic,说明 `validate_database_url` 与本闭包逻辑不一致, +/// 属于代码 bug 而非用户错误。 +pub static DB_POOL: LazyLock = LazyLock::new(|| { + // 前置校验已保证配置合法;闭包里直接 expect 以满足 LazyLock 的类型约束。 + let pg_cfg = build_pg_config() + .expect("DATABASE_URL should have been validated at startup; validate_database_url() was not called"); + // 使用 Fast 回收策略:归还连接时不额外发 SELECT 1 验证,直接复用。 // Verified 在高并发下会为每次 get() 增加一次往返;Fast 依赖 tokio-postgres // 在使用时自然报错,由 get_conn 的重试层兜底。 @@ -53,8 +98,12 @@ pub static DB_POOL: LazyLock = LazyLock::new(|| { /// 从全局连接池获取一个数据库连接,失败时按指数退避 + jitter 重试。 /// +/// 这是**运行期**获取连接的路径:反雪崩导向——快速失败(约 1.6s 后放弃), +/// 让上层限流兜底。**启动期**(迁移)请用 [`get_conn_for_startup`],它有一个 +/// 更长的可配置重试窗口,专为“DB 还没起来”的场景设计。 +/// /// 退避策略见 `retry::backoff_for`。仅对 Backend/Postgres 错误(DB 不可达)重试; -/// Timeout(池满)直接返回,让上层限流兜底,避免雪崩(L6)。 +/// Timeout(池满)直接返回,让上层限流兜底,避免雪崩。 /// 若所有重试均失败,返回最后一次的 PoolError。 pub async fn get_conn() -> Result { use rand::Rng; @@ -87,3 +136,57 @@ pub async fn get_conn() -> Result Result { + let timeout_secs = std::env::var("MIGRATE_STARTUP_TIMEOUT_SECS") + .ok() + .and_then(|s| s.parse::().ok()) + .unwrap_or(30); + + let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout_secs); + let retry_interval = Duration::from_millis(500); + + let mut attempt = 0u32; + loop { + attempt += 1; + match DB_POOL.get().await { + Ok(conn) => { + if attempt > 1 { + tracing::info!( + "connected to database after {} attempt(s)", + attempt + ); + } + return Ok(conn); + } + Err(e) => { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + return Err(e); + } + tracing::warn!( + "startup DB connection attempt {} failed, ~{}s remaining until giving up: {:?}", + attempt, + remaining.as_secs(), + e, + ); + // 不要睡过 deadline,避免超出用户配置的窗口。 + let sleep = std::cmp::min(retry_interval, remaining); + tokio::time::sleep(sleep).await; + } + } + } +} diff --git a/src/main.rs b/src/main.rs index 35a384e..188d690 100644 --- a/src/main.rs +++ b/src/main.rs @@ -203,11 +203,26 @@ fn main() { if std::env::var("DATABASE_URL").is_err() { tracing::error!("DATABASE_URL environment variable not set. Make sure .env exists or the variable is exported."); eprintln!("ERROR: DATABASE_URL environment variable not set"); + eprintln!("HINT: create a .env file with DATABASE_URL=postgres://user:pass@host:5432/dbname"); + std::process::exit(1); + } + + // 前置校验 DATABASE_URL 格式 + DB_POOL_SIZE,避免触发 DB_POOL LazyLock 闭包里 + // 不可达的 .expect() panic——让用户可修复的配置错误走统一友好的 exit(1) 路径。 + // 此处必须在任何 DB_POOL.get() 调用之前执行(即迁移之前)。 + if let Err(e) = db::pool::validate_database_url() { + tracing::error!("{e}"); + eprintln!("ERROR: {e}"); + if e.starts_with("DB_POOL_SIZE") { + eprintln!("HINT: DB_POOL_SIZE must be a positive integer (e.g. 20)."); + } else { + eprintln!("HINT: expected something like postgres://user:pass@host:5432/dbname"); + } std::process::exit(1); } // 启动前执行数据库迁移。阻塞:完成前不监听端口。 - // 失败直接 panic(expect),避免启动一个 schema 不一致的半残服务。 + // 失败用 exit(1) 退出(不 panic),避免启动一个 schema 不一致的半残服务。 // 多实例滚动发布时由咨询锁串行化,详见 src/db/migrate.rs。 // // main() 是同步函数,这里用一个独立的多线程 runtime 驱动迁移的异步逻辑, @@ -218,9 +233,32 @@ fn main() { .build() .expect("failed to build migration runtime"); migrate_rt.block_on(async { - db::migrate::run() - .await - .expect("Database migration failed on startup"); + tracing::info!("running database migrations"); + + // 启动期用长重试窗口拿连接:DB 可能还在初始化(docker-compose 无 healthcheck、 + // 本机忘启 Postgres 等)。窗口由 MIGRATE_STARTUP_TIMEOUT_SECS 控制,默认 30s。 + let mut conn = match db::pool::get_conn_for_startup().await { + Ok(conn) => conn, + Err(e) => { + let secs = std::env::var("MIGRATE_STARTUP_TIMEOUT_SECS") + .ok() + .and_then(|s| s.parse::().ok()) + .unwrap_or(30); + tracing::error!("could not connect to database within {secs}s startup window: {e}"); + eprintln!("ERROR: could not connect to database within {secs}s startup window: {e}"); + eprintln!("HINT: is PostgreSQL running and reachable at the configured DATABASE_URL?"); + eprintln!("HINT: raise MIGRATE_STARTUP_TIMEOUT_SECS if the DB needs longer to start."); + std::process::exit(1); + } + }; + + // 连接拿到后再执行迁移主体(咨询锁 + 建表 + 应用迁移)。 + if let Err(e) = db::migrate::run_on_conn(&mut conn).await { + tracing::error!("database migration failed: {e}"); + eprintln!("ERROR: database migration failed: {e}"); + eprintln!("HINT: check the logs above; verify DATABASE_URL and that PostgreSQL is healthy."); + std::process::exit(1); + } }); // 迁移 runtime 用完即弃,显式 drop 以在 serve() 前释放其线程资源。 drop(migrate_rt);