Files
OJ2/CLAUDE.md
yuetsh f6995c841b ops(成就): 加 fix-achievement-hours,订正时区丢失期间误发的「夜猫子」「早起的鸟儿」
OJ2 上线到时区修复之间,成就的小时键按 UTC 判定:UTC 的 0–5 点 / 5–7 点是北京
的上午 9–13 点 / 下午 1–3 点,上课时间的提交被记成熬夜和早起。Django 时代的存量
本来就是东八区口径(已用生产备份做判别性核对),出问题的只有这两周的增量。

脚本按东八区重算两个小时指标(只合并这两个键,不整体覆盖 metrics)→ 撤回不达标
的 → 补发达标却没发的 → 同步 unlock_count → 校正已解锁数与「奖杯收藏家」连锁。
默认只读预演,--apply 落库后自动复核,幂等。

用 db_backup_2026_09_14_18_17_37.sql 实跑:修正 148 行、撤回 60 条(47 + 13,
50 人)、补发 0、连锁 0,其余指标 0 行被动。必须先部署时区修复再跑。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1d8B3f4SXJwDvUY625eQd
2026-09-14 05:55:27 -06:00

29 KiB
Raw Blame History

CLAUDE.md

OJ2 是判题狗Online Judge的后端重写Django 6 → Bun + TypeScript前后端同仓。 上一代在 ../OnlineJudge/Django../ojnext/Vue SPA仍然完全冻结、 一行都不改

旧栈已不可逆地下线。 0002_drop_django_leftovers 删掉了 Django 的框架表 django_session 等),且已在生产库执行完毕。所以「停新栈起旧栈」「把 NPM 上游 改回 8080」都已失效唯一退路是从数据库备份恢复 —— 切换手册里的「回滚保证」 那节只剩历史价值。

生产库上 0002 有一张没删干净0 行的 django_migrations,来源已无法复原), 由 0014_drop_django_migrations 补删,前因后果写在那个迁移文件的注释里。

旧仓库仍然零改动,没有例外——包括修 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、判题沙箱 镜像也能在本机构建并完整演练上线。这一点和上一代不同,别沿用"本机跑不起来后端" 的旧假设。

bun install
bun run db:up          # 起 postgres(5433) / redis(6380) / 判题沙箱(8081)
bun run dev            # api(3000) + worker + web(5173) 一起起

首次要先建 .env(照 .env.example)。判题机 token 两边必须一致: .envJUDGE_SERVER_TOKENdocker/.envOJ2_JUDGE_TOKEN

常用检查:

bun run --filter '@oj2/api' typecheck         # 后端类型检查
bun run --filter '@oj2/api' check:routes      # 路由遮蔽检查,加完路由跑一下
bun run --filter '@oj2/api' check:ast        # AST 节点类型检查,升级 tree-sitter 后跑
cd apps/web && bun run type-check             # 前端类型检查
cd apps/web && bun run build                  # 前端构建

⚠️ 前端类型检查只能走 bun run type-check 这个脚本。 两条看起来等价的路子 都会静默通过vue-tsc --noEmit -p tsconfig.json 检查 0 个文件(那个 tsconfig 是 files: [] + references 的壳,真正的配置在 tsconfig.app.jsonvite build 根本不做类型检查。改完 .vue / .ts 别拿构建当验证。

不要写测试 —— 沿用上一代的项目约定。验证靠实跑:起服务、打接口、看结果。 本机 Docker 全套都能起,实跑的成本比想象中低。

几件必须知道的事

单二进制是有代价的

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.tspathBase,别自己拼。

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.tsapps/web/src/utils/constants.ts 必须一致。 这些整数是落库的值12 万条历史提交的 submission.result 就是它们,判题沙箱回的也是 这套编码,所以只能新增、不能改已有的含义。题目表情 reaction 的语义 key 同理。

出参不 parse,用 satisfies

后端的响应一律 satisfies XxxType,不要写 xxxSchema.parse({...}) 出参是后端自己刚拼出来的字面量TS 已经在编译期校验过;再 parse 一遍拿不到任何新 信息,唯一可能失败的输入是库里的历史数据,而失败的代价是 500。这一层原来有 136 处, 已经全部撤掉,撤的时候当场炸出两个一直存在的线上 500

  • adminProblemSchema.lastUpdateTime 写的是 z.string(),但 problem.last_update_time 是全库唯一可空的列961 道题里 470 道是 NULL——后台打开任何一道没编辑过的老题都是 500
  • embeddedSubmissionSchemasubmissionDetailSchema 继承了 problemDisplayId 却没 omit而路由只填了同义的 problem——凡是收到过站内信的人,消息页都打不开(列表为空 时才碰巧不炸,所以一直没人报)。

两个都是「读出侧校验」自己造出来的故障,不是它拦住的故障。历史上还有两次同类: exerciseSchema 按题型收紧后一行脏数据让整条练习列表 500info 写成 union([完整形状, z.object({})]) 后对不上的一律落进空对象那支且 parse 成功 管理员详情页的测试点表格静默消失(全量核出 9163/124192 条中招RE 8480/8480 全中—— 沙箱在非正常退出的测试点上写 output_md5: null,而契约写的是 z.string())。

闸设在写入侧,一共三处形态:

  1. 入参 safeParse58 处,全部保留)—— 请求体进来的那一刻校验,对不上回 400。
  2. db/schema.ts.$type<>() —— 枚举型的列(submission.result / .languageproblem.difficulty / .languagesachievement.rarityexercise.type…)和几个 形状确定的 JSONBproblem.template / .astRules / .sqlConfig / .sqlDisplayacm_contest_rank.submission_info)直接在列上收窄,只影响 TS、不产生任何 SQL。 这些断言逐列拿根目录那份生产备份核过12.4 万条提交的 result 全在 -2..6,10、 961 道题的 languages 全是合法数组、10050 条榜单条目形状全对)。 加这类断言前先照样核一遍,别凭直觉。
  3. 语义校验函数 —— astRulesError()services/exercise.tsexerciseDataError

JSONB 原文(submission.info / statistic_info / exercise.data)仍然一律放行 读出侧不收窄:它们的形状真相在判题机那边。

query 里的筛选值要和收窄过的列比较时走 routes/helpers.tsasFilterValue() —— 那是纯类型交接,不加校验:在那儿拦一道会把「筛出空列表」变成「筛条件被忽略、 返回全部」。前端那侧(utils/contract.ts 为什么只挂三处)见 apps/web/CLAUDE.md

唯一还留着 parse 的地方是 judge/events.tsparseSubmissionEvent —— 那是从 Redis 收回来的报文,真边界,且失败返回 null 而不是 500。

AST 代码规则:一张表,外加一个机器检查

契约的 AST_NODE_TARGETS_BY_LANGUAGE唯一一张表,一个 target 一条 { label, node }label 给后台下拉和题目页,node 给判题机比 tree-sitter 节点类型。 运算符表 AST_OPERATOR_TARGETS_BY_LANGUAGE 一份两用(它的值既是文案又是要比的 token。 判题机侧没有第二张表,解析统一走契约的 astTargetNodeType()

这里原来是两张表:契约那张 target → 中文名,judge/ast.tsmappings 是 target → 节点类型,靠一句「两边必须同增同减」的注释维持。加 target 而漏配节点类型 现在在结构上不可能了,那条注释也就不必再守。

配错仍然可能,而且完全静默:节点类型对不上就是一个都收不到,于是「必须使用 X」 永远失败、「不能使用 X」永远通过两头不报错只有学生受着。所以有

bun run --filter '@oj2/api' check:ast     # 每个 target 的 node 在语法里是否真实存在

升级 tree-sitter-* 依赖之后一定要跑一次 —— 语法改节点名是常事,后果全静默。 加这个检查那天56 个 target 里就抓出一个:f_string 一直配的是 format_string 而这个版本的 tree-sitter-python 根本没有这种节点f-string 是 string 里带 interpolation),所以「不能使用 f-string」从上线起就没生效过。

它只验节点类型存在,不验语义对不对(把 while_loop 配成 for_statement 这种两个都存在,机器看不出来),语义那层还是得实跑。

判题机只认 AST_SUPPORTED_LANGUAGES 里的语言C / C++ / Python3。别的语言配了规则 一条都不会跑,所以后台不给它们开 tab题目页也不把它们的规则展示成「要求」—— 看得见却不检查比没有更糟。

C++ 的语法表是「C 的全集 + C++ 独有的几条」,因为 tree-sitter-cpp 继承 tree-sitter-c C 那 14 个 target 在 C++ 树里逐个实测通用。但调用形态两者不同,加语言时必须一起看: a.push_back()p->push_back() 在 C++ 都是 call_expression + field_expression 不是 Python 的 attributestd::sort(...) 的 function 是 qualified_identifier 而不是 identifier,所以 functionCalls 对 C++ 额外比一次 :: 末段——否则学生写了 using namespace std 与否会得到不同的判定结果。

规则的语义校验在 astRulesError(),不在 zod 的 refine 上:astRulesSchema 同时用于 后台题目详情,在读路径上抛错会让历史脏数据把整个题目详情打不开。同理,保存前 先 pickAstRules() 剔除够不着的分组再校验,否则早年配过 C++ 规则的题会把老师锁死 ——tab 里看不到那组规则,保存却被拦下。

比赛只有 ACM 模式

没有 OI。上一代残留的 OI 分支在阶段 0 已经砍掉,不要"顺手补回来"。

前端要兼容老 Chrome

机房电脑 Chrome < 94。mermaid-legacy 等 fallback 依赖和 vite 的构建 target 不能动,vite.config.ts 里有注释说明。

时间只有一个锚点:apps/api/src/time.ts

凡是要把一个时刻换算成「哪一天 / 几点 / 哪一年」,一律走那个模块。 不要写 new Date(x).getHours()setHours(0,0,0,0)getFullYear()new Date(y, m, d) 这类跟进程时区走的代码 —— 容器是 UTC、开发机是本机时区 两边答案不同而且不报错。SQL 里要按日历切,显式写 at time zone ${TIME_ZONE_SQL},别依赖数据库会话时区。

旧栈 Django 是 TIME_ZONE = "Asia/Shanghai" + USE_TZ = True:库里存 UTC、 应用层按北京时间算日历。重写时这个锚点丢了,直到 2026-09 才收回来 —— 期间 「今日提交」在北京时间 0:008:00 是空的两个成就「凌晨提交次数」0:005:00、 「早起提交次数」5:007:00整体偏 8 小时。别再把口径散出去。

实现上按固定偏移算(大陆 1991 年起没有夏令时),不查 tzdata、不用 Intl 所以 dev / 编译产物 / 任何镜像基底都算得一样。Dockerfile 里的 TZ=Asia/Shanghai 和数据库连接上的 TimeZone 都只是兜底,不是依据。

分层:存 UTC 时刻 → 后端判定按东八区 → 出参 ISO UTC → 前端按东八区渲染。

  • 35 个时间列全是 timestamptzwithout time zone 0 个、date 0 个), 写侧一律 new Date().toISOString()。库里永远是绝对时刻,换时区不用动数据。
  • 判定:日历语义(哪一天/几点/哪一年)走 time.tsSQL 显式 at time zone
  • 出参db/index.ts 给 OID 1184 挂了 parser所有读出来的时刻统一成 ISO 8601 UTC2026-09-14T12:00:00.000Z,库里带微秒的保留成 …00.123456Z)。 别在这里退回去 —— 原来 drizzle 把 1184 的 parser 换成了恒等函数,读出来是 PG 文本(2026-09-14 20:00:00+08),于是同一个字段在 接口上有两种形状实测同一批端点PG 文本 77 处 + ISO 14 处),对接外部系统时对方 得解析两套,而 +08 还取决于服务器会话时区、不该进契约。 ⚠️ 微秒不能丢,别改回 new Date(v).toISOString():读出的时刻常被原样塞回查询条件 (提交列表翻页的分界行、班级 AC 排名的 <= min(create_time)),截成毫秒后分界行自己 被排除 —— 翻页每页丢一条、排名少 1。生产库 12.3 万条 Django 时代的提交几乎全带微秒。 ⚠️ ::text 的 OID 是 25、绕过那个 parser,所以「为了拿回和列一样形状」而写的 max(join_time)::text 之类现在会变成异类,见到就撤掉。只换 1184别碰 1082(date) —— date(... at time zone ...) 要的是 2026-09-14,套上 toISOString() 就错了。
  • 渲染:前端 parseTime()IntltimeZone: "Asia/Shanghai"(见 apps/web/CLAUDE.md)。三条解析路径(new Date / date-fns parseISO / VueUse normalizeDate)实测都能吃 ISO改动出参格式不需要动前端。

存量成就的口径是东八区,别再退回 UTC

2026-09-14 用生产备份 db_backup_2026_09_08_19_22_11.sql 实测过,结论和直觉相反, 记在这里省得下次重新推。下面「那两周留下的实际后果」和脚本的跑数,又用 db_backup_2026_09_14_18_17_37.sql(最新提交到北京时间 9-14 17:06逐项复核过一致。

历史指标本来就是北京时间。 旧栈 OnlineJudge/achievement/metrics.py 全程用 timezone.localtime(...),而 settings.TIME_ZONE = "Asia/Shanghai",所以 2022-04 到 OJ2 上线之间那 10 万多条提交累积出的 user_stat.metrics东八区口径。 判别性核对(只在新旧口径算出不同值的用户里看存量更像哪边):

指标 两口径不同 存量==UTC 存量==东八区
midnight_submissions 1259 132 1123
early_bird_submissions 909 1 905
active_days 90 0 90
max_ac_in_one_day 17 0 17
max_ac_streak_days 40 0 40

所以修 OJ2 的时区不是「换口径」,是「把 OJ2 弄丢的口径补回来」。 别以为改动会让 存量数据失配 —— 失配的是 OJ2 上线后那两周,修完反而对齐了。

那两周留下的实际后果(截至 2026-09-14 备份1508 条 OJ2 期提交、约 1500 个已结算用户):

  • 日期键没被污染_active_dates / _ac_per_day 一个都没偏 —— 上课时间的提交 在 UTC 下日期和北京是同一天。所以 5 个日期口径的成就(活跃天数、单日最多 AC、 连续天数)一条都没错。
  • 只有小时键被污染145 人 midnight_submissions 虚高、45 人 early_bird_submissions 虚高。因为 UTC 的「凌晨 05 点」正好是北京的上午 913 点,而学生恰恰在上课时间提交。
  • 结果是 60 条误发47 个「夜猫子」+ 13 个「早起的鸟儿」,涉及 50 人,全是 OJ2 时期的新账号(backfilled = false)。
  • 0 条漏发而且是结构性的OJ2 窗口那 1508 条提交里,真正落在北京 05 点和 57 点的都是 0 条 —— 真熬夜、真早起的人都在 Django 时代活跃过了,他们的成就 是当时按东八区正确发的。这个 bug 只会多给,不会少给。

改数据时最大的坑:不能只删 user_achievement unlockAchievements() 的判定是纯阈值比较metrics[metric] >= threshold),不是 「这次有没有跨过阈值」。只删行、不修 user_stat.metrics 的话,学生下一次提交就把 同一个成就原样再发一次。必须「按东八区重算小时指标」和「对账发放」一起做。

现成的工具是一次性对账脚本 apps/api/src/scripts/fix-achievement-hours.ts bun run --filter '@oj2/api' fix:achievement-hours,默认 dry-run--apply 才写): 重算两个小时指标 → 撤回不达标的 → 补发达标却没发的 → 同步 achievement.unlock_count → 校正 achievement_unlocked_count 与「奖杯收藏家」连锁。实测幂等2026-09-14 那批 跑出来是「修正 148 行 · 撤回 60 条 · 补发 0 条 · 连锁 0 条」。

⚠️ 顺序:先部署时区修复,再跑这个脚本。 反过来的话,旧代码还在按 UTC 累加, 跑完马上又被写脏、成就又发回来。

下次再动日历口径,照这套方法核实:从生产备份里捞出 submission / user_stat / user_achievement / achievement 四张表回放一遍,先用与时间无关的指标 submission_count / accepted_count)校准重放器(实测逐人 0 差异 / 4 人差异), 再比受影响的指标。别靠推理 —— 这次推理就得出过相反的结论。

数据库

Drizzle schema 最初是 drizzle-kit pull 从生产库拉出来的,所以它长得像 Django 建的表 表名、bigint/int4 混用),schema.ts 顶部记了哪些地方是手工修的。

外键的删除动作从 0010 起是显式的,不再是 Django 留下的一律 NO ACTION

  • CASCADE:父行消失后子行必然无意义、且不构成「学生做过什么」的证据 —— 中间表 problem_tags、题单/教程/成就的组成部分、一对一附属user_profile与可重算的 缓存user_stat
  • NO ACTION即拦住:需要人看见的删除 —— submission.problem_id、以及 user 的绝大多数外键。删用户撞外键会被 handler 翻译成「请改为禁用账号」,这是有意的。

加新子表时必须回来想一遍该走哪一档,别默认新外键会自己连坐 —— drizzle 不写 .onDelete() 就是 NO ACTION而 0010 只改了当时存在的那批。

schema 现在归 OJ2 独占。 旧后端已下线,「改 schema 要考虑回滚」这条约束不再存在, 结构变更走下面的 migration 正常演进即可。

改 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.tsmigrationsDir、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 不算——它们不掉数据,拦了只会让人习惯性带上放行开关。 空库自举时这道闸不生效没有数据可丢0002 那串 DROP ... IF EXISTS 全是空转, 拦下来只会逼每个新环境都带一次放行开关,把它训练成习惯动作。

放行的三条路,别记错:

  1. 服务器上手工部署:OJ2_ALLOW_DESTRUCTIVE=1 docker/deploy.sh
  2. CI.github/workflows/deploy.yml必须先手工触发并在 workflow_dispatch 上勾 allow_destructive。push 触发拿不到这个 input值恒为空 —— 也就是说自动部署永远不会执行破坏性迁移,只会停在闸门上把工作流判红。 这是有意的:那种改动得有人先确认备份。
  3. 先单跑迁移把结构推到位,再 push 代码:迁移一旦记进 drizzle.__drizzle_migrations 就不会再跑,后续自动部署里它已不是 pending 自然不触发闸门。多环境共库时(机房 + 服务器)推荐这条。

空库能自举了。 oj2-api migrate 指向一个空库时直接从 0000 建起:

DATABASE_URL=postgres://... oj2-api migrate
# 空库,从 0000 开始自举。
# 待执行 15 条迁移,开始。
#   ✓ 0000_crazy_gateway
#   ✓ 0001_add_submission_public_create_time_idx
#   ✓ 0002_drop_django_leftovers
#   …
#   ✓ 0014_drop_django_migrations

0000_crazy_gateway.sql 原本是 drizzle-kit pull 的产物、整份被 /* */ 包着、可执行 语句 0 条,所以以前新库只能先手工 psql -f docs/specs/schema.sql。现在它的内容由那份 生产 dump 机械转换而来(去掉 psql 专有指令、去掉 7 张 Django 遗留表及其索引外键, 其余原样保留)。实测:空库自举出来的结构,和「灌 schema.sql + 打基线 + 跑迁移」 这条老路子跑出来的结构,pg_dump --schema-only 逐字节一致734 行,零差异)。

改 0000 对生产库没有影响 —— migrator 只比 created_at从不校验 hash pg-core/dialect.js 里就一句 Number(lastDbMigration.created_at) < migration.folderMillis 而生产库那行 baseline-0000-faked 早把它挡在门外了。

⚠️ 0000 的注释里不要出现 statement-breakpoint 那个分隔标记的字面量。 readMigrationFiles 是纯文本切分,不管它在不在注释里,照切不误 —— 注释被从中间切开, 后半截当成 SQL 发出去,报的是 syntax error at or near "。" 这种和真实原因毫不相干的错。

给一个已经存在的库做基线drizzle 没有 --fake-initialmigrate 见到空的 __drizzle_migrations、库里却已经有表,会拒绝执行并 exit 3裸跑 drizzle-kit migrate 的话则是从 0000 撞上已存在的表、整个事务回滚,而且 exit 1 却一个错误都不打印)。 对已有数据的库第一次跑之前,先手插一行把 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.jsonpull 出来的,没法无损还原 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。 已改成字符串。
  • 表达式索引的 opclassproblem_tag_name_ci_unique 在快照里带 opclass,但 drizzle 自己序列化不出来,导致每次都 drop + recreate。已从快照里去掉。

还有一个写代码时要绕开的

  • .op() 会吞掉索引方向:真正的根因不是 .desc(),是 opclass。drizzle-kit 的 CreatePgIndexConvertor 里那个三元一旦走进 opclass 分支就回不到方向分支: ${it.opclass ? ${it.opclass} : it.asc ? "" : " DESC"}。而 drizzle-kit pull每一列都挂了 .op(...),所以本仓库里"写了 .desc() 却生成不出 DESC"每次都会重演。

    要方向就别写 .op() 不写没有任何代价——int4_ops / timestamptz_ops 本来就是 这些类型的默认 opclass写了等于没写。实测drizzle-kit 0.31.10,探针索引跑过 generate

    schema.ts 生成的 SQL
    .desc().nullsFirst().op("timestamptz_ops") "create_time" timestamptz_ops ← 方向丢了
    .desc().nullsFirst() "create_time" DESC NULLS FIRST
    .desc() "create_time" DESC NULLS LAST

    所以多列混合方向的索引可以正常 generate,不必手写。

    假 diff 的机制也要理解对:带 .op() 时快照记的是 asc: falseSQL 建出来却是 ASC 分歧在快照和真实库之间,不在快照和 schema.ts 之间——所以再跑 generate 是干净的, 要等到下次 pull 才炸出来。这是当初难定位的原因。

迁移执行器是自己的,不是 drizzle 那个

db/migrate.ts 不调用 drizzle 的 migrate(),自己按 journal 逐条执行。换掉它是因为 pg-core/dialect.js 里那个实现有两条硬伤:

  1. 所有待执行的迁移共用一个事务,第 3 条失败会把第 1、2 条一起回滚。现在是一条一个 事务,语义和 Django migrate 一致,失败时也说得清库停在哪儿。
  2. 正因为全在事务里,CREATE INDEX CONCURRENTLY 一律跑不了,没有开关。

记账行的写法和 drizzle 完全一致(hash = 整个文件的 sha256created_at = journal 的 when),而 migrator 只比 created_at、不校验 hash所以两套执行器可以互换不会看不懂 对方写的记录。

CREATE INDEX CONCURRENTLY 现在能跑了。 在迁移文件第一行写上标记:

-- oj2:no-transaction
CREATE INDEX CONCURRENTLY "xxx_idx" ON "submission" USING btree ("language");

这条迁移就走裸执行(简单查询协议,不包事务)。代价是没有回滚:中途失败时前面的语句 已经生效,而且 CONCURRENTLY 失败会在库里留下一个 INVALID 索引,要先 DROP INDEX 再重来(select indexrelid::regclass from pg_index where not indisvalid 能找出来)。所以这种迁移一个文件只放一条语句

要不要用是另一回事:参考量级是 12.3 万行的部分索引,普通 CREATE INDEX 只锁 74ms 一般不用纠结CONCURRENTLY 留给真扛不住锁写窗口的场合。

退出码2 = 配置/文件问题3 = 基线不对4 = 撞上破坏性迁移5 = 某条迁移执行失败。

部署

三套 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_PORT8080 被旧 backend 占着)和 JUDGE_STATE_DIR(两个判题机不能共用运行目录)。 这种形态下旧栈一个容器都不用停,正式切换退化成改一行 NPM 上游。

⚠️ DATA_DIR 默认值 ../dataOJ2/data,不是部署目录的 data/。 沿用旧数据却忘了设它,会静默起一套空数据(空库、没测试点、图片 404 而且不报错 —— 这是切换当天唯一会静默走歪的地方。

细节和演练结果都在 docs/specs/phase5-cutover-runbook.md