yggdrasil/src/db/migrate.rs
xfy b24cfdcabc 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).
2026-06-22 11:24:13 +08:00

314 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 数据库迁移运行器。
//!
//! 在服务器启动时(`dioxus::server::serve()` 之前)自动执行迁移。
//! 设计要点:
//! - 迁移 SQL 通过 `include_str!` 内联进二进制,部署只需单个二进制。
//! - `schema_migrations` 表记录已应用版本,避免重复执行。
//! - 每个迁移在独立事务里执行,失败自动回滚,版本行不会写入。
//! - 咨询锁(`pg_advisory_lock`)保证多实例启动时只有一个进程执行迁移。
//!
//! 仅在 `feature = "server"` 时编译。
use std::collections::HashSet;
/// 咨询锁的固定 key。Postgres 咨询锁是数据库级唯一的;
/// 这里用一个项目专属的大整数,避免与同库其它应用冲突。
/// 该值无语义,仅要求唯一性。
const ADVISORY_LOCK_KEY: i64 = 0x5947_4752_4153_494C;
/// 所有迁移的 (version, sql) 列表,按 version 升序排列。
///
/// 新增迁移时:
/// 1. 在 `migrations/` 下创建 `NNN_描述.sql`
/// 2. 在本数组末尾追加一行 `("NNN", include_str!("../../migrations/NNN_描述.sql"))`
///
/// version 用字符串而非整数,与文件名前缀直接对应,且未来可支持日期戳/语义版本。
const MIGRATIONS: &[(&str, &str)] = &[
("001", include_str!("../../migrations/001_init.sql")),
("002", include_str!("../../migrations/002_posts.sql")),
("003", include_str!("../../migrations/003_indexes.sql")),
("004", include_str!("../../migrations/004_search_trgm.sql")),
("005", include_str!("../../migrations/005_comments.sql")),
("006", include_str!("../../migrations/006_add_toc_html.sql")),
("007", include_str!("../../migrations/007_settings.sql")),
("008", include_str!("../../migrations/008_comments_cascade.sql")),
(
"009",
include_str!("../../migrations/009_cleanup_duplicate_indexes.sql"),
),
(
"010",
include_str!("../../migrations/010_post_word_counts.sql"),
),
("011", include_str!("../../migrations/011_perf_indexes.sql")),
(
"012",
include_str!("../../migrations/012_session_generation.sql"),
),
(
"013",
include_str!("../../migrations/013_comment_content_hash_index.sql"),
),
(
"014",
include_str!("../../migrations/014_drop_ineffective_trgm_index.sql"),
),
// 新增迁移在此追加,同时在 migrations/ 下创建对应 .sql 文件。
];
/// 迁移执行错误。
#[derive(Debug)]
pub enum MigrateError {
/// 无法从连接池获取连接。
Pool(deadpool_postgres::PoolError),
/// 执行查询(建表、查版本、咨询锁等)失败。
Query(tokio_postgres::Error),
/// 某个具体迁移执行失败(包含版本号便于定位)。
Apply {
version: String,
source: tokio_postgres::Error,
},
}
impl From<deadpool_postgres::PoolError> for MigrateError {
fn from(e: deadpool_postgres::PoolError) -> Self {
MigrateError::Pool(e)
}
}
impl From<tokio_postgres::Error> for MigrateError {
fn from(e: tokio_postgres::Error) -> Self {
MigrateError::Query(e)
}
}
impl std::fmt::Display for MigrateError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
MigrateError::Pool(e) => write!(f, "database pool error: {}", e),
MigrateError::Query(e) => write!(f, "database query error: {}", e),
MigrateError::Apply { version, source } => {
write!(f, "migration {} failed: {}", version, source)
}
}
}
}
impl std::error::Error for MigrateError {}
/// 在**已获取**的连接上执行迁移主体逻辑(咨询锁 + 建表 + 应用迁移 + 解锁)。
///
/// 调用方负责自行控制连接获取策略——例如 `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_on_conn(conn: &mut deadpool_postgres::Object) -> Result<(), MigrateError> {
// 抢咨询锁:多实例滚动发布时只有一个进程能进入迁移循环,
// 其余实例在此等待;锁释放后它们查版本表发现已全部应用,直接返回。
conn.execute("SELECT pg_advisory_lock($1)", &[&ADVISORY_LOCK_KEY])
.await?;
// 在已持锁连接上执行迁移主体逻辑。
// 锁释放策略:
// - 正常返回 / 返回 Err下面的显式 pg_advisory_unlock 释放锁。
// - 进程被强杀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
.execute("SELECT pg_advisory_unlock($1)", &[&ADVISORY_LOCK_KEY])
.await
{
tracing::warn!("failed to release migration advisory lock: {}", unlock_err);
}
result
}
/// 在已持有咨询锁的连接上执行迁移主体逻辑。
async fn run_inner(conn: &mut deadpool_postgres::Object) -> Result<(), MigrateError> {
// 确保版本表存在(独立语句,不在事务里,否则建表失败无法记录)。
ensure_versions_table(conn).await?;
// 查询已应用的版本集合。
let applied = applied_versions(conn).await?;
// 按序应用未应用的迁移。
let mut applied_count = 0usize;
for (version, sql) in MIGRATIONS {
if applied.contains(*version) {
continue;
}
tracing::info!("applying migration {}", version);
apply_one(conn, version, sql).await?;
applied_count += 1;
}
if applied_count == 0 {
tracing::info!("database is up to date, 0 migrations applied");
} else {
tracing::info!("successfully applied {} migration(s)", applied_count);
}
Ok(())
}
/// 创建 `schema_migrations` 表(若不存在)。
async fn ensure_versions_table(conn: &deadpool_postgres::Object) -> Result<(), MigrateError> {
conn.batch_execute(
"CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)",
)
.await?;
Ok(())
}
/// 查询已应用的版本集合。
async fn applied_versions(
conn: &deadpool_postgres::Object,
) -> Result<HashSet<String>, MigrateError> {
let rows = conn.query("SELECT version FROM schema_migrations", &[]).await?;
let mut set = HashSet::with_capacity(rows.len());
for row in rows {
set.insert(row.get::<_, String>(0));
}
Ok(set)
}
/// 在一个事务内应用单个迁移:执行 SQL + 写入版本行,失败则回滚。
async fn apply_one(
conn: &mut deadpool_postgres::Object,
version: &str,
sql: &str,
) -> Result<(), MigrateError> {
let tx = conn.transaction().await.map_err(MigrateError::Query)?;
// batch_execute 执行整段 SQL可含多条语句
if let Err(e) = tx.batch_execute(sql).await {
// 显式回滚以尽早释放事务(而非等 Transaction drop 的隐式回滚);
// 回滚本身的错误丢弃,因为已有更具信息量的 apply 错误要上报。
let _ = tx.rollback().await;
return Err(MigrateError::Apply {
version: version.to_string(),
source: e,
});
}
// 记录版本行。显式构造 Apply 错误(不能用 ?,否则会被 blanket From 映射成 Query
if let Err(e) = tx
.execute(
"INSERT INTO schema_migrations (version) VALUES ($1)",
&[&version],
)
.await
{
let _ = tx.rollback().await;
return Err(MigrateError::Apply {
version: version.to_string(),
source: e,
});
}
tx.commit().await.map_err(|e| MigrateError::Apply {
version: version.to_string(),
source: e,
})?;
Ok(())
}
#[cfg(all(test, feature = "server"))]
mod tests {
use super::*;
#[test]
fn migrations_are_sorted_ascending() {
let mut sorted = MIGRATIONS.iter().map(|(v, _)| *v).collect::<Vec<_>>();
sorted.sort_unstable();
let original: Vec<&str> = MIGRATIONS.iter().map(|(v, _)| *v).collect();
assert_eq!(original, sorted, "MIGRATIONS must be in ascending version order");
}
#[test]
fn migrations_have_unique_versions() {
let mut versions: Vec<&str> = MIGRATIONS.iter().map(|(v, _)| *v).collect();
let total = versions.len();
versions.sort_unstable();
versions.dedup();
assert_eq!(versions.len(), total, "MIGRATIONS has duplicate version strings");
}
#[test]
fn migrations_non_empty() {
assert!(!MIGRATIONS.is_empty(), "MIGRATIONS must not be empty");
}
/// 防止"新建了 .sql 但忘记在 MIGRATIONS 加行"的脚枪。
/// 扫描 migrations/ 目录,断言每个 .sql 文件都在 MIGRATIONS 里有对应版本行。
/// 仅在 server feature + test 下运行WASM 无文件系统)。
#[test]
fn migrations_match_files_on_disk() {
use std::collections::HashSet;
use std::fs;
// CARGO_MANIFEST_DIR 指向 crate 根目录yggdrasil/)。
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let migrations_dir = std::path::Path::new(manifest_dir).join("migrations");
let mut files_on_disk: HashSet<String> = HashSet::new();
for entry in fs::read_dir(&migrations_dir)
.unwrap_or_else(|e| panic!("failed to read {}: {}", migrations_dir.display(), e))
{
let entry = entry.unwrap();
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) == Some("sql") {
let filename = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_else(|| panic!("non-utf8 filename: {}", path.display()));
// 文件名形如 "001_init.sql",取前 3 位数字作为 version。
let version = filename
.split('_')
.next()
.unwrap_or_else(|| panic!("filename has no '_' separator: {}", filename));
files_on_disk.insert(version.to_string());
}
}
let versions_in_array: HashSet<String> =
MIGRATIONS.iter().map(|(v, _)| v.to_string()).collect();
// 磁盘上有但数组里没有 → 忘记加行(会静默不执行该迁移)。
let missing_in_array: Vec<&String> =
files_on_disk.difference(&versions_in_array).collect();
assert!(
missing_in_array.is_empty(),
"migrations/*.sql files not registered in MIGRATIONS: {:?}. \
Add a row for each in src/db/migrate.rs.",
missing_in_array
);
// 数组里有但磁盘上没有 → include_str! 本就会编译失败,这里只是双保险。
let missing_on_disk: Vec<&String> =
versions_in_array.difference(&files_on_disk).collect();
assert!(
missing_on_disk.is_empty(),
"MIGRATIONS rows without a corresponding .sql file: {:?}",
missing_on_disk
);
}
}