Files
OJ2/apps/api/src/judge/run.ts
T
xuyue d25b5dbe6d
Deploy / deploy (push) Canceled after 0s
update
2026-09-27 19:36:28 +08:00

582 lines
20 KiB
TypeScript
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.
import { createHash } from "node:crypto"
import { astRuleSchema, type ContestSubmissionInfo } from "@oj2/contract"
import { and, eq, inArray } from "drizzle-orm"
import { config } from "../config"
import { db, schema } from "../db"
import { publishAchievementNotification } from "../events"
import {
updateAchievementsForProblemSet,
updateAchievementsForSubmission,
} from "../services/achievements"
import { recordSolvedProblem } from "../services/problemset"
import { checkAst, type AstRule } from "./ast"
import { publishSubmissionUpdate } from "./events"
import { asRecord } from "../routes/helpers"
import type { JudgeJobData } from "./job"
import { judgeConfigFor } from "./languages"
import { isAccepted, JudgeStatus, type JudgeStatusValue } from "./status"
import { parseProblemTemplate } from "./template"
import { runSqlCase } from "./sql"
import { readInfo } from "../services/test-case"
import { readFile } from "node:fs/promises"
import { resolve as resolvePath } from "node:path"
interface JudgeCase {
cpu_time: number
memory: number
result: number
test_case: string
/** SQL 判题会带上中文原因,沙箱判题没有这个字段 */
error_message?: string | null
[key: string]: unknown
}
interface JudgeResponse {
err: string | null
data: JudgeCase[] | unknown
}
function statusValue(value: number): JudgeStatusValue {
const statuses = new Set<number>(Object.values(JudgeStatus))
return statuses.has(value) ? (value as JudgeStatusValue) : JudgeStatus.SYSTEM_ERROR
}
function templateForLanguage(value: unknown, language: string) {
const template = asRecord(value)[language]
return typeof template === "string" ? template : null
}
/**
* 取某个语言的 AST 规则。逐条过 astRuleSchema 而不是整片 `as AstRule[]` 硬转 ——
* 这是从库里读出来的 JSONB,形状不对(比如 engine 是个 evaluateRule 不认识的值)
* 时丢掉那一条,行为和 evaluateRule 的 `default: return null` 一致,只是提前到了
* 这里、并且不再骗类型系统。
*/
function astRulesForLanguage(value: unknown, language: string): AstRule[] {
const rules = asRecord(value)[language]
if (!Array.isArray(rules)) return []
return rules.flatMap((rule) => {
const parsed = astRuleSchema.safeParse(rule)
return parsed.success ? [parsed.data] : []
})
}
async function requestJudge(
language: string,
code: string,
timeLimit: number,
memoryLimit: number,
testCaseId: string,
) {
const languageConfig = judgeConfigFor(language)
if (!languageConfig) throw new Error(`Unsupported judge language: ${language}`)
const token = createHash("sha256").update(config.judgeServerToken).digest("hex")
const response = await fetch(new URL("/judge", config.judgeServerUrl), {
method: "POST",
headers: {
"content-type": "application/json",
"X-Judge-Server-Token": token,
},
body: JSON.stringify({
language_config: languageConfig,
src: code,
max_cpu_time: timeLimit,
max_memory: 1024 * 1024 * memoryLimit,
test_case_id: testCaseId,
output: false,
io_mode: {
io_mode: "Standard IO",
input: "input.txt",
output: "output.txt",
},
}),
})
if (!response.ok) {
throw new Error(`JudgeServer returned HTTP ${response.status}`)
}
return (await response.json()) as JudgeResponse
}
async function persistResult(
submissionId: string,
problemId: number,
userId: number,
displayId: string,
result: JudgeStatusValue,
info: unknown,
statisticInfo: Record<string, unknown>,
contestId: number | null,
submissionCreateTime: string,
) {
return db.transaction(async (tx) => {
const [currentSubmission] = await tx
.select({ result: schema.submission.result })
.from(schema.submission)
.where(eq(schema.submission.id, submissionId))
.for("update")
if (
!currentSubmission ||
![JudgeStatus.PENDING, JudgeStatus.JUDGING].includes(currentSubmission.result as 6 | 7)
) {
return false
}
const [problem] = await tx
.select({
submissionNumber: schema.problem.submissionNumber,
acceptedNumber: schema.problem.acceptedNumber,
statisticInfo: schema.problem.statisticInfo,
})
.from(schema.problem)
.where(eq(schema.problem.id, problemId))
.for("update")
const [profile] = await tx
.select()
.from(schema.userProfile)
.where(eq(schema.userProfile.userId, userId))
.for("update")
if (!problem || !profile) {
throw new Error("Submission dependencies disappeared during judging")
}
await tx
.update(schema.submission)
.set({ result, info, statisticInfo })
.where(eq(schema.submission.id, submissionId))
const problemStatistics = asRecord(problem.statisticInfo)
const resultKey = String(result)
const previousResultCount = problemStatistics[resultKey]
problemStatistics[resultKey] =
(typeof previousResultCount === "number" ? previousResultCount : 0) + 1
await tx
.update(schema.problem)
.set({
submissionNumber: problem.submissionNumber + 1,
acceptedNumber: problem.acceptedNumber + (isAccepted(result) ? 1 : 0),
statisticInfo: problemStatistics,
})
.where(eq(schema.problem.id, problemId))
const acmStatus = asRecord(profile.acmProblemsStatus)
const statusKey = contestId === null ? "problems" : "contest_problems"
const problems = asRecord(acmStatus[statusKey])
const previous = asRecord(problems[String(problemId)])
const previousStatus = previous.status
const wasAccepted = typeof previousStatus === "number" && isAccepted(previousStatus)
const acceptedNow = isAccepted(result)
if (previousStatus === undefined) {
problems[String(problemId)] = {
status: acceptedNow ? JudgeStatus.ACCEPTED : result,
_id: displayId,
}
} else if (!wasAccepted) {
problems[String(problemId)] = {
...previous,
status: acceptedNow ? JudgeStatus.ACCEPTED : result,
_id: displayId,
}
}
acmStatus[statusKey] = problems
await tx
.update(schema.userProfile)
.set({
submissionNumber: profile.submissionNumber + (contestId === null ? 1 : 0),
acceptedNumber:
profile.acceptedNumber + (contestId === null && acceptedNow && !wasAccepted ? 1 : 0),
acmProblemsStatus: acmStatus,
})
.where(eq(schema.userProfile.id, profile.id))
if (contestId !== null) {
const [contest] = await tx
.select({ startTime: schema.contest.startTime })
.from(schema.contest)
.where(eq(schema.contest.id, contestId))
.for("update")
if (!contest) throw new Error("Contest disappeared during judging")
await tx
.insert(schema.acmContestRank)
.values({
contestId,
userId,
submissionNumber: 0,
acceptedNumber: 0,
totalTime: 0,
submissionInfo: {},
})
.onConflictDoNothing({
target: [schema.acmContestRank.contestId, schema.acmContestRank.userId],
})
const [rank] = await tx
.select()
.from(schema.acmContestRank)
.where(
and(
eq(schema.acmContestRank.contestId, contestId),
eq(schema.acmContestRank.userId, userId),
),
)
.for("update")
if (!rank) throw new Error("Contest rank could not be created")
const rankInfo = rank.submissionInfo
const previousInfo = rankInfo[String(problemId)]
const alreadyAccepted = previousInfo?.is_ac === true
if (!alreadyAccepted) {
const errorNumber = previousInfo?.error_number ?? 0
const nextInfo: ContestSubmissionInfo = {
is_ac: acceptedNow,
ac_time: 0,
error_number:
errorNumber + (!acceptedNow && result !== JudgeStatus.COMPILE_ERROR ? 1 : 0),
is_first_ac: false,
}
let totalTime = rank.totalTime
let acceptedNumber = rank.acceptedNumber
if (acceptedNow) {
const acTime = Math.max(
0,
Math.floor((Date.parse(submissionCreateTime) - Date.parse(contest.startTime)) / 1000),
)
nextInfo.ac_time = acTime
nextInfo.is_first_ac = problem.acceptedNumber === 0
acceptedNumber += 1
totalTime += acTime + errorNumber * 20 * 60
}
rankInfo[String(problemId)] = nextInfo
await tx
.update(schema.acmContestRank)
.set({
submissionNumber: rank.submissionNumber + 1,
acceptedNumber,
totalTime,
submissionInfo: rankInfo,
})
.where(eq(schema.acmContestRank.id, rank.id))
}
}
return true
})
}
async function markSystemError(submissionId: string, userId: number, error: unknown) {
const message = error instanceof Error ? error.message : String(error)
const updated = await db
.update(schema.submission)
.set({
result: JudgeStatus.SYSTEM_ERROR,
statisticInfo: { err_info: message, score: 0 },
})
.where(
and(
eq(schema.submission.id, submissionId),
inArray(schema.submission.result, [JudgeStatus.PENDING, JudgeStatus.JUDGING]),
),
)
.returning({ id: schema.submission.id })
if (updated.length > 0) {
await publishSubmissionUpdate(userId, {
type: "submission_update",
submissionId,
result: JudgeStatus.SYSTEM_ERROR,
status: "error",
})
}
}
/**
* 判题任务在 `judgeSubmission` 之外失败时的兜底。
*
* 正常路径上的异常都被 judgeSubmission 自己的 try/catch 接住、落成 SYSTEM_ERROR
* 并且推给前端,所以能走到队列 `failed` 事件的只剩两种:取提交那一步就炸了,
* 以及**worker 进程中途死掉** —— 机房断电、容器 OOM 被杀、部署时重启。后一种
* BullMQ 会先按 stalled 重入队一次,再没人接就彻底放手;判题队列又没配 attempts,
* 失败即终局。没有这个兜底,那条提交就永远停在「等待评分」,学生看着转圈,
* 教师统计里它还占着一个「判题中」的名额。生产库里 3 条卡死的 PENDING
* (2022-11 / 2026-03 / 2026-04,都是旧栈时代留下的)就是这么来的。
*
* `markSystemError` 只动 PENDING / JUDGING 两个状态,所以判完了的、被重判改过的
* 都不会被它覆盖。唯一能撞上的是「重判刚把状态置回 PENDING,同一刻上一个被遗弃的
* 任务才失败」——结果是这次重判被吃掉、显示成系统错误,比静默卡死看得见。
*/
export async function failAbandonedSubmission(submissionId: string, error: unknown) {
const [row] = await db
.select({ userId: schema.submission.userId })
.from(schema.submission)
.where(eq(schema.submission.id, submissionId))
.limit(1)
if (!row) return
await markSystemError(submissionId, row.userId, error)
}
export async function judgeSubmission(job: JudgeJobData) {
const [row] = await db
.select({
submission: schema.submission,
problem: schema.problem,
})
.from(schema.submission)
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id))
.where(and(eq(schema.submission.id, job.submissionId), eq(schema.problem.id, job.problemId)))
.limit(1)
if (!row) throw new Error(`Submission ${job.submissionId} does not exist`)
if (![JudgeStatus.PENDING, JudgeStatus.JUDGING].includes(row.submission.result as 6 | 7)) {
return
}
try {
await db
.update(schema.submission)
.set({ result: JudgeStatus.JUDGING })
.where(eq(schema.submission.id, row.submission.id))
await publishSubmissionUpdate(row.submission.userId, {
type: "submission_update",
submissionId: row.submission.id,
result: JudgeStatus.JUDGING,
status: "judging",
})
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
// SQL 题不经判题沙箱:沙箱是给编译型/脚本型语言用的,SQL 判的是结果集,
// 走 judge/sql 的 WASM 引擎(在独立子进程里跑,见那边的说明)。
const response =
row.submission.language === "SQL"
? await judgeSqlSubmission(row.problem, row.submission.code)
: await requestJudge(
row.submission.language,
source,
row.problem.timeLimit,
row.problem.memoryLimit,
row.problem.testCaseId,
)
let result: JudgeStatusValue
let info: unknown = {}
let statisticInfo: Record<string, unknown> = {}
if (response.err) {
result = JudgeStatus.COMPILE_ERROR
statisticInfo = {
err_info: typeof response.data === "string" ? response.data : JSON.stringify(response.data),
score: 0,
}
} else {
if (!Array.isArray(response.data)) {
throw new Error("JudgeServer returned an invalid result payload")
}
const cases = [...response.data].sort(
(left, right) => Number(left.test_case) - Number(right.test_case),
)
info = { err: null, data: cases }
const firstFailure = cases.find((item) => item.result !== JudgeStatus.ACCEPTED)
result = statusValue(firstFailure?.result ?? JudgeStatus.ACCEPTED)
statisticInfo = {
time_cost: Math.max(0, ...cases.map((item) => Number(item.cpu_time) || 0)),
memory_cost: Math.max(0, ...cases.map((item) => Number(item.memory) || 0)),
score: 0,
}
// SQL 判题给出的中文提示(只读拒绝/超时/内存/无结果集)只存在测试点的
// error_message 里,而前端只读 statistic_info.err_info。把首个失败测试点的
// 提示提上来,否则学生只看到一个 WA 却不知道原因。对齐旧 sql_dispatcher。
const failedMessage = cases.find(
(item) => item.result !== JudgeStatus.ACCEPTED && item.error_message,
)?.error_message
if (typeof failedMessage === "string") statisticInfo.err_info = failedMessage
if (result === JudgeStatus.ACCEPTED) {
const rules = astRulesForLanguage(row.problem.astRules, row.submission.language)
if (rules.length > 0) {
const ast = await checkAst(row.submission.code, row.submission.language, rules)
if (!ast.passed) {
result = JudgeStatus.AST_CHECK_FAILED
statisticInfo.ast_results = ast.results
}
}
}
}
const saved = await persistResult(
row.submission.id,
row.problem.id,
row.submission.userId,
row.problem.displayId,
result,
info,
statisticInfo,
row.submission.contestId,
row.submission.createTime,
)
if (!saved) return
// 题单记账挪到判题这一路。以前靠前端 AC 之后回调 PUT /problem-set-progress,
// 只认路由参数里那一个题单:从普通题库入口做出同一道题不计进度,网络一抖就静默丢失。
// 放在最后那条 publishSubmissionUpdate("finished") 之前 —— 前端收到「判完了」时
// 进度已经落库,跳回题单页看到的就是新数据。
// 比赛题不进题单(题单加题时卡了 contestId IS NULL),跳过。
if (row.submission.contestId === null && isAccepted(result)) {
try {
const { updated, earned } = await recordSolvedProblem(
row.submission.userId,
row.problem.id,
row.submission.id,
row.submission.createTime,
)
if (earned.length > 0) {
await publishAchievementNotification(
row.submission.userId,
earned.map((badge) => ({
id: badge.id,
name: badge.name,
description: badge.description,
icon: badge.icon,
rarity: "bronze",
kind: "badge",
})),
)
}
if (updated > 0) {
const unlocked = await updateAchievementsForProblemSet(row.submission.userId)
await publishAchievementNotification(
row.submission.userId,
unlocked.map((achievement) => ({
id: achievement.id,
name: achievement.name,
description: achievement.description,
icon: achievement.icon,
rarity: achievement.rarity,
kind: "achievement",
})),
)
}
} catch (error) {
console.error(`Failed to record problem set progress for ${row.submission.id}`, error)
}
}
try {
const unlocked = await updateAchievementsForSubmission(row.submission.id)
await publishAchievementNotification(
row.submission.userId,
unlocked.map((achievement) => ({
id: achievement.id,
name: achievement.name,
description: achievement.description,
icon: achievement.icon,
rarity: achievement.rarity,
kind: "achievement",
})),
)
} catch (error) {
console.error(`Failed to update achievements for ${row.submission.id}`, error)
}
await publishSubmissionUpdate(row.submission.userId, {
type: "submission_update",
submissionId: row.submission.id,
result,
status: "finished",
score: typeof statisticInfo.score === "number" ? statisticInfo.score : undefined,
})
} catch (error) {
console.error(`Failed to judge submission ${row.submission.id}`, error)
await markSystemError(row.submission.id, row.submission.userId, error)
}
}
/**
* SQL 题判题:逐个测试点用各自的初始化脚本跑一遍,产出与沙箱同构的结果结构,
* 好让上面的状态聚合、统计、排名、WebSocket 推送逻辑完全复用。
* 对齐旧 `judge/sql_dispatcher.py`。
*/
async function judgeSqlSubmission(
problem: typeof schema.problem.$inferSelect,
studentSql: string,
): Promise<JudgeResponse> {
const sqlConfig = asRecord(problem.sqlConfig)
const mode = sqlConfig.mode
if (mode !== "query" && mode !== "modify") {
throw new Error("题目缺少 SQL 配置(题型)")
}
const answers = Array.isArray(problem.answers) ? problem.answers : []
const refSql = answers
.map((item) => asRecord(item))
.find(
(item) => item.language === "SQL" && typeof item.code === "string" && item.code.trim(),
)?.code
if (typeof refSql !== "string") throw new Error("题目缺少 SQL 标准答案")
const info = await readInfo(problem.testCaseId)
if (!info) throw new Error("测试点信息读取失败")
if (!info.sql) throw new Error("测试点不是 SQL 类型,请重新上传 SQL 测试点压缩包")
// 按 "1","2",… 的数字序遍历,保证测试点顺序稳定
const keys = Object.keys(info.test_cases ?? {}).sort((a, b) => Number(a) - Number(b))
if (keys.length === 0) throw new Error("题目没有任何测试点")
const cases: JudgeCase[] = []
for (const [index, key] of keys.entries()) {
const inputName = info.test_cases![key]!.input_name
const initSql = await readFile(
resolvePath(config.testCaseDirectory, problem.testCaseId, inputName),
"utf8",
).catch(() => {
throw new Error(`测试点脚本 ${inputName} 读取失败`)
})
const outcome = await runSqlCase({
kind: "judge",
initSql,
refSql,
studentSql,
mode,
orderSensitive: sqlConfig.order_sensitive === true,
timeLimitMs: problem.timeLimit,
memoryLimitMb: problem.memoryLimit,
})
if (!outcome.ok) {
// 初始化/标准答案执行失败属出题配置问题,整题 SYSTEM_ERROR
if (outcome.result === JudgeStatus.SYSTEM_ERROR) throw new Error(outcome.message)
// 子进程被杀(超时/内存)也走这里,按学生错误记成一个测试点
cases.push({
test_case: String(index + 1),
result: outcome.result,
cpu_time: 0,
real_time: 0,
memory: 0,
error_message: outcome.message,
} as unknown as JudgeCase)
break
}
const value = outcome.value
value.test_case = String(index + 1)
// 语法错误与数据无关,首个测试点即可确认,整题按编译错误处理
// (ACM 不罚时,前端展示 err_info)
if (index === 0 && value.result === JudgeStatus.COMPILE_ERROR) {
return { err: "CompileError", data: value.error_message }
}
cases.push(value as unknown as JudgeCase)
}
return { err: null, data: cases }
}