fix(startup): replace migration panic with friendly exit + configurable retry window

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).
This commit is contained in:
xfy 2026-06-22 11:24:13 +08:00
parent 67212a52b3
commit b24cfdcabc
5 changed files with 179 additions and 34 deletions

View File

@ -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

View File

@ -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
```

View File

@ -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 释放锁。
// - panicpanic 会跳过显式 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

View File

@ -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<Pool> = 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<tokio_postgres::Config, String> {
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::<tokio_postgres::Config>()
.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::<u32>().ok())
@ -28,6 +37,42 @@ pub static DB_POOL: LazyLock<Pool> = LazyLock::new(|| {
// 通过 libpq options 传递 GUCtokio-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::<usize>() {
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<Pool> = 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<Pool> = 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<deadpool_postgres::Object, deadpool_postgres::PoolError> {
use rand::Rng;
@ -87,3 +136,57 @@ pub async fn get_conn() -> Result<deadpool_postgres::Object, deadpool_postgres::
}
Err(last_err.unwrap())
}
/// 启动期专用在可配置的时间窗口内反复尝试连接数据库专治“DB 还没起来”。
///
/// 与运行期的 [`get_conn`] 区别:
/// - **没有反雪崩约束**:启动时只有这一个进程在连,不会雪崩,可以放心长重试。
/// - **固定间隔重试**(而非指数退避):启动场景下 DB 要么起来要么没起来,
/// 固定 500ms 轮询比指数退避更可预测,也更贴合 `pg_isready` 式的等待语义。
/// - **以总时长为终止条件**(而非次数):对运维更直观——"给 DB 30 秒起来"。
///
/// 超时窗口由 `MIGRATE_STARTUP_TIMEOUT_SECS` 控制,默认 30 秒。窗口用尽后返回
/// 最后一次错误,由调用方(`main.rs`)决定如何向用户报告。
///
/// 适用 docker-compose无 healthcheck、本机忘启 Postgres 等“DB 起得比 app 慢”的场景。
pub async fn get_conn_for_startup(
) -> Result<deadpool_postgres::Object, deadpool_postgres::PoolError> {
let timeout_secs = std::env::var("MIGRATE_STARTUP_TIMEOUT_SECS")
.ok()
.and_then(|s| s.parse::<u64>().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;
}
}
}
}

View File

@ -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);
}
// 启动前执行数据库迁移。阻塞:完成前不监听端口。
// 失败直接 panicexpect避免启动一个 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::<u64>().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);