Files
OJ2/CLAUDE.md
yuetsh 586c88f629
Some checks failed
Deploy / deploy (push) Has been cancelled
build(数据库): 改用 drizzle migration,加提交列表索引、清掉 Django 残留
一条线上的三件事:让 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>
2026-08-26 07:56:30 -06:00

206 lines
11 KiB
Markdown
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.
# 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 + 三套 composedev / debian / school |
| `docs/specs/` | 设计、端点清单、各阶段评审报告与演练报告 |
## 本机环境
**Docker 可用,全套依赖都能在本机跑起来**PostgreSQL、Redis、判题沙箱
镜像也能在本机构建并完整演练上线。这一点和上一代不同,别沿用"本机跑不起来后端"
的旧假设。
```bash
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`
常用检查:
```bash
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
需要确认备份后显式放行:
```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一般不用纠结。
## 部署
三套 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`