Skip to main content

tdm_server_rust/runner/
schema_migration_runner.rs

1//! PostgreSQL 启动迁移。
2//!
3//! 迁移在数据库连接建立后、RSS 和缓存预热启动前执行,避免新字段未创建时业务任务先查询失败。
4
5use sqlx::PgPool;
6
7/// 旧库接入迁移系统时跳过的历史迁移版本上限。
8const LEGACY_BOOTSTRAP_CUTOFF_VERSION: &str = "202606181200";
9/// 去重迁移之前的已知结构版本,用于兼容没有迁移历史的现有数据库。
10const PREVIOUS_MIGRATION_VERSION: &str = "202607171900";
11/// 当前编译进服务的最新迁移版本。
12const LATEST_MIGRATION_VERSION: &str = "202607190130";
13
14/// 单个内置迁移脚本。
15struct Migration {
16    /// 迁移版本号,来自文件名前缀。
17    version: &'static str,
18    /// 迁移名称,来自文件名说明段。
19    name: &'static str,
20    /// 迁移 SQL 内容。
21    sql: &'static str,
22}
23
24/// 编译进二进制的 PostgreSQL 迁移列表。
25const MIGRATIONS: &[Migration] = &[
26    Migration {
27        version: "202606170000",
28        name: "postgresql_baseline",
29        sql: include_str!("../../migrations/V202606170000__postgresql_baseline.sql"),
30    },
31    Migration {
32        version: "202606171200",
33        name: "Add_select_query_indexes",
34        sql: include_str!("../../migrations/V202606171200__Add_select_query_indexes.sql"),
35    },
36    Migration {
37        version: "202606171400",
38        name: "Add_pg_trgm_fuzzy_indexes",
39        sql: include_str!("../../migrations/V202606171400__Add_pg_trgm_fuzzy_indexes.sql"),
40    },
41    Migration {
42        version: "202606181200",
43        name: "Fix_unpublished_episode_indexes",
44        sql: include_str!("../../migrations/V202606181200__Fix_unpublished_episode_indexes.sql"),
45    },
46    Migration {
47        version: "202606201000",
48        name: "Add_editor_tables",
49        sql: include_str!("../../migrations/V202606201000__Add_editor_tables.sql"),
50    },
51    Migration {
52        version: "202606201300",
53        name: "Add_member_avatar_url",
54        sql: include_str!("../../migrations/V202606201300__Add_member_avatar_url.sql"),
55    },
56    Migration {
57        version: "202606201500",
58        name: "Add_global_search_trgm_indexes",
59        sql: include_str!("../../migrations/V202606201500__Add_global_search_trgm_indexes.sql"),
60    },
61    Migration {
62        version: "202606201610",
63        name: "Create_episode_role_taker",
64        sql: include_str!("../../migrations/V202606201610__Create_episode_role_taker.sql"),
65    },
66    Migration {
67        version: "202606211420",
68        name: "Fix_editor_page_id_bigint",
69        sql: include_str!("../../migrations/V202606211420__Fix_editor_page_id_bigint.sql"),
70    },
71    Migration {
72        version: "202606211500",
73        name: "Fix_editor_timestamp_utc",
74        sql: include_str!("../../migrations/V202606211500__Fix_editor_timestamp_utc.sql"),
75    },
76    Migration {
77        version: "202606211510",
78        name: "Force_editor_timestamp_utc",
79        sql: include_str!("../../migrations/V202606211510__Force_editor_timestamp_utc.sql"),
80    },
81    Migration {
82        version: "202607020900",
83        name: "Create_gray_release_tables",
84        sql: include_str!("../../migrations/V202607020900__Create_gray_release_tables.sql"),
85    },
86    Migration {
87        version: "202607171900",
88        name: "Add_episode_type",
89        sql: include_str!("../../migrations/V202607171900__Add_episode_type.sql"),
90    },
91    Migration {
92        version: "202607190130",
93        name: "Deduplicate_workreminder",
94        sql: include_str!("../../migrations/V202607190130__Deduplicate_workreminder.sql"),
95    },
96];
97
98/// 执行所有待应用迁移。
99pub async fn run(pool: &PgPool) -> anyhow::Result<()> {
100    ensure_migration_table(pool).await?;
101
102    bootstrap_existing_database(pool).await?;
103
104    for migration in MIGRATIONS {
105        if is_applied(pool, migration.version).await? {
106            continue;
107        }
108        apply_migration(pool, migration).await?;
109    }
110
111    Ok(())
112}
113
114/// 确保迁移记录表存在。
115async fn ensure_migration_table(pool: &PgPool) -> anyhow::Result<()> {
116    sqlx::query(
117        r#"
118        CREATE TABLE IF NOT EXISTS __tdm_schema_migrations (
119            version TEXT PRIMARY KEY,
120            name TEXT NOT NULL,
121            applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
122        )
123        "#,
124    )
125    .execute(pool)
126    .await?;
127    Ok(())
128}
129
130/// 为没有迁移历史的既有结构补记版本。
131async fn bootstrap_existing_database(pool: &PgPool) -> anyhow::Result<()> {
132    let applied_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM __tdm_schema_migrations")
133        .fetch_one(pool)
134        .await?;
135
136    let membertb: Option<String> =
137        sqlx::query_scalar("SELECT to_regclass('public.membertb')::text")
138            .fetch_one(pool)
139            .await?;
140    let core_schema_present = membertb.is_some();
141
142    // Docker 首次初始化会直接执行 migrations/*.sql,但不会创建 Flyway 历史表。
143    let previous_schema_present: bool = sqlx::query_scalar(
144        r#"
145        SELECT EXISTS (
146            SELECT 1
147            FROM information_schema.columns
148            WHERE table_schema = 'public'
149              AND table_name = 'mangaepisodetb'
150              AND column_name = 'episodeType'
151        )
152        "#,
153    )
154    .fetch_one(pool)
155    .await?;
156
157    // 最新唯一索引存在时,说明排序在它之前的脚本也已执行,可安全补记全部版本。
158    let latest_schema_present: bool =
159        sqlx::query_scalar("SELECT to_regclass('public.uk_workreminder_episode_post') IS NOT NULL")
160            .fetch_one(pool)
161            .await?;
162
163    if let Some(cutoff) = bootstrap_cutoff(
164        applied_count,
165        core_schema_present,
166        previous_schema_present,
167        latest_schema_present,
168    ) {
169        mark_history_through(pool, cutoff).await?;
170    }
171
172    Ok(())
173}
174
175fn bootstrap_cutoff(
176    applied_count: i64,
177    core_schema_present: bool,
178    previous_schema_present: bool,
179    latest_schema_present: bool,
180) -> Option<&'static str> {
181    if !core_schema_present {
182        None
183    } else if latest_schema_present {
184        Some(LATEST_MIGRATION_VERSION)
185    } else if applied_count > 0 {
186        None
187    } else if previous_schema_present {
188        Some(PREVIOUS_MIGRATION_VERSION)
189    } else {
190        Some(LEGACY_BOOTSTRAP_CUTOFF_VERSION)
191    }
192}
193
194/// 旧库已有基线表结构,历史基线和索引迁移只登记,不重复执行。
195async fn mark_history_through(pool: &PgPool, cutoff_version: &str) -> anyhow::Result<()> {
196    for migration in MIGRATIONS {
197        if migration.version <= cutoff_version {
198            mark_applied(pool, migration).await?;
199        }
200    }
201    tracing::info!("既有数据库迁移记录初始化完成 cutoff={cutoff_version}");
202    Ok(())
203}
204
205/// 查询迁移是否已应用(同时检查应用内嵌迁移表和 Flyway 历史表)。
206async fn is_applied(pool: &PgPool, version: &str) -> anyhow::Result<bool> {
207    // 先查应用自己的迁移记录表
208    let app_applied: Option<String> =
209        sqlx::query_scalar("SELECT version FROM __tdm_schema_migrations WHERE version = $1")
210            .bind(version)
211            .fetch_optional(pool)
212            .await?;
213    if app_applied.is_some() {
214        return Ok(true);
215    }
216
217    let flyway_history_exists: bool =
218        sqlx::query_scalar("SELECT to_regclass('flyway_schema_history') IS NOT NULL")
219            .fetch_one(pool)
220            .await?;
221    if !flyway_history_exists {
222        return Ok(false);
223    }
224
225    // 再查 Flyway 历史表(deploy 脚本通过 Flyway 以 superuser 运行迁移,
226    // 应用以 app user 连接时无法 REPLACE superuser 拥有的函数,必须跳过)
227    let flyway_applied: Option<String> = sqlx::query_scalar(
228        "SELECT version FROM flyway_schema_history WHERE version = $1 AND success = true",
229    )
230    .bind(version)
231    .fetch_optional(pool)
232    .await?;
233    if flyway_applied.is_some() {
234        // 同步到应用迁移表,避免后续重复检查 Flyway
235        sqlx::query(
236            "INSERT INTO __tdm_schema_migrations(version, name) VALUES ($1, $2) ON CONFLICT (version) DO NOTHING",
237        )
238        .bind(version)
239        .bind("synced_from_flyway")
240        .execute(pool)
241        .await?;
242        return Ok(true);
243    }
244    Ok(false)
245}
246
247/// 执行迁移并记录版本。
248async fn apply_migration(pool: &PgPool, migration: &Migration) -> anyhow::Result<()> {
249    tracing::info!(
250        "开始执行数据库迁移 version={} name={}",
251        migration.version,
252        migration.name
253    );
254    let mut tx = pool.begin().await?;
255    sqlx::raw_sql(migration.sql).execute(&mut *tx).await?;
256    sqlx::query(
257        r#"
258        INSERT INTO __tdm_schema_migrations(version, name)
259        VALUES ($1, $2)
260        ON CONFLICT (version) DO NOTHING
261        "#,
262    )
263    .bind(migration.version)
264    .bind(migration.name)
265    .execute(&mut *tx)
266    .await?;
267    tx.commit().await?;
268    tracing::info!(
269        "数据库迁移执行完成 version={} name={}",
270        migration.version,
271        migration.name
272    );
273    Ok(())
274}
275
276/// 仅记录迁移版本。
277async fn mark_applied(pool: &PgPool, migration: &Migration) -> anyhow::Result<()> {
278    sqlx::query(
279        r#"
280        INSERT INTO __tdm_schema_migrations(version, name)
281        VALUES ($1, $2)
282        ON CONFLICT (version) DO NOTHING
283        "#,
284    )
285    .bind(migration.version)
286    .bind(migration.name)
287    .execute(pool)
288    .await?;
289    Ok(())
290}
291
292#[cfg(test)]
293mod tests {
294    use super::{
295        bootstrap_cutoff, LATEST_MIGRATION_VERSION, LEGACY_BOOTSTRAP_CUTOFF_VERSION, MIGRATIONS,
296        PREVIOUS_MIGRATION_VERSION,
297    };
298
299    #[test]
300    fn bootstrap_cutoff_matches_database_shape() {
301        assert_eq!(bootstrap_cutoff(0, false, false, false), None);
302        assert_eq!(
303            bootstrap_cutoff(0, true, false, false),
304            Some(LEGACY_BOOTSTRAP_CUTOFF_VERSION)
305        );
306        assert_eq!(
307            bootstrap_cutoff(0, true, true, false),
308            Some(PREVIOUS_MIGRATION_VERSION)
309        );
310        assert_eq!(
311            bootstrap_cutoff(0, true, true, true),
312            Some(LATEST_MIGRATION_VERSION)
313        );
314        assert_eq!(
315            bootstrap_cutoff(4, true, true, true),
316            Some(LATEST_MIGRATION_VERSION)
317        );
318        assert_eq!(bootstrap_cutoff(4, true, true, false), None);
319    }
320
321    /// 确保最新去重迁移已编译进启动迁移清单。
322    #[test]
323    fn latest_migration_version_matches_embedded_list() {
324        assert_eq!(LATEST_MIGRATION_VERSION, "202607190130");
325        assert_eq!(
326            MIGRATIONS.last().map(|migration| migration.version),
327            Some("202607190130")
328        );
329    }
330
331    /// 断言去重迁移在清理和建索引前取得写入阻断锁。
332    fn assert_migration_lock_order(sql: &str, lock_marker: &str) {
333        let lock = sql.find(lock_marker).expect("迁移必须先取得写入阻断锁");
334        let delete = sql.find("DELETE").expect("迁移必须清理历史重复行");
335        let index = sql
336            .find("CREATE UNIQUE INDEX")
337            .expect("迁移必须创建唯一索引");
338        assert!(lock < delete, "写入阻断锁必须早于重复清理");
339        assert!(delete < index, "重复清理必须早于唯一索引创建");
340    }
341
342    /// PostgreSQL 与 MySQL 去重迁移都应关闭清理和建索引之间的并发窗口。
343    #[test]
344    fn dedup_migrations_lock_before_cleanup_and_unique_index() {
345        let postgres = include_str!("../../migrations/V202607190130__Deduplicate_workreminder.sql");
346        let mysql =
347            include_str!("../../migrations/mysql/V202607190130__Deduplicate_workreminder.sql");
348
349        assert_migration_lock_order(
350            postgres,
351            "LOCK TABLE workreminder IN SHARE ROW EXCLUSIVE MODE",
352        );
353        assert_migration_lock_order(mysql, "LOCK TABLES");
354        for lock_target in [
355            "workreminder WRITE",
356            "workreminder AS older WRITE",
357            "workreminder AS newer WRITE",
358        ] {
359            assert!(
360                mysql.contains(lock_target),
361                "MySQL 缺少锁目标: {lock_target}"
362            );
363        }
364        assert!(
365            mysql.find("CREATE UNIQUE INDEX").unwrap() < mysql.find("UNLOCK TABLES").unwrap(),
366            "MySQL 应在唯一索引创建后释放表锁"
367        );
368    }
369}