fix(db): 启动时对齐被改写过的迁移校验和,1.2.4 的老库不再无法启动 (#1703)

* fix(db): 启动时对齐被改写过的迁移校验和,老库不再无法启动

1.2.5 就地修改了已经发布的迁移 2(#1684 把 SET tags = null 改成 SET tags = '[]')。
sqlx 会用文件内容的 SHA-384 校验每条已应用的迁移,于是 1.1.8 ~ 1.2.4 期间建立的
数据库升级后一律以 VersionMismatch 整体失败:进程退出码 1,容器反复重启。

在跑迁移之前加一步对齐:只有当 _sqlx_migrations 里记的校验和恰好等于登记在册的
历史摘要时,才在同一个事务里补跑新旧内容的差异(幂等 UPDATE)并改写校验和。
其余任何失配仍按原样报错,不会把用户自己改过的迁移悄悄放行。

* test(db): 钉死已发布迁移的校验和,防止再次就地改写

这次故障的根因是改了一条已经发布过的迁移。加一条断言把迁移 1..5 的 SHA-384 写死:
再有人就地改动已发布的迁移,CI 当场失败,而不是等用户升级时容器起不来。
This commit is contained in:
Noah authored and GitHub committed 2026-09-21 02:48:33 +00:00
1 parent 9d02856a50
commit 4e156ac683
1 file changed
+286 -8
@@ -1,13 +1,45 @@
use crate::server::errors::{AppError, AppResult};
use error_stack::ResultExt;
use sqlx::migrate::Migrator;
use sqlx::sqlite::SqlitePoolOptions;
use sqlx::{Pool, Sqlite};
use std::fmt::Write as _;
use std::path::Path;
use tracing::info;
use tracing::{info, warn};
/// SQLite连接池类型别名
pub type ConnectionPool = Pool<Sqlite>;
/// 一条曾经被就地改写过的迁移。
///
/// `legacy_checksum` 是历史版本里该文件内容的 SHA-384(sqlx 记在 `_sqlx_migrations`
/// 里的那一列),`repair` 是把旧内容的执行效果补成新内容所需的语句。
struct SupersededMigration {
version: i64,
legacy_checksum: &'static str,
/// 必须幂等:老库可能已经处于新内容期望的状态,也可能重复启动多次。
repair: &'static [&'static str],
}
/// 迁移文件是只读历史:sqlx 会用文件内容的 SHA-384 校验每条已应用的迁移,一旦
/// 就地改写,所有老库都会在启动时以
/// `migration N was previously applied but has been modified` 整体失败 —— 服务起不来,
/// 容器无限重启(#1701 / #1702)。修 SQL 的正确做法始终是新增一个迁移文件。
///
/// 这张表只给**已经发生过**的改写兜底,不是继续改写的许可。登记一条的前提是:
/// 对已经跑过旧内容的库来说,新旧内容的差异能用下面这组幂等语句补齐。
const SUPERSEDED_MIGRATIONS: &[SupersededMigration] = &[
// 迁移 2 原本写 `UPDATE uploadstreamers SET tags = null`,而该列是 JSON NOT NULL,
// 从 Python 版升级且 tags 为空串的实例会卡死在这一步;v1.2.5 把它改成了
// `SET tags = '[]'`(#1684)。改动本身是对的,但让 v1.2.4 及更早版本建立的库
// 全部校验失配。两版内容的唯一差异就是这一条 UPDATE。
SupersededMigration {
version: 2,
legacy_checksum: "fcc6436a889297e5c28f2a0f12196e5eee5975ba352032c8201dfa61fb1b9c3fd01ddc41ddfa71bd7423999521b895eb",
repair: &["UPDATE uploadstreamers SET tags = '[]' WHERE tags = '' OR tags = 'null'"],
},
];
/// 连接管理器
/// 负责管理SQLite数据库连接池的创建和配置
pub struct ConnectionManager;
@@ -48,21 +80,267 @@ impl ConnectionManager {
))?;
// 运行数据库迁移,确保数据库结构是最新的
let migrator = sqlx::migrate!();
Self::reconcile_superseded_migrations(&pool, &migrator).await?;
info!("migrations enabled, running...");
sqlx::migrate!()
.run(&pool)
.await
.change_context(AppError::Custom(
"error while running database migrations".to_string(),
))?;
migrator.run(&pool).await.change_context(AppError::Custom(
"error while running database migrations".to_string(),
))?;
Ok(pool)
}
/// 把历史上被改写过的迁移记录对齐到当前文件内容,让老库还能继续升级。
///
/// 只在记录里的校验和**恰好等于**登记过的历史摘要时才动手;其余任何失配都原样
/// 留给 sqlx 报错,免得把用户自己改过的迁移悄悄放行。补丁语句与校验和改写在同一
/// 个事务里,中途崩溃不会留下「补丁没跑但校验和已对齐」的中间态。
async fn reconcile_superseded_migrations(
pool: &ConnectionPool,
migrator: &Migrator,
) -> AppResult<()> {
let bookkeeping_exists: Option<String> = sqlx::query_scalar(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = '_sqlx_migrations'",
)
.fetch_optional(pool)
.await
.change_context(AppError::Custom(
"error while inspecting database migration state".to_string(),
))?;
// 全新安装:连记账表都还没有,没有任何历史需要对齐。
if bookkeeping_exists.is_none() {
return Ok(());
}
for entry in SUPERSEDED_MIGRATIONS {
let Some(current) = migrator.iter().find(|m| m.version == entry.version) else {
continue;
};
let applied: Option<(Vec<u8>, bool)> =
sqlx::query_as("SELECT checksum, success FROM _sqlx_migrations WHERE version = ?")
.bind(entry.version)
.fetch_optional(pool)
.await
.change_context(AppError::Custom(
"error while inspecting database migration state".to_string(),
))?;
// 没跑过(含跑失败留下的记录)的迁移由 sqlx 正常应用,不需要对齐。
let Some((checksum, true)) = applied else {
continue;
};
if checksum == current.checksum.as_ref() {
continue;
}
if hex_lower(&checksum) != entry.legacy_checksum {
warn!(
version = entry.version,
"已应用的迁移既不是当前内容也不是已知的历史内容,不做自动对齐"
);
continue;
}
let mut tx = pool.begin().await.change_context(AppError::Custom(
"error while reconciling database migration state".to_string(),
))?;
for statement in entry.repair {
sqlx::query(statement)
.execute(&mut *tx)
.await
.change_context(AppError::Custom(
"error while reconciling database migration state".to_string(),
))?;
}
sqlx::query("UPDATE _sqlx_migrations SET checksum = ? WHERE version = ?")
.bind(current.checksum.to_vec())
.bind(entry.version)
.execute(&mut *tx)
.await
.change_context(AppError::Custom(
"error while reconciling database migration state".to_string(),
))?;
tx.commit().await.change_context(AppError::Custom(
"error while reconciling database migration state".to_string(),
))?;
info!(
version = entry.version,
"该迁移在新版本中被修正,已补跑差异并对齐校验和"
);
}
Ok(())
}
}
fn hex_lower(bytes: &[u8]) -> String {
bytes
.iter()
.fold(String::with_capacity(bytes.len() * 2), |mut out, byte| {
let _ = write!(out, "{byte:02x}");
out
})
}
#[cfg(test)]
mod tests {
use super::ConnectionManager;
use super::{ConnectionManager, ConnectionPool, SUPERSEDED_MIGRATIONS, hex_lower};
fn decode_hex(hex: &str) -> Vec<u8> {
(0..hex.len())
.step_by(2)
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16).unwrap())
.collect()
}
/// 把一个全新库回退成 v1.2.4 那一代的样子:迁移 4/5 尚未应用,迁移 2 记的是
/// 改写前的校验和。
async fn rewind_to_v1_2_4(pool: &ConnectionPool, migration_2_checksum: &[u8]) {
sqlx::query("DELETE FROM _sqlx_migrations WHERE version IN (4, 5)")
.execute(pool)
.await
.unwrap();
sqlx::query("ALTER TABLE uploadstreamers DROP COLUMN tid_v2")
.execute(pool)
.await
.unwrap();
sqlx::query("UPDATE _sqlx_migrations SET checksum = ? WHERE version = 2")
.bind(migration_2_checksum.to_vec())
.execute(pool)
.await
.unwrap();
}
/// 已发布迁移的内容摘要,逐条钉死。
///
/// 这个断言挂掉说明有人改动了一条**已经发布过**的迁移 —— 所有老库都会在升级时
/// 以校验和失配启动失败(#1701 就是这么来的)。正确做法是把改动挪进一个新的迁移
/// 文件;确实只能就地改的话,把旧摘要连同幂等补丁登记进 `SUPERSEDED_MIGRATIONS`,
/// 再更新这里。
#[test]
fn shipped_migration_checksums_are_frozen() {
const FROZEN: &[(i64, &str)] = &[
(
1,
"f0dbce08e61eb0722d99ea5415742b6786a00904aa87bf1d9c21eceabced7484e340c4b51185546cb0578106bbb0c865",
),
(
2,
"aef99c14523e93ceed5602d79b6c40c61891c794ea7f5fe8583ec1858315f57afeebfcaf9167a108ba7589745d7e34bf",
),
(
3,
"bda5d2a2757539c071a1887ffba044a2660dd03200680d9f8537546680556c987047422c074bd438ecb7ef25a73be51b",
),
(
4,
"8c93f71ec95e78418443f93395375eaeb9e0f16cf666fec555aa3165e73faa75e9a336794e292556d8d717a979085aaa",
),
(
5,
"f6f913ecbbdf1f4d437bf2ce4778b8d0317353056c4b240795fc9347b4eeac7f71e719d4c799ac2df72b06802ee064c7",
),
];
let embedded = sqlx::migrate!();
let actual: Vec<(i64, String)> = embedded
.iter()
.map(|m| (m.version, hex_lower(&m.checksum)))
.collect();
let expected: Vec<(i64, String)> =
FROZEN.iter().map(|(v, c)| (*v, (*c).to_string())).collect();
assert_eq!(actual, expected);
}
/// #1701:v1.2.5 就地改写了迁移 2,所有 v1.2.4 及更早版本建立的库都会以
/// `migration 2 was previously applied but has been modified` 启动失败。
/// 升级必须照常完成:待应用的迁移要跑完,既有数据不能丢。
#[tokio::test]
async fn upgrade_from_pre_1_2_5_database_reconciles_rewritten_migration() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("data.sqlite3");
let pool = ConnectionManager::new_pool(db.to_str().unwrap())
.await
.unwrap();
rewind_to_v1_2_4(&pool, &decode_hex(SUPERSEDED_MIGRATIONS[0].legacy_checksum)).await;
sqlx::query(
"INSERT INTO uploadstreamers (id, template_name, tags) VALUES \
(1, 'from-python', ''), (2, 'normal', '[\"直播录像\"]')",
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO livestreamers (url, remark, override) VALUES \
('https://live.bilibili.com/1', 'placeholder', '{\"file_size\":null}')",
)
.execute(&pool)
.await
.unwrap();
pool.close().await;
let pool = ConnectionManager::new_pool(db.to_str().unwrap())
.await
.expect("v1.2.4 建立的库必须能升级上来");
let migrations: Vec<(i64, Vec<u8>)> =
sqlx::query_as("SELECT version, checksum FROM _sqlx_migrations ORDER BY version")
.fetch_all(&pool)
.await
.unwrap();
assert_eq!(
migrations.iter().map(|m| m.0).collect::<Vec<_>>(),
vec![1, 2, 3, 4, 5],
"待应用的迁移必须补齐"
);
let embedded = sqlx::migrate!();
let expected = embedded.iter().find(|m| m.version == 2).unwrap();
assert_eq!(
hex_lower(&migrations[1].1),
hex_lower(&expected.checksum),
"迁移 2 的校验和必须对齐到当前文件内容"
);
let tags: Vec<(i64, String)> =
sqlx::query_as("SELECT id, tags FROM uploadstreamers ORDER BY id")
.fetch_all(&pool)
.await
.unwrap();
assert_eq!(tags[0].1, "[]", "空 tags 必须被补成空数组,而不是 NULL");
assert_eq!(tags[1].1, "[\"直播录像\"]", "正常数据原样保留");
// 迁移 4/5 确实跑过:占位 null 被清掉,tid_v2 列已存在。
let override_json: String = sqlx::query_scalar("SELECT override FROM livestreamers")
.fetch_one(&pool)
.await
.unwrap();
assert!(!override_json.contains("file_size"), "{override_json}");
sqlx::query("SELECT tid_v2 FROM uploadstreamers")
.fetch_all(&pool)
.await
.expect("迁移 5 必须已应用");
}
/// 自愈只针对登记在册的历史内容。用户自己改过的迁移仍然要报错,不能被悄悄放行。
#[tokio::test]
async fn unknown_migration_checksum_is_not_silently_reconciled() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("data.sqlite3");
let pool = ConnectionManager::new_pool(db.to_str().unwrap())
.await
.unwrap();
sqlx::query("UPDATE _sqlx_migrations SET checksum = ? WHERE version = 2")
.bind(vec![0u8; 48])
.execute(&pool)
.await
.unwrap();
pool.close().await;
assert!(
ConnectionManager::new_pool(db.to_str().unwrap())
.await
.is_err()
);
}
/// 旧版本落库的覆写里 `file_size: null` 只是占位,迁移后必须消失(跟随全局),
/// 其它显式设置的字段与真正的数值原样保留。