build(数据库): 改用 drizzle migration,加提交列表索引、清掉 Django 残留
Some checks failed
Deploy / deploy (push) Has been cancelled
Some checks failed
Deploy / deploy (push) Has been cancelled
一条线上的三件事:让 drizzle 的迁移机制真正可用 → 用它加索引 → 用它清掉 不再需要的 Django 表,最后接进部署和 CI。 ## 1. 让 drizzle-kit generate 可用 原本以为不能用:只加一个索引,generate 却吐出一堆噪音,其中 5 条 `DROP SEQUENCE auth_*/django_*` 打到生产库上会直接搞坏旧后端。 逐个查下来全是 `pull` 出的基线自己不能 round-trip,都是可修的: - **快照里的 Django 序列**:tablesFilter 只过滤表、不过滤它们的序列。 已从 0000_snapshot.json 清掉。 - **bigint 上限精度**:pull 生成的 `maxValue: 9223372036854775807` 是 JS number 字面量,round-trip 成 ...776000,每次 generate 都多出 10 条 ALTER COLUMN。改成字符串。 - **表达式索引的 opclass**:problem_tag_name_ci_unique 在快照里带 opclass,drizzle 自己序列化不出来,导致每次 drop + recreate。已去掉。 改完 `generate` 是干净的 no-op。 两个改不掉、只能绕的写进了 CLAUDE.md:索引 `.desc()` 生成 SQL 时会被丢 (单列索引不写方向即可,Postgres 用 Index Scan Backward 服务 ORDER BY DESC,实测同样 0.08ms);migrator 把所有语句包一个事务, CREATE INDEX CONCURRENTLY 跑不了。 最容易吃亏的是 drizzle 没有 --fake-initial:对已有数据的库直接 migrate 会从 0000 跑起、撞表回滚,**而且 exit 1 但一个错误都不打印**。 ## 2. 0001 提交列表索引 `WHERE contest_id IS NULL ORDER BY create_time DESC LIMIT n` 用不上现有的 contest_create_time_idx (contest_id, create_time DESC) —— Postgres 不把 `IS NULL` 当成能吃掉首列、从而继承第二列有序性的等值条件。把 enable_seqscan / enable_bitmapscan 全关掉逼它用也不肯,宁可走单列 contest_id 索引再全量排序。于是每翻一页都 Parallel Seq Scan 扫完整张表。 换成部分索引后谓词由索引自己保证,索引序就是查询要的排序序。生产快照 (12.3 万条提交)实测首页取 10 行:61.8ms / 读 18936 blocks → 0.22ms / 读 34 blocks。端到端 94ms → 6ms。 真正要命的不是单次 61ms,是每个请求都要把 169MB 的表刷一遍 shared_buffers —— 一节课几十个学生同时开提交列表,磁盘和缓存直接被打穿。 ## 3. 0002 删掉 Django 残留 确认旧 Django 后端不再使用、也不再作为回滚路径。删前核实过:没有任何 OJ2 保留的表引用这 7 张,3 条外键全在它们内部(所以不用 CASCADE,真有 漏网的会报错而不是被悄悄级联掉);5 个序列都由各自的表 owned,随 DROP TABLE 一并消失;数据全是 Django 自身元数据。tablesFilter 随之移除。 **回滚路径就此作废** —— CLAUDE.md 开头和 runbook 的「回滚保证」「七、回滚」 都改了。这条迁移已在本机 dev 库和生产快照副本上跑通,**生产库尚未执行**。 ## 4. migrate 接进部署与 CI deploy.sh 在构建之后、起栈之前跑 `oj2-api migrate`,失败就中止部署(旧 容器原样还在跑)。CI 走的也是 deploy.sh,所以不用给 GitHub 配数据库凭据, 也不用把生产库对外开放。 迁移文件**不内嵌进二进制**,随镜像装在 /usr/local/share/oj2/migrations。 这样 drizzle 的 migrate() 能原样用 —— 靠 _journal.json 自动发现,新增迁移 不用改任何代码,和 Django 扫 migrations/ 是一回事。内嵌就得为每条迁移 手写一行 import,那是迟早会漏的账。(CLAUDE.md 里「单二进制不能读文件」 那条讲的是 node_modules 和 import.meta.dir 推路径,按显式绝对路径读一个 数据目录不在此列。) 三道闸门,都是写完测出来才补上的: - **破坏性迁移拦截**:DROP TABLE / DROP COLUMN / DROP SCHEMA / ALTER COLUMN ... TYPE / TRUNCATE 命中就退出 4,需要 `OJ2_ALLOW_DESTRUCTIVE=1` 显式放行。DROP INDEX / DROP CONSTRAINT 不算, 拦了只会让人习惯性带上放行开关。扫描前先剥注释,避免误报。 - **基线缺失**:退出 3 并直接打印该敲的 SQL。注意判的是 `max(created_at) < 0` 而不是「表不存在」—— 表存在而为空(上次迁移失败 留下的)同样是没基线。 - **迁移目录读不到**:这是最可能犯的错(Dockerfile 漏拷),原本是 drizzle 的堆栈,现在直接说该检查哪一行。 另外发现 0000_crazy_gateway.sql 是 pull 的产物,**整份被 /* */ 包着, 可执行语句 0 条**,所以这个库根本不能靠迁移自举建表。原先写的「空库就从 0000 建」跑起来会炸在一个和真实原因毫不相干的 unterminated /* comment 上。 现在如实说明:结构只能来自 docs/specs/schema.sql 或生产 dump。 验证:镜像内编译(ARTIFACTS=build)出的真实镜像跑完五种场景 —— 拦截、 放行、幂等、无基线、漏拷目录,全部符合预期;dev 形态同样五种场景全过。 tsc / 路由遮蔽 / deploy.sh 语法 / generate no-op 都通过。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
93
CLAUDE.md
93
CLAUDE.md
@@ -1,17 +1,24 @@
|
||||
# CLAUDE.md
|
||||
|
||||
OJ2 是判题狗(Online Judge)的后端重写:Django 6 → Bun + TypeScript,前后端同仓。
|
||||
上一代在 `../OnlineJudge/`(Django)和 `../ojnext/`(Vue SPA),**两者都是回滚路径,
|
||||
完全冻结、一行都不改。**
|
||||
上一代在 `../OnlineJudge/`(Django)和 `../ojnext/`(Vue SPA),**仍然完全冻结、
|
||||
一行都不改**。
|
||||
|
||||
> **2026-08-26 起**:旧仓库零改动,没有例外——包括修 bug、包括不影响外部接口的
|
||||
> 内部小修(日志、缓存实现之类,以前允许,现已收紧)。所有后续工作,包括在旧仓库
|
||||
> 里发现的 bug,都只落在 OJ2:先确认 OJ2 是否有对应逻辑、是否重现了同样的问题,
|
||||
> 只在 OJ2 里修;旧仓库那边如实告知用户"未处理,按当前政策不动旧仓库",不要顺手改掉。
|
||||
> **2026-08-26:回滚路径已废弃。** 旧 Django 后端确认不再使用,
|
||||
> `0002_drop_django_leftovers` 会删掉它的 7 张框架表(含 `django_session`、
|
||||
> `django_migrations`)。这条迁移**已在本机 dev 库和生产快照副本上跑通**,
|
||||
> **生产库尚未执行**——跑之前务必确认服务器上没有 Django 进程还活着,
|
||||
> 否则删的是它正在用的 session 表。跑完之后旧后端就起不来了,
|
||||
> 「把上游切回去」不再是可用的回滚手段,要退只能靠备份恢复。
|
||||
>
|
||||
> 所以「改 schema 要考虑回滚」这条约束**已经解除**,schema 现在归 OJ2 独占,
|
||||
> 走 drizzle migration 正常演进即可。
|
||||
|
||||
冻结的目的是「回滚那天旧站能原样起来、和新站看到同一份数据」——改 OJ2 时,接口路径
|
||||
与响应结构、数据库 schema 这些外部可观测的东西都要小心,一动回滚就不再是「把上游
|
||||
切回去」那么简单。
|
||||
> **旧仓库仍然零改动**,没有例外——包括修 bug、包括不影响外部接口的内部小修。
|
||||
> 所有后续工作,包括在旧仓库里发现的 bug,都只落在 OJ2:先确认 OJ2 是否有对应逻辑、
|
||||
> 是否重现了同样的问题,只在 OJ2 里修;旧仓库那边如实告知用户"未处理,按当前政策
|
||||
> 不动旧仓库",不要顺手改掉。冻结的理由现在只剩「留作参照、别分散精力」,
|
||||
> 不再是回滚保证。
|
||||
|
||||
设计文档:`docs/specs/2026-08-06-bun-backend-rewrite-design.md`
|
||||
切换手册:`docs/specs/phase5-cutover-runbook.md` ← 上线当天照这份走
|
||||
@@ -109,11 +116,71 @@ Drizzle schema 是从生产库 `drizzle-kit pull` 出来的,**不写迁移**
|
||||
新旧后端跑在同一套表结构上(阶段 5 演练逐列比对过,零差异),这是回滚能成立的前提 ——
|
||||
所以改 schema 前先想清楚回滚怎么办。
|
||||
|
||||
生产库的几个约定:
|
||||
### 改 schema 走 drizzle migration
|
||||
|
||||
- `raw_password` 明文列**要保留**,老师用它找学生密码。不要"顺手清理"。
|
||||
- `judge_server_heartbeat` 保留。
|
||||
- `problem.prompt` 是给未来 AI 预留的,当前没接线,不要删。
|
||||
`bun run db:generate`(造迁移文件)→ `bun run db:migrate`(按 `drizzle.__drizzle_migrations`
|
||||
增量执行),就是 Django `makemigrations` / `migrate` 的等价物。索引/结构变更走这条,
|
||||
不要再手写 SQL 往 `docs/specs/` 里塞。
|
||||
|
||||
**部署时自动执行。** `docker/deploy.sh` 在「构建镜像」之后、「起栈」之前会跑
|
||||
`oj2-api migrate`,失败就中止部署(旧容器原样还在跑)。CI 走的也是 deploy.sh,
|
||||
所以不需要给 GitHub 配数据库凭据,也不用把生产库对外开放。
|
||||
|
||||
迁移文件**不内嵌进二进制**,随镜像装在 `/usr/local/share/oj2/migrations`
|
||||
(见 `runtime.ts` 的 `migrationsDir`、Dockerfile 里那两条 COPY)。这样 drizzle 的
|
||||
`migrate()` 能原样用——它靠 `meta/_journal.json` 自动发现迁移,**新增迁移不用改任何
|
||||
代码**。内嵌就得为每条迁移手写一行 import,那是迟早会漏的账。
|
||||
|
||||
**破坏性迁移默认拦截。** 含 `DROP TABLE` / `DROP COLUMN` / `DROP SCHEMA` /
|
||||
`ALTER COLUMN ... TYPE` / `TRUNCATE` 的迁移会让部署停在迁移这步并退出 4,
|
||||
需要确认备份后显式放行:
|
||||
|
||||
```bash
|
||||
OJ2_ALLOW_DESTRUCTIVE=1 docker/deploy.sh
|
||||
```
|
||||
|
||||
`DROP INDEX` / `DROP CONSTRAINT` 不算——它们不掉数据,拦了只会让人习惯性带上放行开关。
|
||||
|
||||
**0000 跑不了,库不能靠迁移自举。** `0000_crazy_gateway.sql` 是 `drizzle-kit pull`
|
||||
的产物,整份被 `/* */` 包着,可执行语句 0 条。所以任何新库的结构都只能来自
|
||||
`docs/specs/schema.sql` 或生产 dump,然后手工做基线。`oj2-api migrate` 会检测这两种
|
||||
情况并打印具体该做什么,不会让你撞上 drizzle 那个语焉不详的报错。
|
||||
|
||||
**给一个已经存在的库做基线**:drizzle 没有 `--fake-initial`,`migrate` 见到空的
|
||||
`__drizzle_migrations` 会从 `0000` 的完整建表跑起,撞上已存在的表就整个事务回滚 ——
|
||||
**而且失败时 exit 1 但一个错误都不打印**(只有 NOTICE,实测过)。所以对已有数据的库
|
||||
第一次跑之前,先手插一行把 `0000` 标记成已执行:
|
||||
|
||||
```sql
|
||||
CREATE SCHEMA IF NOT EXISTS drizzle;
|
||||
CREATE TABLE IF NOT EXISTS drizzle.__drizzle_migrations (
|
||||
id SERIAL PRIMARY KEY, hash text NOT NULL, created_at bigint);
|
||||
INSERT INTO drizzle.__drizzle_migrations (hash, created_at)
|
||||
VALUES ('baseline-0000-faked', 1786070652521); -- = meta/_journal.json 里 0000 的 when
|
||||
```
|
||||
|
||||
migrator 只比 `created_at`,不校验 hash,所以 hash 随便填。
|
||||
|
||||
**已知的三个坑**(`meta/0000_snapshot.json` 是 `pull` 出来的,没法无损还原 Django 建的
|
||||
schema,下面三处已经修过了,别让它们回潮):
|
||||
|
||||
- ~~**快照里的 Django 序列**~~:已随 `0002_drop_django_leftovers` 删表一并解决,
|
||||
`tablesFilter` 也移除了。(历史原因:`tablesFilter` 只过滤表、不过滤它们的序列,
|
||||
于是 `generate` 会吐出 5 条 `DROP SEQUENCE`。)
|
||||
- **bigint 上限精度**:`pull` 生成的 `maxValue: 9223372036854775807` 是 JS number 字面量,
|
||||
round-trip 成 `...776000`,每次 generate 都会多出 10 条 `ALTER COLUMN ... SET MAXVALUE`。
|
||||
已改成字符串。
|
||||
- **表达式索引的 opclass**:`problem_tag_name_ci_unique` 在快照里带 `opclass`,但 drizzle
|
||||
自己序列化不出来,导致每次都 drop + recreate。已从快照里去掉。
|
||||
|
||||
**还有两个改不掉的、写代码时要绕开的**:
|
||||
|
||||
- **索引方向会被丢**:`.desc()` 在生成 SQL 时消失,但快照里记成 `asc: false`,两边对不上,
|
||||
下次 pull 就是假 diff。单列索引不写方向就行(Postgres 用 Index Scan Backward 服务
|
||||
`ORDER BY ... DESC`,代价一样)。**多列混合方向的索引别指望 generate**,得手写。
|
||||
- **`CREATE INDEX CONCURRENTLY` 跑不了**:migrator 把所有语句包在一个事务里。大表加索引
|
||||
要是不能接受锁写窗口,只能绕开 migration 手工执行。参考量级:12.3 万行的部分索引,
|
||||
普通 `CREATE INDEX` 只锁 74ms,一般不用纠结。
|
||||
|
||||
## 部署
|
||||
|
||||
|
||||
@@ -7,6 +7,4 @@ export default defineConfig({
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL ?? "postgres://onlinejudge:onlinejudge@localhost:5433/onlinejudge",
|
||||
},
|
||||
// Django 框架表不进新后端,introspect 时直接排除
|
||||
tablesFilter: ["!django_*", "!auth_*"],
|
||||
})
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
"seed:dev": "bun src/scripts/seed-dev.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"check:routes": "bun src/scripts/check-route-shadowing.ts",
|
||||
"db:pull": "drizzle-kit pull"
|
||||
"db:pull": "drizzle-kit pull",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate"
|
||||
},
|
||||
"dependencies": {
|
||||
"@node-rs/jieba": "^2.0.2",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
CREATE INDEX "submission_public_create_time_idx" ON "submission" USING btree ("create_time" timestamptz_ops) WHERE "submission"."contest_id" is null;
|
||||
20
apps/api/src/db/0002_drop_django_leftovers.sql
Normal file
20
apps/api/src/db/0002_drop_django_leftovers.sql
Normal file
@@ -0,0 +1,20 @@
|
||||
-- 删掉旧 Django 后端遗留的 7 张表。2026-08-26 确认旧后端不再使用、也不再作为回滚路径。
|
||||
--
|
||||
-- 执行前提:目标库上**没有任何 Django 进程还在跑**。旧后端一旦还活着,
|
||||
-- 掉的是它的 session 表和 migration 记录,会直接打崩线上。
|
||||
--
|
||||
-- 已核实(在生产快照上):
|
||||
-- * 没有任何 OJ2 保留的表引用这 7 张表,它们之间只有 3 条内部外键,删除是自洽的;
|
||||
-- * 5 个相关序列(auth_group_id_seq 等)都由各自的表 owned,随 DROP TABLE 一并消失,
|
||||
-- 不需要单独 DROP SEQUENCE;
|
||||
-- * 表里没有需要保留的数据:auth_permission 136 行、django_content_type 34 行、
|
||||
-- django_migrations 91 行、django_session 1 行,其余为空——全是 Django 自身的元数据。
|
||||
--
|
||||
-- 按外键依赖顺序删,不用 CASCADE:这样万一将来真有别的东西引用了,会直接报错而不是被悄悄级联掉。
|
||||
DROP TABLE IF EXISTS auth_group_permissions;--> statement-breakpoint
|
||||
DROP TABLE IF EXISTS auth_permission;--> statement-breakpoint
|
||||
DROP TABLE IF EXISTS auth_group;--> statement-breakpoint
|
||||
DROP TABLE IF EXISTS django_content_type;--> statement-breakpoint
|
||||
DROP TABLE IF EXISTS django_dramatiq_task;--> statement-breakpoint
|
||||
DROP TABLE IF EXISTS django_migrations;--> statement-breakpoint
|
||||
DROP TABLE IF EXISTS django_session;
|
||||
@@ -2552,7 +2552,6 @@
|
||||
"expression": "lower(name)",
|
||||
"asc": true,
|
||||
"nulls": "last",
|
||||
"opclass": "text_ops",
|
||||
"isExpression": true
|
||||
}
|
||||
],
|
||||
@@ -3828,58 +3827,7 @@
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {
|
||||
"public.auth_group_id_seq": {
|
||||
"name": "auth_group_id_seq",
|
||||
"schema": "public",
|
||||
"startWith": "1",
|
||||
"minValue": "1",
|
||||
"maxValue": "2147483647",
|
||||
"increment": "1",
|
||||
"cycle": false,
|
||||
"cache": "1"
|
||||
},
|
||||
"public.auth_group_permissions_id_seq": {
|
||||
"name": "auth_group_permissions_id_seq",
|
||||
"schema": "public",
|
||||
"startWith": "1",
|
||||
"minValue": "1",
|
||||
"maxValue": "2147483647",
|
||||
"increment": "1",
|
||||
"cycle": false,
|
||||
"cache": "1"
|
||||
},
|
||||
"public.auth_permission_id_seq": {
|
||||
"name": "auth_permission_id_seq",
|
||||
"schema": "public",
|
||||
"startWith": "1",
|
||||
"minValue": "1",
|
||||
"maxValue": "2147483647",
|
||||
"increment": "1",
|
||||
"cycle": false,
|
||||
"cache": "1"
|
||||
},
|
||||
"public.django_content_type_id_seq": {
|
||||
"name": "django_content_type_id_seq",
|
||||
"schema": "public",
|
||||
"startWith": "1",
|
||||
"minValue": "1",
|
||||
"maxValue": "2147483647",
|
||||
"increment": "1",
|
||||
"cycle": false,
|
||||
"cache": "1"
|
||||
},
|
||||
"public.django_migrations_id_seq": {
|
||||
"name": "django_migrations_id_seq",
|
||||
"schema": "public",
|
||||
"startWith": "1",
|
||||
"minValue": "1",
|
||||
"maxValue": "2147483647",
|
||||
"increment": "1",
|
||||
"cycle": false,
|
||||
"cache": "1"
|
||||
}
|
||||
},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
|
||||
3818
apps/api/src/db/meta/0001_snapshot.json
Normal file
3818
apps/api/src/db/meta/0001_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
3818
apps/api/src/db/meta/0002_snapshot.json
Normal file
3818
apps/api/src/db/meta/0002_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,20 @@
|
||||
"when": 1786070652521,
|
||||
"tag": "0000_crazy_gateway",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1787740189021,
|
||||
"tag": "0001_add_submission_public_create_time_idx",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1787740469403,
|
||||
"tag": "0002_drop_django_leftovers",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
142
apps/api/src/db/migrate.ts
Normal file
142
apps/api/src/db/migrate.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { readMigrationFiles } from "drizzle-orm/migrator"
|
||||
import { drizzle } from "drizzle-orm/postgres-js"
|
||||
import { migrate as drizzleMigrate } from "drizzle-orm/postgres-js/migrator"
|
||||
import postgres from "postgres"
|
||||
|
||||
import { migrationsDir } from "../runtime"
|
||||
|
||||
/**
|
||||
* 会造成不可逆数据丢失的语句。DROP INDEX / DROP CONSTRAINT 不在内 —— 它们不掉数据,
|
||||
* 拦下来只会让日常部署平白多一道人工确认。
|
||||
*
|
||||
* `ALTER COLUMN ... TYPE` 算进来是因为它要重写整表、拿 ACCESS EXCLUSIVE 锁,
|
||||
* 而且窄化类型时会报错或截断。
|
||||
*/
|
||||
const DESTRUCTIVE_PATTERNS: Array<[RegExp, string]> = [
|
||||
[/\bdrop\s+table\b/i, "DROP TABLE"],
|
||||
[/\bdrop\s+schema\b/i, "DROP SCHEMA"],
|
||||
[/\balter\s+table\s+.+\s+drop\s+column\b/is, "DROP COLUMN"],
|
||||
[/\balter\s+column\s+.+\s+type\b/is, "ALTER COLUMN ... TYPE"],
|
||||
[/\btruncate\b/i, "TRUNCATE"],
|
||||
]
|
||||
|
||||
/** 去掉 `--` 行注释和 `/* *\/` 块注释,免得注释里提到 drop table 就误判 */
|
||||
function stripComments(sql: string) {
|
||||
return sql.replace(/\/\*[\s\S]*?\*\//g, "").replace(/--[^\n]*/g, "")
|
||||
}
|
||||
|
||||
const BASELINE_HOWTO = `先建基线表并把 0000 标记成已执行(相当于 Django 的 --fake-initial):
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS drizzle;
|
||||
CREATE TABLE IF NOT EXISTS drizzle.__drizzle_migrations (
|
||||
id SERIAL PRIMARY KEY, hash text NOT NULL, created_at bigint);
|
||||
INSERT INTO drizzle.__drizzle_migrations (hash, created_at)
|
||||
VALUES ('baseline-0000-faked', 1786070652521);
|
||||
|
||||
详见 CLAUDE.md「改 schema 走 drizzle migration」。`
|
||||
|
||||
export async function runMigrations() {
|
||||
const url = process.env.DATABASE_URL
|
||||
if (!url) {
|
||||
console.error("没有 DATABASE_URL,不知道该迁移哪个库")
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
// readMigrationFiles 找不到 meta/_journal.json 会直接抛,堆栈指向 drizzle 内部,
|
||||
// 看不出真实原因。而这恰恰是最可能发生的失误:镜像里漏拷迁移目录。
|
||||
let files: ReturnType<typeof readMigrationFiles>
|
||||
try {
|
||||
files = readMigrationFiles({ migrationsFolder: migrationsDir })
|
||||
} catch {
|
||||
console.error(
|
||||
`读不到迁移目录:${migrationsDir}\n` +
|
||||
"编译产物不内嵌迁移文件,它们随镜像装在固定路径下。\n" +
|
||||
"检查 docker/Dockerfile 里那条 `COPY apps/api/src/db/ ...`," +
|
||||
"或用 OJ2_MIGRATIONS_DIR 显式指定。",
|
||||
)
|
||||
process.exit(2)
|
||||
}
|
||||
if (files.length === 0) {
|
||||
console.error(`${migrationsDir} 下没找到任何迁移。镜像里的迁移目录是不是漏拷了?`)
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
// max: 1 —— advisory lock 是会话级的,多连接会让锁挂在另一条连接上,等于没锁
|
||||
const client = postgres(url, { max: 1, onnotice: () => {} })
|
||||
|
||||
try {
|
||||
// 防止两次部署撞在一起同时迁移。key 是随手取的常量,只要全项目一致就行
|
||||
await client`select pg_advisory_lock(4478215096)`
|
||||
|
||||
const applied = await client<{ last: string }[]>`
|
||||
select coalesce(max(created_at), -1)::text as last
|
||||
from drizzle.__drizzle_migrations
|
||||
`.catch(() => null)
|
||||
|
||||
// 基线缺失。这个库没法靠迁移自举 —— 0000 是 `drizzle-kit pull` 的产物,
|
||||
// 整个文件被块注释包着,一条可执行语句都没有。结构只能来自 docs/specs/schema.sql
|
||||
// 或生产 dump,然后手工把 0000 标记成已执行。
|
||||
const lastApplied = applied === null ? -1 : Number(applied[0]?.last ?? -1)
|
||||
if (lastApplied < 0) {
|
||||
const rows = await client<{ count: number }[]>`
|
||||
select count(*)::int as count from information_schema.tables where table_schema = 'public'
|
||||
`
|
||||
const tableCount = rows[0]?.count ?? 0
|
||||
console.error(
|
||||
tableCount > 0
|
||||
? `库里已经有 ${tableCount} 张表,但没有迁移基线记录。\n` +
|
||||
"直接迁移会从 0000 跑起,而 0000 是 introspect 产物、整份被注释掉,跑不了。\n\n" +
|
||||
BASELINE_HOWTO
|
||||
: "这是个空库,迁移没法自举建表(0000 是 introspect 产物,整份被注释掉)。\n" +
|
||||
"先把结构灌进去:\n\n" +
|
||||
" psql -d <库> -f docs/specs/schema.sql\n\n" +
|
||||
BASELINE_HOWTO,
|
||||
)
|
||||
process.exit(3)
|
||||
}
|
||||
|
||||
const pending = files.filter((f) => f.folderMillis > lastApplied)
|
||||
if (pending.length === 0) {
|
||||
console.log("没有待执行的迁移。")
|
||||
return
|
||||
}
|
||||
|
||||
// 兜底:真要跑到一条「没有可执行语句」的迁移,说明基线状态不对
|
||||
// (多半是 0000 被算进了 pending)。这种情况下 drizzle 会把整份注释当 SQL 发过去,
|
||||
// 报一个和真实原因毫不相干的 "unterminated /* comment"。宁可自己先说清楚。
|
||||
if (pending.some((f) => stripComments(f.sql.join("\n")).trim() === "")) {
|
||||
console.error(
|
||||
"待执行的迁移里有一条不含任何可执行语句(多半是 introspect 出来的 0000)。\n" +
|
||||
"基线记录不对,检查 drizzle.__drizzle_migrations。\n\n" +
|
||||
BASELINE_HOWTO,
|
||||
)
|
||||
process.exit(3)
|
||||
}
|
||||
|
||||
const blocked = pending
|
||||
.map((f) => destructiveReasons(f.sql.join("\n")))
|
||||
.filter((reasons) => reasons.length > 0)
|
||||
|
||||
if (blocked.length > 0 && process.env.OJ2_ALLOW_DESTRUCTIVE !== "1") {
|
||||
console.error(
|
||||
"待执行的迁移里有破坏性语句,已停下:\n" +
|
||||
blocked.map((reasons) => ` · ${reasons.join(" / ")}`).join("\n") +
|
||||
"\n\n这类改动不可逆,不该在一次日常部署里顺手执行。" +
|
||||
"\n确认已经做过备份之后,用这个显式放行:\n\n" +
|
||||
" OJ2_ALLOW_DESTRUCTIVE=1 docker/deploy.sh\n",
|
||||
)
|
||||
process.exit(4)
|
||||
}
|
||||
|
||||
console.log(`待执行 ${pending.length} 条迁移,开始。`)
|
||||
await drizzleMigrate(drizzle(client), { migrationsFolder: migrationsDir })
|
||||
console.log("迁移完成。")
|
||||
} finally {
|
||||
await client.end()
|
||||
}
|
||||
}
|
||||
|
||||
function destructiveReasons(sql: string) {
|
||||
const bare = stripComments(sql)
|
||||
return DESTRUCTIVE_PATTERNS.filter(([re]) => re.test(bare)).map(([, label]) => label)
|
||||
}
|
||||
@@ -1,20 +1,27 @@
|
||||
// 本文件由 `drizzle-kit pull` 从本地库自动生成,drizzle.config.ts 的 tablesFilter
|
||||
// (["!django_*", "!auth_*"]) 已生效:34 张表中的 7 张 Django 框架表
|
||||
// (auth_group、auth_group_permissions、auth_permission、django_content_type、
|
||||
// django_dramatiq_task、django_migrations、django_session) 均未生成 pgTable 定义,
|
||||
// 剩余 27 张业务表原样保留。
|
||||
// 本文件由 `drizzle-kit pull` 从生产库自动生成,之后按下面几条手工维护。
|
||||
//
|
||||
// 手工剪枝:tablesFilter 只过滤了 pgTable,未过滤这些框架表的 id 序列,遗留了
|
||||
// 5 个孤儿 pgSequence 导出(authGroupIdSeq、authGroupPermissionsIdSeq、
|
||||
// authPermissionIdSeq、djangoContentTypeIdSeq、djangoMigrationsIdSeq)——
|
||||
// 它们不被任何剩余表引用,留着只会在未来 `drizzle-kit generate` 时生成多余的
|
||||
// `CREATE SEQUENCE` 迁移,因此手工删除。
|
||||
// 2026-08-26:旧 Django 后端下线,7 张框架表(auth_group、auth_group_permissions、
|
||||
// auth_permission、django_content_type、django_dramatiq_task、django_migrations、
|
||||
// django_session)已由 0002_drop_django_leftovers 删除,drizzle.config.ts 的
|
||||
// tablesFilter 随之移除。库里现在就是这 27 张业务表。
|
||||
//
|
||||
// 手工修正(都是 `pull` 自己没法无损 round-trip 的地方,改回去会让 generate 产生假 diff,
|
||||
// 详见 CLAUDE.md「改 schema 走 drizzle migration」):
|
||||
// * bigint identity 的 maxValue 用字符串,不能写成 JS number 字面量(会丢精度)。
|
||||
// * 索引不写 `.desc()`,生成 SQL 时方向会被丢掉。
|
||||
//
|
||||
// 关于 10 张表的 bigint id(problemset*、achievement、user_achievement、user_stat、
|
||||
// user_badge、ai_analysis):这是历史巧合不是设计——这些 app 的 0001_initial 生成时
|
||||
// Django 还没设 DEFAULT_AUTO_FIELD,用了 3.2+ 的默认 BigAutoField;更早的表(user、
|
||||
// problem、contest、submission)都是 int4。现存最大 id 一万出头,确实都用不上 bigint,
|
||||
// 但 2026-08-26 评估后决定**不改**:省 4 字节/行毫无意义,ALTER TYPE 要重写整表并拿
|
||||
// ACCESS EXCLUSIVE 锁,而且其中 6 处 id 被外键绑着得连坐。别再提这件事了。
|
||||
import { pgTable, index, foreignKey, bigint, text, jsonb, timestamp, integer, boolean, serial, doublePrecision, varchar, unique, uniqueIndex } from "drizzle-orm/pg-core"
|
||||
import { sql } from "drizzle-orm"
|
||||
|
||||
export const aiAnalysis = pgTable("ai_analysis", {
|
||||
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
|
||||
id: bigint({ mode: "number" }).primaryKey().generatedByDefaultAsIdentity({ name: "ai_analysis_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 9223372036854775807, cache: 1 }),
|
||||
id: bigint({ mode: "number" }).primaryKey().generatedByDefaultAsIdentity({ name: "ai_analysis_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: "9223372036854775807", cache: 1 }),
|
||||
provider: text().notNull(),
|
||||
data: jsonb().notNull(),
|
||||
systemPrompt: text("system_prompt").notNull(),
|
||||
@@ -55,7 +62,7 @@ export const announcement = pgTable("announcement", {
|
||||
|
||||
export const achievement = pgTable("achievement", {
|
||||
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
|
||||
id: bigint({ mode: "number" }).primaryKey().generatedByDefaultAsIdentity({ name: "achievement_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 9223372036854775807, cache: 1 }),
|
||||
id: bigint({ mode: "number" }).primaryKey().generatedByDefaultAsIdentity({ name: "achievement_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: "9223372036854775807", cache: 1 }),
|
||||
name: text().notNull(),
|
||||
description: text().notNull(),
|
||||
icon: text().notNull(),
|
||||
@@ -222,7 +229,7 @@ export const optionsSysoptions = pgTable("options_sysoptions", {
|
||||
|
||||
export const problemset = pgTable("problemset", {
|
||||
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
|
||||
id: bigint({ mode: "number" }).primaryKey().generatedByDefaultAsIdentity({ name: "problemset_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 9223372036854775807, cache: 1 }),
|
||||
id: bigint({ mode: "number" }).primaryKey().generatedByDefaultAsIdentity({ name: "problemset_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: "9223372036854775807", cache: 1 }),
|
||||
title: text().notNull(),
|
||||
description: text().notNull(),
|
||||
createTime: timestamp("create_time", { withTimezone: true, mode: 'string' }).notNull(),
|
||||
@@ -243,7 +250,7 @@ export const problemset = pgTable("problemset", {
|
||||
|
||||
export const problemsetProblem = pgTable("problemset_problem", {
|
||||
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
|
||||
id: bigint({ mode: "number" }).primaryKey().generatedByDefaultAsIdentity({ name: "problemset_problem_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 9223372036854775807, cache: 1 }),
|
||||
id: bigint({ mode: "number" }).primaryKey().generatedByDefaultAsIdentity({ name: "problemset_problem_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: "9223372036854775807", cache: 1 }),
|
||||
order: integer().notNull(),
|
||||
isRequired: boolean("is_required").notNull(),
|
||||
score: integer().notNull(),
|
||||
@@ -269,7 +276,7 @@ export const problemsetProblem = pgTable("problemset_problem", {
|
||||
|
||||
export const problemsetProgress = pgTable("problemset_progress", {
|
||||
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
|
||||
id: bigint({ mode: "number" }).primaryKey().generatedByDefaultAsIdentity({ name: "problemset_progress_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 9223372036854775807, cache: 1 }),
|
||||
id: bigint({ mode: "number" }).primaryKey().generatedByDefaultAsIdentity({ name: "problemset_progress_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: "9223372036854775807", cache: 1 }),
|
||||
joinTime: timestamp("join_time", { withTimezone: true, mode: 'string' }).notNull(),
|
||||
completeTime: timestamp("complete_time", { withTimezone: true, mode: 'string' }),
|
||||
isCompleted: boolean("is_completed").notNull(),
|
||||
@@ -299,7 +306,7 @@ export const problemsetProgress = pgTable("problemset_progress", {
|
||||
|
||||
export const problemsetSubmission = pgTable("problemset_submission", {
|
||||
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
|
||||
id: bigint({ mode: "number" }).primaryKey().generatedByDefaultAsIdentity({ name: "problemset_submission_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 9223372036854775807, cache: 1 }),
|
||||
id: bigint({ mode: "number" }).primaryKey().generatedByDefaultAsIdentity({ name: "problemset_submission_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: "9223372036854775807", cache: 1 }),
|
||||
problemId: integer("problem_id").notNull(),
|
||||
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
|
||||
problemsetId: bigint("problemset_id", { mode: "number" }).notNull(),
|
||||
@@ -460,6 +467,17 @@ export const submission = pgTable("submission", {
|
||||
ip: text(),
|
||||
}, (table) => [
|
||||
index("contest_create_time_idx").using("btree", table.contestId.asc().nullsLast().op("timestamptz_ops"), table.createTime.desc().nullsFirst().op("int4_ops")),
|
||||
// 提交列表默认视图(WHERE contest_id IS NULL ORDER BY create_time DESC)专用。
|
||||
// 上面的 contest_create_time_idx 看着能覆盖,但 Postgres 不把 `contest_id IS NULL`
|
||||
// 当成能吃掉首列、从而继承第二列有序性的等值条件——把 seqscan/bitmapscan 全关掉逼它
|
||||
// 也不肯用,只会走单列 contest_id 索引再全量排序。结果是每翻一页都 Parallel Seq Scan
|
||||
// 扫完整张表 + top-N 排序。改用部分索引后谓词由索引本身保证,排序序就是索引序。
|
||||
// 生产快照(12.3 万条提交)实测:61.8ms / 18936 blocks → 0.22ms / 34 blocks。
|
||||
// 这个索引不在 Django 的 migration 里,是 OJ2 单独加的,见 src/db/0001_naive_agent_zero.sql。
|
||||
// 不写 .desc():drizzle-kit 生成 SQL 时会把方向丢掉,写了会让快照(记 asc:false)和实际
|
||||
// 建出来的索引(ASC)对不上,下次 pull 就产生假 diff。单列索引无所谓方向,Postgres 用
|
||||
// Index Scan Backward 服务 ORDER BY ... DESC,实测同样是 0.08ms。
|
||||
index("submission_public_create_time_idx").using("btree", table.createTime.op("timestamptz_ops")).where(sql`${table.contestId} is null`),
|
||||
index("problem_user_idx").using("btree", table.problemId.asc().nullsLast().op("int4_ops"), table.userId.asc().nullsLast().op("int4_ops")),
|
||||
index("submission_contest_id_775716d5").using("btree", table.contestId.asc().nullsLast().op("int4_ops")),
|
||||
index("submission_problem_id_76847b55").using("btree", table.problemId.asc().nullsLast().op("int4_ops")),
|
||||
@@ -500,7 +518,7 @@ export const tutorial = pgTable("tutorial", {
|
||||
|
||||
export const userStat = pgTable("user_stat", {
|
||||
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
|
||||
id: bigint({ mode: "number" }).primaryKey().generatedByDefaultAsIdentity({ name: "user_stat_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 9223372036854775807, cache: 1 }),
|
||||
id: bigint({ mode: "number" }).primaryKey().generatedByDefaultAsIdentity({ name: "user_stat_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: "9223372036854775807", cache: 1 }),
|
||||
metrics: jsonb().default({}).notNull(),
|
||||
updateTime: timestamp("update_time", { withTimezone: true, mode: 'string' }).notNull(),
|
||||
userId: integer("user_id").notNull(),
|
||||
@@ -515,7 +533,7 @@ export const userStat = pgTable("user_stat", {
|
||||
|
||||
export const userAchievement = pgTable("user_achievement", {
|
||||
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
|
||||
id: bigint({ mode: "number" }).primaryKey().generatedByDefaultAsIdentity({ name: "user_achievement_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 9223372036854775807, cache: 1 }),
|
||||
id: bigint({ mode: "number" }).primaryKey().generatedByDefaultAsIdentity({ name: "user_achievement_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: "9223372036854775807", cache: 1 }),
|
||||
unlockTime: timestamp("unlock_time", { withTimezone: true, mode: 'string' }).notNull(),
|
||||
backfilled: boolean().default(false).notNull(),
|
||||
notified: boolean().default(false).notNull(),
|
||||
@@ -542,7 +560,7 @@ export const userAchievement = pgTable("user_achievement", {
|
||||
|
||||
export const userBadge = pgTable("user_badge", {
|
||||
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
|
||||
id: bigint({ mode: "number" }).primaryKey().generatedByDefaultAsIdentity({ name: "user_badge_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 9223372036854775807, cache: 1 }),
|
||||
id: bigint({ mode: "number" }).primaryKey().generatedByDefaultAsIdentity({ name: "user_badge_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: "9223372036854775807", cache: 1 }),
|
||||
earnedTime: timestamp("earned_time", { withTimezone: true, mode: 'string' }).notNull(),
|
||||
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
|
||||
badgeId: bigint("badge_id", { mode: "number" }).notNull(),
|
||||
@@ -634,7 +652,7 @@ export const user = pgTable("user", {
|
||||
|
||||
export const problemsetBadge = pgTable("problemset_badge", {
|
||||
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
|
||||
id: bigint({ mode: "number" }).primaryKey().generatedByDefaultAsIdentity({ name: "problemset_badge_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 9223372036854775807, cache: 1 }),
|
||||
id: bigint({ mode: "number" }).primaryKey().generatedByDefaultAsIdentity({ name: "problemset_badge_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: "9223372036854775807", cache: 1 }),
|
||||
name: text().notNull(),
|
||||
description: text().notNull(),
|
||||
icon: text().notNull(),
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
* oj2-api worker # BullMQ 判题消费者
|
||||
* oj2-api healthcheck # 探活,给 Dockerfile 的 HEALTHCHECK 用
|
||||
* oj2-api sql-child # SQL 判题子进程,由服务自己 spawn,不该手动调
|
||||
* oj2-api migrate # 执行待办的数据库迁移,部署时由 docker/deploy.sh 调
|
||||
*
|
||||
* 用动态 import 而非顶层 import:这几个模块都有导入即执行的副作用
|
||||
* (Bun.serve、连 Redis 开消费者),静态导入会让 sql-child 也把整个服务拉起来。
|
||||
@@ -27,6 +28,11 @@ switch (command) {
|
||||
case "worker":
|
||||
await import("./worker")
|
||||
break
|
||||
case "migrate": {
|
||||
const { runMigrations } = await import("./db/migrate")
|
||||
await runMigrations()
|
||||
break
|
||||
}
|
||||
case "sql-child": {
|
||||
const { runSqlChild } = await import("./judge/sql/child")
|
||||
await runSqlChild()
|
||||
@@ -47,6 +53,6 @@ switch (command) {
|
||||
}
|
||||
}
|
||||
default:
|
||||
console.error(`未知子命令:${command}\n可用:serve | worker | healthcheck | sql-child`)
|
||||
console.error(`未知子命令:${command}\n可用:serve | worker | migrate | healthcheck | sql-child`)
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
@@ -32,3 +32,20 @@ export function selfCommand(subcommand: string): string[] {
|
||||
* 就会落到 apps/api/data/ 下,两边不是同一个目录,新传的测试点判题时报「找不到测试数据」。
|
||||
*/
|
||||
export const pathBase = isCompiled ? process.cwd() : resolve(import.meta.dir, "../../..")
|
||||
|
||||
/**
|
||||
* 迁移文件(`0000_*.sql` … + `meta/_journal.json`)所在目录。
|
||||
*
|
||||
* 这些文件**不内嵌进二进制**,而是随镜像一起装到一个固定绝对路径下。
|
||||
* 之所以能这么做、也应该这么做:CLAUDE.md 里「单二进制是有代价的」那条讲的是
|
||||
* 不能依赖 node_modules、不能拿 `import.meta.dir` 去推路径(编译后它恒为
|
||||
* `/$bunfs/root`,往上几级就跑到文件系统根)。按一个**显式给定的绝对路径**读一个
|
||||
* 数据目录不在此列。
|
||||
*
|
||||
* 换来的好处是 drizzle 的 `migrate()` 能原样用 —— 它靠 `_journal.json` 自动发现
|
||||
* 迁移,和 Django 扫 `migrations/` 是一回事。要是改成内嵌,就得为每条迁移手写一行
|
||||
* import,那是迟早会漏的账。
|
||||
*/
|
||||
export const migrationsDir =
|
||||
process.env.OJ2_MIGRATIONS_DIR ??
|
||||
(isCompiled ? "/usr/local/share/oj2/migrations" : resolve(import.meta.dir, "db"))
|
||||
|
||||
@@ -129,6 +129,16 @@ COPY --from=ghcr.io/astral-sh/ruff:0.15.12 /ruff /usr/local/bin/ruff
|
||||
|
||||
COPY --from=artifacts /artifacts/oj2-api /usr/local/bin/oj2-api
|
||||
|
||||
# 迁移文件**不内嵌进二进制**,随镜像装在这个固定路径下(apps/api/src/runtime.ts
|
||||
# 的 migrationsDir 写死的就是它)。这样 drizzle 的 migrate() 能原样用 —— 它靠
|
||||
# meta/_journal.json 自动发现迁移,新增迁移不用改任何代码。内嵌的话得为每条迁移
|
||||
# 手写一行 import,那是迟早会漏的账。
|
||||
#
|
||||
# 只拷 .sql 和 journal,不拷 meta/*_snapshot.json(那是 drizzle-kit generate 用的,
|
||||
# 运行时不读,几百 KB 没必要进镜像)。
|
||||
COPY apps/api/src/db/*.sql /usr/local/share/oj2/migrations/
|
||||
COPY apps/api/src/db/meta/_journal.json /usr/local/share/oj2/migrations/meta/
|
||||
|
||||
# /data 下是要持久化的东西:测试用例、上传的图片、头像。
|
||||
# 相对路径按 cwd 解析(见 apps/api/src/runtime.ts),所以 workdir 设成 /data;
|
||||
# 但下面的路径全写绝对值,不靠这个巧合。
|
||||
|
||||
@@ -127,6 +127,22 @@ if [ "$BUILD" -eq 1 ]; then
|
||||
"${COMPOSE[@]}" build
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------- 迁移
|
||||
# 在起栈**之前**跑:schema 先就位,新代码再启动。
|
||||
#
|
||||
# --no-deps 是必须的:这一步只要 api 镜像里的二进制,不该顺带把 worker/web 拉起来。
|
||||
# 库连的是 env 里的 DB_HOST,和起栈用的是同一份配置。
|
||||
#
|
||||
# 含 DROP TABLE 这类破坏性语句的迁移会被拦下并退出 4,需要显式放行:
|
||||
# OJ2_ALLOW_DESTRUCTIVE=1 docker/deploy.sh
|
||||
# 放行前先备份。这道闸门在 apps/api/src/db/migrate.ts。
|
||||
say "数据库迁移"
|
||||
"${COMPOSE[@]}" run --rm --no-deps \
|
||||
-e OJ2_ALLOW_DESTRUCTIVE="${OJ2_ALLOW_DESTRUCTIVE:-}" \
|
||||
api oj2-api migrate \
|
||||
|| die "迁移没通过,已中止部署(旧容器还在跑,没动过)。
|
||||
上面的输出说明了原因。破坏性迁移需要 OJ2_ALLOW_DESTRUCTIVE=1 显式放行。"
|
||||
|
||||
say "起栈"
|
||||
"${COMPOSE[@]}" up -d
|
||||
"${COMPOSE[@]}" ps
|
||||
|
||||
@@ -25,9 +25,26 @@
|
||||
|
||||
### 两个原本以为要做、实测不需要做的事
|
||||
|
||||
**1. 不需要执行任何 DDL。** 把生产 dump 的表结构和新后端在用的库逐列对比:
|
||||
**1. 切换本身不需要执行任何 DDL。** 把生产 dump 的表结构和新后端在用的库逐列对比:
|
||||
两边都是 **278 列,完全一致**,没有新增、没有删除、没有新表。新后端直接跑在现有结构上。
|
||||
|
||||
> 后来追加了一个**性能索引**(提交列表默认视图从全表扫变成索引扫),并且改用
|
||||
> drizzle migration 管理,见 `apps/api/src/db/0001_add_submission_public_create_time_idx.sql`。
|
||||
> 它**不属于切换流程**,切换前后任何时候单独跑一次即可,跑早点更好,
|
||||
> **别塞进停机窗口**(12.3 万行实测建索引 74ms,但没必要占用窗口时间)。
|
||||
>
|
||||
> 对生产库第一次执行前,**必须先手插 `__drizzle_migrations` 的基线行**,否则 migrate
|
||||
> 会从 `0000` 的完整建表跑起、撞表回滚,而且**失败时不打印任何错误**。
|
||||
> 基线 SQL 和完整说明见 `CLAUDE.md` 的「改 schema 走 drizzle migration」。执行:
|
||||
>
|
||||
> ```bash
|
||||
> # 1) 先插基线行(见 CLAUDE.md)
|
||||
> # 2) 再跑
|
||||
> DATABASE_URL=<生产库> bun run db:migrate
|
||||
> ```
|
||||
>
|
||||
> 这会在生产库里新建一个 `drizzle` schema 和 `__drizzle_migrations` 表。
|
||||
|
||||
**2. 不需要重置序列。** 我在 `phase3-coverage.md` 里记过一条「切换必做:重置各表 sequence」,
|
||||
**那条是错的** —— 它来自我手工造的本地库(用显式 id 导入、没有 setval)。真实的
|
||||
pg_dumpall 备份带 30 条 `setval`,而且直接查生产快照里所有序列 vs `max(id)`,
|
||||
@@ -35,6 +52,13 @@ pg_dumpall 备份带 30 条 `setval`,而且直接查生产快照里所有序
|
||||
|
||||
### 回滚保证(已实测)
|
||||
|
||||
> ⚠️ **2026-08-26 起本节即将作废。** 旧 Django 后端确认不再使用,
|
||||
> `0002_drop_django_leftovers` 会删掉它的 7 张框架表。该迁移**尚未在生产库执行**;
|
||||
> 一旦执行,旧栈就起不来,本节和第七节描述的「停新栈起旧栈」「把 NPM 上游改回 8080」
|
||||
> 全部失效,唯一退路是从备份恢复数据库。**执行前先做一次全量备份。**
|
||||
> 下面内容保留作历史记录。
|
||||
|
||||
|
||||
新栈跑完登录、提交、判题之后,再和生产 dump 的结构比一次:**逐列一致,零差异**。
|
||||
新后端不会给旧后端留下任何它不认识的东西。
|
||||
|
||||
@@ -424,6 +448,10 @@ curl -s -o /dev/null -w '未登录进后台 %{http_code}\n' $BASE/api/admin/das
|
||||
|
||||
## 七、回滚
|
||||
|
||||
> ⚠️ **执行 `0002_drop_django_leftovers` 之后本节作废**,理由见上面「回滚保证」一节:
|
||||
> 旧 Django 后端的表被删除后旧栈起不来,退路只剩「从备份恢复数据库」,不是秒级操作。
|
||||
> 下面内容保留作历史记录。
|
||||
|
||||
**试跑之后切的**(推荐路径):NPM 里把 `xuyue.cc` 的上游从 `8090` 改回 `8080`。
|
||||
旧栈这时候还跑着,**秒级生效,什么都不用停不用起**。等确认稳定了再决定何时收掉新栈。
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
"dev:web": "bun run --filter '@oj2/web' dev",
|
||||
"seed:dev": "bun run --filter '@oj2/api' seed:dev",
|
||||
"db:up": "docker compose -f docker/compose.dev.yml up -d",
|
||||
"db:generate": "bun run --filter '@oj2/api' db:generate",
|
||||
"db:migrate": "bun run --filter '@oj2/api' db:migrate",
|
||||
"db:down": "docker compose -f docker/compose.dev.yml down"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
Reference in New Issue
Block a user