一条线上的三件事:让 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>
11 KiB
CLAUDE.md
OJ2 是判题狗(Online Judge)的后端重写:Django 6 → Bun + TypeScript,前后端同仓。
上一代在 ../OnlineJudge/(Django)和 ../ojnext/(Vue SPA),仍然完全冻结、
一行都不改。
2026-08-26:回滚路径已废弃。 旧 Django 后端确认不再使用,
0002_drop_django_leftovers会删掉它的 7 张框架表(含django_session、django_migrations)。这条迁移已在本机 dev 库和生产快照副本上跑通, 生产库尚未执行——跑之前务必确认服务器上没有 Django 进程还活着, 否则删的是它正在用的 session 表。跑完之后旧后端就起不来了, 「把上游切回去」不再是可用的回滚手段,要退只能靠备份恢复。所以「改 schema 要考虑回滚」这条约束已经解除,schema 现在归 OJ2 独占, 走 drizzle migration 正常演进即可。
旧仓库仍然零改动,没有例外——包括修 bug、包括不影响外部接口的内部小修。 所有后续工作,包括在旧仓库里发现的 bug,都只落在 OJ2:先确认 OJ2 是否有对应逻辑、 是否重现了同样的问题,只在 OJ2 里修;旧仓库那边如实告知用户"未处理,按当前政策 不动旧仓库",不要顺手改掉。冻结的理由现在只剩「留作参照、别分散精力」, 不再是回滚保证。
设计文档:docs/specs/2026-08-06-bun-backend-rewrite-design.md
切换手册:docs/specs/phase5-cutover-runbook.md ← 上线当天照这份走
仓库结构
| 目录 | 作用 |
|---|---|
apps/api/ |
后端。Hono + Drizzle + BullMQ,编译成单二进制 |
apps/web/ |
前端。从 ojnext 原样搬来的 Vue 3 SPA |
packages/contract/ |
前后端共用的 Zod 契约 |
docker/ |
Dockerfile + 三套 compose(dev / debian / school) |
docs/specs/ |
设计、端点清单、各阶段评审报告与演练报告 |
本机环境
Docker 可用,全套依赖都能在本机跑起来(PostgreSQL、Redis、判题沙箱), 镜像也能在本机构建并完整演练上线。这一点和上一代不同,别沿用"本机跑不起来后端" 的旧假设。
bun install
bun run db:up # 起 postgres(5433) / redis(6380) / 判题沙箱(8081)
bun run dev # api(3000) + worker + web(5173) 一起起
首次要先建 .env(照 .env.example)。判题机 token 两边必须一致:
.env 的 JUDGE_SERVER_TOKEN 和 docker/.env 的 OJ2_JUDGE_TOKEN。
常用检查:
bunx tsc --noEmit -p apps/api # 后端类型检查
bun run --filter '@oj2/api' check:routes # 路由遮蔽检查,加完路由跑一下
cd apps/web && bun run build # 前端构建(vite 不做类型检查,构建即验证)
不要写测试 —— 沿用上一代的项目约定。验证靠实跑:起服务、打接口、看结果。
几件必须知道的事
单二进制是有代价的
apps/api 编译成 bun build --compile 的单二进制,所以运行时不能依赖
node_modules。任何 require.resolve / Bun.resolveSync / __dirname 去找文件的
写法,本地都正常、编译后都会炸,而且只在离开仓库目录后才炸(在仓库里跑时它顺着
cwd 摸到了 node_modules,假装没事)。
资源要用 with { type: "file" } 内嵌。.node 原生模块还要额外注意:这个写法
只有打包器认、bun run 不认,所以必须按形态分叉 —— 见 apps/api/src/vendor/jieba.ts
的注释,那里把坑写全了。
改完这类代码,dev 和编译两种形态都要跑一遍。 我吃过亏:只验了编译产物, dev 直接起不来。
路径解析看 runtime.ts
编译后 import.meta.dir 恒为 /$bunfs/root,往上三级就是文件系统根。
相对路径一律走 runtime.ts 的 pathBase,别自己拼。
SQL 判题会 spawn「自己」
judge/sql/index.ts 起的子进程是二进制自身 + sql-child 子命令(因为编译后磁盘上
没有 child.ts 可以 spawn)。所以入口必须有 argv 分发,否则「起自己」变成
「把整个程序再跑一遍」→ 指数级 fork。这不是假想,开发时炸过一次开发机。
OJ2_SQL_CHILD 那道递归闸不要删。
加路由要防遮蔽
Hono 按注册顺序匹配,不是静态优先(实测确认过,别凭直觉)。/problems/:id
注册在 /problems/random 前面的话,后者永远进不去 —— 而且不报错、不警告,
只是静默走进前一条的 handler。阶段 4 真实发生过一次,两个教师用的分析端点被吃掉,
一直到评审才发现。
加完路由跑 bun run --filter '@oj2/api' check:routes。
判题状态码要三处同步
apps/api/src/judge/status.ts、apps/web/src/utils/constants.ts、
以及上一代的 ../OnlineJudge/submission/models.py(回滚时要对得上)。
题目表情 reaction 的语义 key 同理。
比赛只有 ACM 模式
没有 OI。上一代残留的 OI 分支在阶段 0 已经砍掉,不要"顺手补回来"。
前端要兼容老 Chrome
机房电脑 Chrome < 94。mermaid-legacy 等 fallback 依赖和 vite 的构建 target
不能动,vite.config.ts 里有注释说明。
数据库
Drizzle schema 是从生产库 drizzle-kit pull 出来的,不写迁移。
新旧后端跑在同一套表结构上(阶段 5 演练逐列比对过,零差异),这是回滚能成立的前提 ——
所以改 schema 前先想清楚回滚怎么办。
改 schema 走 drizzle migration
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,
需要确认备份后显式放行:
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 标记成已执行:
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,一般不用纠结。
部署
三套 compose 在 docker/:dev(本机)、debian(服务器)、school(机房)。
机房那套没有 postgres,连的是服务器的库。 两个站点共用一个数据库, 但各有各的 Redis 和判题沙箱 —— 所以上线那天两边必须一起切。
compose.debian.yml 有两种形态,靠 env 切换:
- 只换前后端(上线用这个):设
DATA_DIR/DB_HOST/REDIS_HOST, 沿用旧栈已经在跑的 postgres 和 redis,只起 api / worker / web / judge。 - 自带数据(本机、演练):不设那几个变量,起栈时加
--profile local-data。 - 并行试跑(上线前先挂
oj2.xuyue.cc跑几天):在「只换前后端」基础上再加WEB_PORT(8080 被旧 backend 占着)和JUDGE_STATE_DIR(两个判题机不能共用运行目录)。 这种形态下旧栈一个容器都不用停,正式切换退化成改一行 NPM 上游。
⚠️ DATA_DIR 默认值 ../data 是 OJ2/data,不是部署目录的 data/。
沿用旧数据却忘了设它,会静默起一套空数据(空库、没测试点、图片 404),
而且不报错 —— 这是切换当天唯一会静默走歪的地方。
细节和演练结果都在 docs/specs/phase5-cutover-runbook.md。