Files
OJ2/apps/api/src/judge/run.ts

356 lines
10 KiB
TypeScript

import { createHash } from "node:crypto"
import { and, eq, inArray } from "drizzle-orm"
import { config } from "../config"
import { db, schema } from "../db"
import { checkAst, type AstRule } from "./ast"
import { publishSubmissionUpdate } from "./events"
import type { JudgeJobData } from "./job"
import { languageConfigs } from "./languages"
import {
isAccepted,
JudgeStatus,
type JudgeStatusValue,
} from "./status"
import { parseProblemTemplate } from "./template"
interface JudgeCase {
cpu_time: number
memory: number
result: number
test_case: string
[key: string]: unknown
}
interface JudgeResponse {
err: string | null
data: JudgeCase[] | unknown
}
function objectValue(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, 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 = objectValue(value)[language]
return typeof template === "string" ? template : null
}
function astRulesForLanguage(value: unknown, language: string): AstRule[] {
const rules = objectValue(value)[language]
return Array.isArray(rules) ? (rules as AstRule[]) : []
}
async function requestJudge(
language: string,
code: string,
timeLimit: number,
memoryLimit: number,
testCaseId: string,
) {
const languageConfig = languageConfigs[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>,
) {
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 = objectValue(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 = objectValue(profile.acmProblemsStatus)
const problems = objectValue(acmStatus.problems)
const previous = objectValue(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.problems = problems
await tx
.update(schema.userProfile)
.set({
submissionNumber: profile.submissionNumber + 1,
acceptedNumber:
profile.acceptedNumber + (acceptedNow && !wasAccepted ? 1 : 0),
acmProblemsStatus: acmStatus,
})
.where(eq(schema.userProfile.id, profile.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",
submission_id: submissionId,
result: JudgeStatus.SYSTEM_ERROR,
status: "error",
err_info: message,
})
}
}
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",
submission_id: 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
const response = 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,
}
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,
)
if (!saved) return
await publishSubmissionUpdate(row.submission.userId, {
type: "submission_update",
submission_id: row.submission.id,
result,
status: "finished",
time_cost:
typeof statisticInfo.time_cost === "number"
? statisticInfo.time_cost
: undefined,
memory_cost:
typeof statisticInfo.memory_cost === "number"
? statisticInfo.memory_cost
: undefined,
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)
}
}