From 79ca8ac3bd67fda935a6b05509814d5593756440 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Wed, 30 Sep 2026 10:51:54 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E8=BF=90=E7=BB=B4):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=20oj2-api=20rediagnose=EF=BC=8C=E6=97=A7=E6=8F=90=E4=BA=A4?= =?UTF-8?q?=E7=9A=84=E8=BF=90=E8=A1=8C=E6=97=B6=E9=94=99=E8=AF=AF=E8=AF=B4?= =?UTF-8?q?=E6=98=8E=E4=B9=9F=E8=83=BD=E7=94=A8=E4=B8=8A=E6=96=B0=E8=A7=84?= =?UTF-8?q?=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 诊断是判题时写进库的,规则改了旧提交不跟着变。这个子命令把库里的诊断按现在的规则重做: Python 只重做已经有诊断的(重跑第一个失败测试点拿回溯,所以要在线上 oj-api 容器里跑), C / C++ 用 info 里存着的信号和退出码直接补上(原来一条都没有)。比赛提交不诊断, 重跑不出来的保留原样,只写 runtime_error 这一个键。默认只读预演,--apply 才写。 本机验过:dev 与编译两种形态都跑通,--apply 之后再跑是 0 条变化。 Co-Authored-By: Claude Opus 5.5 --- apps/api/package.json | 1 + apps/api/src/judge/run.ts | 2 +- apps/api/src/main.ts | 8 ++- apps/api/src/scripts/rediagnose.ts | 104 +++++++++++++++++++++++++++++ 4 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/scripts/rediagnose.ts diff --git a/apps/api/package.json b/apps/api/package.json index a2cb24b..b5d8367 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -12,6 +12,7 @@ "build": "bun build --compile --target=bun-linux-x64 src/main.ts --outfile ../../dist/oj2-api", "seed:dev": "bun src/scripts/seed-dev.ts", "recount": "bun --env-file=../../.env src/main.ts recount", + "rediagnose": "bun --env-file=../../.env src/main.ts rediagnose", "typecheck": "tsc --noEmit", "check:routes": "bun src/scripts/check-route-shadowing.ts", "check:ast": "bun src/scripts/check-ast-targets.ts", diff --git a/apps/api/src/judge/run.ts b/apps/api/src/judge/run.ts index 34088be..5dd1cae 100644 --- a/apps/api/src/judge/run.ts +++ b/apps/api/src/judge/run.ts @@ -371,7 +371,7 @@ export async function failAbandonedSubmission(submissionId: string, error: unkno * * 诊断失败(读不到测试点、判题机没回回溯)就不写这个字段,学生看到的和原来一样。 */ -async function diagnoseRuntimeError( +export async function diagnoseRuntimeError( row: { submission: typeof schema.submission.$inferSelect problem: typeof schema.problem.$inferSelect diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 39faa96..8ab06aa 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -13,6 +13,7 @@ * oj2-api sql-child # SQL 判题子进程,由服务自己 spawn,不该手动调 * oj2-api migrate # 执行待办的数据库迁移,部署时由 docker/deploy.sh 调 * oj2-api recount # 把题目/用户的计数列、成就的已解锁数重算回与明细一致,默认只读预演 + * oj2-api rediagnose # 运行时错误的诊断按现在的规则重做,默认只读预演 * * 用动态 import 而非顶层 import:这几个模块都有导入即执行的副作用 * (Bun.serve、连 Redis 开消费者),静态导入会让 sql-child 也把整个服务拉起来。 @@ -40,6 +41,11 @@ switch (command) { const { recount } = await import("./scripts/recount") process.exit(await recount({ apply: process.argv.slice(3).includes("--apply") })) } + // 同上:诊断规则改了之后,把库里已有的诊断重做一遍(要连判题机和测试点,只能在线上跑) + case "rediagnose": { + const { rediagnose } = await import("./scripts/rediagnose") + process.exit(await rediagnose({ apply: process.argv.slice(3).includes("--apply") })) + } case "sql-child": { const { runSqlChild } = await import("./judge/sql/child") await runSqlChild() @@ -61,7 +67,7 @@ switch (command) { } default: console.error( - `未知子命令:${command}\n可用:serve | worker | migrate | recount | healthcheck | sql-child`, + `未知子命令:${command}\n可用:serve | worker | migrate | recount | rediagnose | healthcheck | sql-child`, ) process.exit(2) } diff --git a/apps/api/src/scripts/rediagnose.ts b/apps/api/src/scripts/rediagnose.ts new file mode 100644 index 0000000..a48df30 --- /dev/null +++ b/apps/api/src/scripts/rediagnose.ts @@ -0,0 +1,104 @@ +import { and, eq, inArray, isNull, sql } from "drizzle-orm" + +import { db, schema } from "../db" +import { diagnoseRuntimeError, templateForLanguage, type JudgeCase } from "../judge/run" +import { JudgeStatus } from "../judge/status" +import { parseProblemTemplate } from "../judge/template" +import { asRecord } from "../routes/helpers" + +/** + * 把库里运行时错误的诊断(`statistic_info.runtime_error`)按现在的规则重做一遍。 + * + * 诊断是判题时写进库的,规则改了旧提交不会跟着变。2026-09 那次把 TypeError 的归类从 + * 12 种扩到 30 多种,库里已有的诊断没有新的 kind,学生翻旧提交看到的还是兜底话。 + * + * - **Python**:只重做**已经有诊断**的(诊断功能上线之前的提交没存过,不去补 —— + * 那是九千多次重跑,而且学生早就不看了)。和判题时一样,重跑第一个失败测试点拿回溯, + * 所以**要在能读到测试点、连得上判题机的地方跑**,也就是线上的 oj-api 容器。 + * - **C / C++**:诊断只是失败测试点的信号和退出码,`info` 里本来就存着,不用重跑, + * 全部补上(原来一条都没有)。 + * + * 比赛提交不诊断,和判题时同一个口径。重跑失败(测试点没了、判题机没回回溯)的保留原样。 + * + * 默认只读,打出变化;确认无误再加 --apply 落库: + * + * docker compose -f docker/compose.debian.yml run --rm oj-api oj2-api rediagnose + * docker compose -f docker/compose.debian.yml run --rm oj-api oj2-api rediagnose --apply + * + * 重跑走的是判题机,挑没人做题的时候跑。只写 runtime_error 这一个键,不碰别的。 + */ +/** jsonb 读回来的键顺序和新拼的对象不一样,按排好序的键比。诊断是一层的平对象 */ +function sameKeys(value: unknown) { + const record = asRecord(value) + return value ? JSON.stringify(record, Object.keys(record).sort()) : "null" +} + +export async function rediagnose({ apply }: { apply: boolean }) { + const rows = await db + .select({ submission: schema.submission, problem: schema.problem }) + .from(schema.submission) + .innerJoin(schema.problem, eq(schema.problem.id, schema.submission.problemId)) + .where( + and( + eq(schema.submission.result, JudgeStatus.RUNTIME_ERROR), + isNull(schema.submission.contestId), + inArray(schema.submission.language, ["Python", "C", "C++"]), + sql`(${schema.submission.language} <> 'Python' or ${schema.submission.statisticInfo} ? 'runtime_error')`, + ), + ) + console.log( + `${rows.length} 条运行时错误要重新诊断${apply ? "" : "(只读预演,加 --apply 才写)"}`, + ) + + const changes = new Map() + let changed = 0 + let failed = 0 + for (const [index, row] of rows.entries()) { + const cases = asRecord(row.submission.info).data + const failure = (Array.isArray(cases) ? (cases as JudgeCase[]) : []) + .toSorted((left, right) => Number(left.test_case) - Number(right.test_case)) + .find((item) => item.result !== JudgeStatus.ACCEPTED) + + const rawTemplate = templateForLanguage(row.problem.template, row.submission.language) + const template = rawTemplate ? parseProblemTemplate(rawTemplate) : null + const source = template + ? `${template.prepend}\n${row.submission.code}\n${template.append}` + : row.submission.code + const prependLines = template ? template.prepend.split("\n").length : 0 + + const next = failure + ? await diagnoseRuntimeError(row, source, prependLines, failure).catch(() => null) + : null + const previous = asRecord(row.submission.statisticInfo).runtime_error + if (!next) { + failed++ + } else if (sameKeys(next) !== sameKeys(previous)) { + changed++ + const label = (value: unknown) => { + const info = asRecord(value) + if (!value) return "(无)" + if (info.type) return `${String(info.type)}/${String(info.kind ?? "-")}` + return `信号 ${String(info.signal ?? 0)} 退出码 ${String(info.exit_code ?? 0)}` + } + const key = `${row.submission.language} ${label(previous)} → ${label(next)}` + changes.set(key, (changes.get(key) ?? 0) + 1) + if (apply) { + await db + .update(schema.submission) + .set({ + statisticInfo: sql`${schema.submission.statisticInfo} || jsonb_build_object('runtime_error', ${JSON.stringify(next)}::jsonb)`, + }) + .where(eq(schema.submission.id, row.submission.id)) + } + } + if ((index + 1) % 50 === 0) console.log(` ${index + 1} / ${rows.length}`) + } + + for (const [key, count] of [...changes].sort((left, right) => right[1] - left[1])) { + console.log(`${String(count).padStart(5)} ${key}`) + } + console.log( + `变了 ${changed} 条${apply ? ",已写入" : ""};没变 ${rows.length - changed - failed} 条;诊断不出来、保留原样 ${failed} 条`, + ) + return 0 +}