Files
OJ2/docs/specs/phase3-coverage.md
yuetsh fb22b7d49f docs(阶段5): 用生产快照跑完整切换演练
演练用 compose.debian.yml **本身**在本机 Docker 里跑,不是简化版。
数据是 2026-08-07 的 pg_dumpall 快照:1710 用户 / 956 题 / 123140 提交。

## 出口标准达成

停旧栈 11s,起新栈 34s(镜像预先构建好),全链路验证约 2 分钟 ——
**停机不到 1 分钟**,远在 30 分钟内。真正的时间风险在构建镜像(首次约 5 分钟),
所以手册里第一条就是「镜像必须在停机窗口之前构建好」。

验证到位的:首页、站点配置、题目列表、标签、公告、登录(argon2 新哈希和
Django pbkdf2 旧哈希都支持)、个人页、排行榜、后台四个接口、判题机自动注册,
以及**完整判题**(提交 Python A+B → AC,1.2 秒,两个测试点全过)。

## 两件原以为要做、实测不用做的事

- **不需要任何 DDL**:生产 dump 和新后端在用的库逐列对比,两边都是 278 列,
  零差异。新后端直接跑在现有结构上。
- **不需要重置序列**:我在 phase3-coverage.md 里记的那条「切换必做:重置序列」
  **是错的**,来自我手工按显式 id 导入、又没补 setval 的本地库。真实的
  pg_dumpall 带 30 条 setval,且把快照里所有序列和 max(id) 逐个对过,错位 0 个。
  已在原文档上标注更正,没有删掉原文 —— 错误结论本身也是信息。

## 回滚保证已实测

新栈跑完登录、提交、判题之后,再和生产 dump 比一次结构:逐列一致,零差异。
加上数据目录布局照抄旧后端,回滚 = 停新栈 + 起旧栈,约 20 秒,不动任何数据。
(未实测的部分也写明了:本机没构建旧 Django 镜像,「起旧栈」这一步没跑过。)

## 演练抓到的真问题

**pg_dumpall 备份会覆盖数据库口令。** 恢复完快照,新后端立刻报
`password authentication failed` —— 因为 dump 里带
`ALTER ROLE onlinejudge ... PASSWORD 'md5…'`,把角色口令覆盖成了备份时生产的那个。
正常切换不受影响(根本不恢复备份),但灾难恢复时这一条不写下来,
现场会被一个看起来毫不相干的报错卡住。

**恢复备份前必须先停应用**,否则 dump 里的 DROP DATABASE 失败。演练时因为
目标库是空的,数据照样进去了 —— 那是运气,目标库有数据就是满屏主键冲突。

## 镜像体积没达标,写明了原因

api 镜像 487MB,设计文档写的是「数十 MB」。一半以上(269MB)是 clang-format
拖进来的 LLVM,光 libLLVM.so 就 124MB。旧 Python 镜像同样装了 clang-format,
所以新镜像仍明显更小,但当初估「数十 MB」时没把它算进去。
瘦身路径也记了(换静态 clang-format 可砍 265MB),暂不做。

## 清理

演练在 data/postgres 留下了一份完整的生产数据副本,含 1710 名学生的
raw_password 明文列,已删除。手册里留了提醒 —— 那不是测试数据。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 02:13:59 -06:00

12 KiB
Raw Blame History

阶段 3 覆盖率对账

日期2026-08-07首次对账2026-08-07 补记(阶段 3 收口) 基准:docs/specs/endpoint-inventory.md 的 110 条 KEEP 端点 对象:apps/api/src/routes/*.ts 的路由 handler

状态:阶段 3 出口标准已达成

出口标准(设计文档第 11 节)是「用户侧全部功能运行在新后端上」。达成判据: apps/weboj/shared/ 两个目录已无任何指向旧 Django 的运行时调用 残留的 utils/http 引用全是 import type { ApiResponse } 这一个类型。 仍走旧后端的只剩 admin/api.ts85 处)与 utils/download.ts,入口都在后台管理界面,属阶段 4。

收口时补做的三件事:

  1. 补齐 3 条被用户侧页面调用的 admin 端点(重判 / 提交统计 / 流程图统计),见下表;
  2. 删除阶段 1 的临时验证物(GET dev/problemsdev-problems.vue、路由项、problemSummarySchema
  3. utils/api2.ts 补上 login-required 弹登录框、permission-denied 弹提示 —— 这两条 utils/http.ts 一直有api2 从建包起就漏了,导致此前已迁移的所有端点 在鉴权失败时都是「点了没反应」。新加的两个教师专属端点会放大这个问题,故一并补。

补记:两份评审里的 7 条 Minor 也已全部处理,逐条结论见 phase3-fix-list.md 文末。

结论

旧端点 KEEP 新后端已实现 缺口
oj 侧 65 65另有 4 条新增) 0
admin 侧 45 3 42
合计 110 68 42

oj 侧已全部覆盖。 admin 侧原计划整块推到阶段 4但其中 3 条被用户侧页面直接调用, 不做完阶段 3 的出口标准(用户侧全部功能跑在新后端上)就不成立,因此在本阶段一并补上:

旧端点 新路由 调用它的用户侧页面
GET admin/submission/rejudge POST submissions/:id/rejudge oj/submission/list.vue 的重判按钮
GET admin/submission/statistics GET submissions/statistics StatisticsPanel.vue(提交列表页 + 题目页)
GET admin/flowchart/statistics GET flowcharts/statistics FlowchartStatisticsPanel.vue(提交列表页)

这三条虽然挂在旧后端的 admin 路由下,权限也确实是 teacher_admin_required / super_admin_required,但入口在用户侧页面里 —— 「admin 路由」和「admin 页面」不是一回事 按 URL 前缀切阶段会漏掉它们。剩下 42 条的入口都在后台管理界面,留给阶段 4。

对账方法说明:阶段 0 已定案 API 重新设计,新旧路径不同名(如旧 /api/problem → 新 /api/problems、 旧 /api/pickone → 新 /api/problems/random无法按字符串自动匹配。本表由人工按语义逐条比对, 判断依据是路由文件归属 + 路径语义 + HTTP 动词。个别条目的对应关系带主观判断,下表逐条列出以便复核。

oj 侧逐条对照65 条)

旧 app 旧端点 新路由
account login POST auth/login
account logout DELETE auth/session
account register POST users
account profile GET me / GET profiles/:username / PUT me/profile
account profile/fresh_display_id POST me/problem-display-ids/refresh
account metrics GET users/:id/metrics
account upload_avatar POST me/avatar
account user_rank GET rankings/users
account user_activity_rank GET rankings/activity
account user_problem_rank GET problems/:displayId/rank
achievement achievements GET achievements
achievement achievements/summary GET achievements/summary
achievement achievements/pending GET achievements/pending
ai ai/detail GET ai/detail
ai ai/duration GET ai/duration
ai ai/heatmap GET ai/heatmap
ai ai/login_summary GET ai/login-summary
ai ai/pinned GET ai/pinned
ai ai/analysis POST ai/analysis
ai ai/hint POST ai/hint
ai ai/class_pk POST ai/class-pk-analysis
ai ai/class_single POST ai/class-analysis
announcement announcement GET announcements / GET announcements/:id
class_pk class_rank GET rankings/classes
class_pk user_class_rank GET me/class-rank
class_pk class_pk POST classes/comparison
conf website GET site
conf hitokoto GET quotes/random
conf class_usernames GET classes/:className/usernames
conf judge_server_heartbeat/ POST judge-server/heartbeat
contest contests GET contests
contest contest GET contests/:id
contest contest/password POST contests/:id/access
contest contest/access GET contests/:id/access
contest contest_rank GET contests/:id/rank
flowchart flowchart/submissionPOST POST flowcharts
flowchart flowchart/submissions GET flowcharts
flowchart flowchart/submission/retry POST flowcharts/:id/retry
flowchart flowchart/submission/detail GET flowcharts/:id
flowchart flowchart/submission/current GET problems/:id/flowchart/current
message message GET messages / POST messages
problem problem/tags GET problem-tags
problem problem GET problems/:displayId
problem problem/beat_count GET problems/:id/beat-count
problem problem/similar GET problems/:displayId/similar
problem problem/author GET problem-authors
problem problem/yearly_ac GET problems/:displayId/yearly-ac
problem pickone GET problems/random
problem contest/problem GET contests/:id/problems + GET contests/:id/problems/:displayId
problemset problemset GET problem-sets
problemset problemset/<id> GET problem-sets/:id
problemset problemset/<id>/problems GET problem-sets/:id/problems
problemset problemset/progress POST problem-set-progress / PUT problem-set-progress
problemset user/badges GET users/:username/badges
problemset problemset/<id>/badges GET problem-sets/:id/badges
problemset problemset/<id>/users_progress GET problem-sets/:id/user-progress
reaction reaction GET problems/:id/reaction / POST problems/:id/reaction
submission submission GET submissions/:id
submission submissions GET submissions
submission submissions/today_count GET submissions/today-count
submission format_code POST code/format
submission contest_submissions GET contests/:contestId/submissions
tutorial tutorial GET tutorials/:id
tutorial tutorials GET tutorials
tutorial exercises GET tutorials/:id/exercises

新增的 4 条(旧后端没有对应)

新路由 说明
POST submissions 旧后端提交走 POST /api/submission,与 GET submission 同路径不同动词,拆开后成独立条目
PUT submissions/:id 提交分享开关(对齐旧 SubmissionAPI.put + ShareSubmissionSerializer)。判题结果写回走内部 worker不经 HTTP —— 早先这里写成「判题结果写回」,会让人误以为存在一个需要判题机凭据的写入端点
POST achievements/pending/read 成就已读标记
GET problems/:id/flowchart/history 流程图历史
GET dev/problems 阶段 1 的临时验证端点,已删除(连同 dev-problems.vue、路由与 problemSummarySchema

admin 侧缺口42 条,按 app

app 条数
problem 14
problemset 10
conf 5
contest 3
tutorial 3
account 2
achievement 2
ai 1
announcement 1
utils 1

problemproblemset 两块占了 24 条,超过 admin 缺口的一半 —— 排期时应作为主体。 submission 原 2 条、flowchart 原 1 条已在本阶段做完,见上方表格。)

待处理项

  1. 本报告的对照关系带人工判断成分,若某条对应有异议,以实际业务行为为准。
  2. utils/download.ts 仍指向旧后端的 /api/adminblob 下载),只被 admin 侧两个页面用,随阶段 4 一起切。

阶段 5 切换必做项(阶段 4 施工时发现,记在这里以免忘)

2026-08-08 更正:这条不是「切换必做项」,降级为「手工造库时的注意事项」。

阶段 5 演练时实测了真实的 pg_dumpall 备份:里面带 30 条 setval 并且把生产快照里所有序列和 max(id) 逐个对过,错位 0 个。 下面这个现象只出现在我手工按显式 id 导入、又没补 setval 的本地库上, 对正常的备份/恢复不成立。切换当天不用管序列。详见 phase5-cutover-runbook.md

导入数据后必须重置全部序列。 本地库是按显式 id 从生产导入的, problem_tag_id_seq 停在 6 而表里 max(id)=87于是第一次新建标签就撞 duplicate key value violates unique constraint "problem_tag_pkey"500

生产切换若沿用同一个库则不受影响(序列本来就是对的);但只要有任何一步是 「导出 → 导入到新库」,就必须补这一句:

do $$
declare r record; mx bigint;
begin
  for r in
    select split_part(pg_get_serial_sequence(quote_ident(t.table_name), c.column_name), '.', 2) as seqname,
           t.table_name, c.column_name
    from information_schema.tables t
    join information_schema.columns c on c.table_name = t.table_name
    join pg_sequences s on s.sequencename = split_part(pg_get_serial_sequence(quote_ident(t.table_name), c.column_name), '.', 2)
    where t.table_schema = 'public'
  loop
    execute format('select coalesce(max(%I),0) from %I', r.column_name, r.table_name) into mx;
    execute format('select setval(%L, greatest(%s, 1))', r.seqname, mx);
  end loop;
end $$;

症状很隐蔽:读全部正常,只有才炸,而且是导入后第一次写才炸。


SQL 判题链路(阶段 2 补课2026-08-07

阶段 4 做题目管理时才发现:新后端完全没有 SQL 判题。旧后端有 judge/sql_runner.py378 行)+ sql_dispatcher.py113 行),走的是与沙箱完全 不同的路径(跑 SQLite 比结果集)。阶段 2 纵切时只打通了沙箱那条线,漏了这条。

防护为什么换了实现

旧实现靠 Python sqlite3 的三件套。bun:sqlite 一个都没有,实测:

结论
setAuthorizer / setProgressHandler / setLimit 均无
PRAGMA max_page_count 有效
Worker.terminate() 能否停掉跑飞的查询 不能 —— 递归 CTE 死循环卡死整个 worker只能从外面杀进程
node:sqlite 该 Bun 版本不可用

因此改成「WASM 引擎sql.js+ 独立子进程」,逐条替代:

旧防护 新做法 实测
authorizer 禁 ATTACH WASM 无宿主文件系统绑定,结构上够不到 attach '/etc/passwd'unable to open database
authorizer 白名单让查询题只读 PRAGMA query_only=1 查询题里 INSERT → 运行错误并说明
progress_handler 墙钟超时 子进程外部 SIGKILL 递归 CTE 死循环 → CPU 超时
setlimit(LIMIT_LENGTH) 子进程 ulimit -d hex(zeroblob(2e8)) → 内存超限

ATTACH 这条比旧实现更强:旧的靠 authorizer 拦,新的是够不到。

两个踩过的坑

  1. ulimit 必须用 -d 不能用 -v -v 限虚拟地址空间,而 JS 引擎预留巨量地址; 实测 -v 之下 Bun 退出时有概率 panicSIGILL结果早已写出但进程异常终止 父进程读到空串误判成超时 —— 6 次里坏 2 次,时好时坏。换 -d(实际提交内存, Linux 4.7 起也覆盖匿名 mmap后 12/12 稳定。
  2. 子进程写完结果直接 SIGKILL 自己,不走 process.exit() —— 后者仍有一段清理会撞限额。