Build Phase 2 judge vertical slice
This commit is contained in:
@@ -4,14 +4,25 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun --watch src/index.ts",
|
||||
"dev": "bun run --parallel dev:http dev:worker",
|
||||
"dev:http": "bun --watch src/index.ts",
|
||||
"dev:worker": "bun --watch src/worker.ts",
|
||||
"start": "bun src/index.ts",
|
||||
"worker": "bun src/worker.ts",
|
||||
"seed:dev": "bun src/scripts/seed-dev.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"db:pull": "drizzle-kit pull"
|
||||
},
|
||||
"dependencies": {
|
||||
"@oj2/contract": "workspace:*",
|
||||
"bullmq": "^6.0.9",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"hono": "^4.0.0",
|
||||
"ioredis": "^6.0.0",
|
||||
"postgres": "^3.4.0",
|
||||
"tree-sitter-c": "^0.24.1",
|
||||
"tree-sitter-python": "^0.25.0",
|
||||
"web-tree-sitter": "^0.26.11",
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
24
apps/api/src/auth/middleware.ts
Normal file
24
apps/api/src/auth/middleware.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { MiddlewareHandler } from "hono"
|
||||
|
||||
import { failure } from "../http"
|
||||
import { getSessionUser, type AuthUser } from "./session"
|
||||
|
||||
export interface AppEnv {
|
||||
Variables: {
|
||||
user: AuthUser | null
|
||||
}
|
||||
}
|
||||
|
||||
export const optionalAuth: MiddlewareHandler<AppEnv> = async (c, next) => {
|
||||
c.set("user", await getSessionUser(c))
|
||||
await next()
|
||||
}
|
||||
|
||||
export const requireAuth: MiddlewareHandler<AppEnv> = async (c, next) => {
|
||||
const user = await getSessionUser(c)
|
||||
if (!user) {
|
||||
return failure(c, 401, "login-required", "Authentication required")
|
||||
}
|
||||
c.set("user", user)
|
||||
await next()
|
||||
}
|
||||
49
apps/api/src/auth/password.ts
Normal file
49
apps/api/src/auth/password.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { pbkdf2, timingSafeEqual } from "node:crypto"
|
||||
import { promisify } from "node:util"
|
||||
|
||||
const pbkdf2Async = promisify(pbkdf2)
|
||||
|
||||
async function verifyDjangoPbkdf2(password: string, encoded: string) {
|
||||
const [algorithm, iterationsText, salt, digestText] = encoded.split("$")
|
||||
if (
|
||||
algorithm !== "pbkdf2_sha256" ||
|
||||
!iterationsText ||
|
||||
!salt ||
|
||||
!digestText
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
const iterations = Number(iterationsText)
|
||||
const expected = Buffer.from(digestText, "base64")
|
||||
if (!Number.isSafeInteger(iterations) || iterations <= 0 || expected.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
const actual = await pbkdf2Async(
|
||||
password,
|
||||
salt,
|
||||
iterations,
|
||||
expected.length,
|
||||
"sha256",
|
||||
)
|
||||
return timingSafeEqual(actual, expected)
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string, encoded: string) {
|
||||
if (encoded.startsWith("pbkdf2_sha256$")) {
|
||||
return {
|
||||
valid: await verifyDjangoPbkdf2(password, encoded),
|
||||
needsUpgrade: true,
|
||||
}
|
||||
}
|
||||
|
||||
if (encoded.startsWith("$argon2")) {
|
||||
return {
|
||||
valid: await Bun.password.verify(password, encoded),
|
||||
needsUpgrade: false,
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: false, needsUpgrade: false }
|
||||
}
|
||||
110
apps/api/src/auth/session.ts
Normal file
110
apps/api/src/auth/session.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { randomBytes } from "node:crypto"
|
||||
|
||||
import { eq } from "drizzle-orm"
|
||||
import type { Context } from "hono"
|
||||
import { deleteCookie, getCookie, setCookie } from "hono/cookie"
|
||||
|
||||
import { config } from "../config"
|
||||
import { db, schema } from "../db"
|
||||
import { redis } from "../redis"
|
||||
|
||||
const SESSION_PREFIX = "session:"
|
||||
|
||||
interface StoredSession {
|
||||
userId: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface AuthUser {
|
||||
id: number
|
||||
username: string
|
||||
email: string | null
|
||||
adminType: string
|
||||
problemPermission: string
|
||||
isDisabled: boolean
|
||||
}
|
||||
|
||||
function sessionKey(token: string) {
|
||||
return `${SESSION_PREFIX}${token}`
|
||||
}
|
||||
|
||||
export async function createSession(c: Context, userId: number) {
|
||||
const token = randomBytes(32).toString("base64url")
|
||||
const value: StoredSession = {
|
||||
userId,
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
await redis.set(
|
||||
sessionKey(token),
|
||||
JSON.stringify(value),
|
||||
"EX",
|
||||
config.sessionTtlSeconds,
|
||||
)
|
||||
setCookie(c, config.sessionCookie, token, {
|
||||
httpOnly: true,
|
||||
sameSite: "Lax",
|
||||
secure: config.secureCookies,
|
||||
path: "/",
|
||||
maxAge: config.sessionTtlSeconds,
|
||||
})
|
||||
}
|
||||
|
||||
export async function destroySession(c: Context) {
|
||||
const token = getCookie(c, config.sessionCookie)
|
||||
if (token) await redis.del(sessionKey(token))
|
||||
deleteCookie(c, config.sessionCookie, { path: "/" })
|
||||
}
|
||||
|
||||
function readCookie(request: Request, name: string) {
|
||||
const header = request.headers.get("cookie")
|
||||
if (!header) return undefined
|
||||
for (const part of header.split(";")) {
|
||||
const [key, ...value] = part.trim().split("=")
|
||||
if (key === name) return decodeURIComponent(value.join("="))
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
async function getUserByToken(token: string | undefined): Promise<AuthUser | null> {
|
||||
if (!token) return null
|
||||
|
||||
const raw = await redis.get(sessionKey(token))
|
||||
if (!raw) return null
|
||||
|
||||
let session: StoredSession
|
||||
try {
|
||||
session = JSON.parse(raw) as StoredSession
|
||||
} catch {
|
||||
await redis.del(sessionKey(token))
|
||||
return null
|
||||
}
|
||||
|
||||
const [user] = await db
|
||||
.select({
|
||||
id: schema.user.id,
|
||||
username: schema.user.username,
|
||||
email: schema.user.email,
|
||||
adminType: schema.user.adminType,
|
||||
problemPermission: schema.user.problemPermission,
|
||||
isDisabled: schema.user.isDisabled,
|
||||
})
|
||||
.from(schema.user)
|
||||
.where(eq(schema.user.id, session.userId))
|
||||
.limit(1)
|
||||
|
||||
if (!user || user.isDisabled) {
|
||||
await redis.del(sessionKey(token))
|
||||
return null
|
||||
}
|
||||
|
||||
await redis.expire(sessionKey(token), config.sessionTtlSeconds)
|
||||
return user
|
||||
}
|
||||
|
||||
export function getSessionUser(c: Context) {
|
||||
return getUserByToken(getCookie(c, config.sessionCookie))
|
||||
}
|
||||
|
||||
export function getRequestSessionUser(request: Request) {
|
||||
return getUserByToken(readCookie(request, config.sessionCookie))
|
||||
}
|
||||
10
apps/api/src/config.ts
Normal file
10
apps/api/src/config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export const config = {
|
||||
port: Number(process.env.PORT ?? 3000),
|
||||
redisUrl: process.env.REDIS_URL ?? "redis://localhost:6380",
|
||||
sessionCookie: "oj2_session",
|
||||
sessionTtlSeconds: Number(process.env.SESSION_TTL_SECONDS ?? 7 * 24 * 60 * 60),
|
||||
secureCookies: process.env.COOKIE_SECURE === "true",
|
||||
judgeServerUrl: process.env.JUDGE_SERVER_URL ?? "http://localhost:8081",
|
||||
judgeServerToken: process.env.JUDGE_SERVER_TOKEN ?? "oj2-dev-token",
|
||||
judgeConcurrency: Number(process.env.JUDGE_CONCURRENCY ?? 2),
|
||||
}
|
||||
14
apps/api/src/http.ts
Normal file
14
apps/api/src/http.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { Context } from "hono"
|
||||
|
||||
export function success<T>(c: Context, data: T, status = 200) {
|
||||
return c.json({ data }, status as 200)
|
||||
}
|
||||
|
||||
export function failure(
|
||||
c: Context,
|
||||
status: 400 | 401 | 403 | 404 | 409 | 500 | 502,
|
||||
code: string,
|
||||
message: string,
|
||||
) {
|
||||
return c.json({ error: { code, message } }, status)
|
||||
}
|
||||
@@ -1,13 +1,53 @@
|
||||
import { Hono } from "hono"
|
||||
|
||||
import { getRequestSessionUser } from "./auth/session"
|
||||
import { config } from "./config"
|
||||
import { authRoutes } from "./routes/auth"
|
||||
import { judgeServerRoutes } from "./routes/judge-server"
|
||||
import { problemRoutes } from "./routes/problem"
|
||||
import { submissionRoutes } from "./routes/submission"
|
||||
import {
|
||||
bridgeSubmissionEvents,
|
||||
submissionWebSocketHandler,
|
||||
type SubmissionSocketData,
|
||||
} from "./websocket"
|
||||
|
||||
const app = new Hono()
|
||||
|
||||
app.get("/health", (c) => c.json({ ok: true }))
|
||||
app.route("/api", authRoutes)
|
||||
app.route("/api", problemRoutes)
|
||||
app.route("/api", submissionRoutes)
|
||||
app.route("/api", judgeServerRoutes)
|
||||
|
||||
export default {
|
||||
port: 3000,
|
||||
fetch: app.fetch,
|
||||
}
|
||||
app.onError((error, c) => {
|
||||
console.error(error)
|
||||
return c.json(
|
||||
{ error: { code: "internal-error", message: "Internal server error" } },
|
||||
500,
|
||||
)
|
||||
})
|
||||
|
||||
const server = Bun.serve<SubmissionSocketData>({
|
||||
port: config.port,
|
||||
async fetch(request, bunServer) {
|
||||
const url = new URL(request.url)
|
||||
if (url.pathname === "/ws/submissions") {
|
||||
const user = await getRequestSessionUser(request)
|
||||
if (!user) return new Response("Unauthorized", { status: 401 })
|
||||
if (
|
||||
bunServer.upgrade(request, {
|
||||
data: { userId: user.id, username: user.username },
|
||||
})
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
return new Response("WebSocket upgrade failed", { status: 400 })
|
||||
}
|
||||
return app.fetch(request)
|
||||
},
|
||||
websocket: submissionWebSocketHandler(),
|
||||
})
|
||||
|
||||
await bridgeSubmissionEvents(server)
|
||||
console.log(`OJ2 API listening on http://localhost:${server.port}`)
|
||||
|
||||
243
apps/api/src/judge/ast.ts
Normal file
243
apps/api/src/judge/ast.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import { Language, Parser, type Node } from "web-tree-sitter"
|
||||
|
||||
export interface AstRule {
|
||||
engine?: string
|
||||
target?: string
|
||||
outer?: string
|
||||
inner?: string
|
||||
label?: string
|
||||
message?: string
|
||||
exact?: number
|
||||
min?: number
|
||||
max?: number
|
||||
}
|
||||
|
||||
export interface AstResult {
|
||||
description: string
|
||||
passed: boolean
|
||||
}
|
||||
|
||||
const mappings: Record<string, Record<string, string>> = {
|
||||
C: {
|
||||
for_loop: "for_statement",
|
||||
while_loop: "while_statement",
|
||||
do_while: "do_statement",
|
||||
if_statement: "if_statement",
|
||||
else_clause: "else_clause",
|
||||
break: "break_statement",
|
||||
continue: "continue_statement",
|
||||
function_definition: "function_definition",
|
||||
return: "return_statement",
|
||||
switch_statement: "switch_statement",
|
||||
case_statement: "case_statement",
|
||||
assignment: "assignment_expression",
|
||||
struct: "struct_specifier",
|
||||
include: "preproc_include",
|
||||
and: "&&",
|
||||
or: "||",
|
||||
not: "!",
|
||||
},
|
||||
Python3: {
|
||||
for_loop: "for_statement",
|
||||
while_loop: "while_statement",
|
||||
if_statement: "if_statement",
|
||||
else_clause: "else_clause",
|
||||
elif_clause: "elif_clause",
|
||||
break: "break_statement",
|
||||
continue: "continue_statement",
|
||||
function_definition: "function_definition",
|
||||
return: "return_statement",
|
||||
try_except: "try_statement",
|
||||
with_statement: "with_statement",
|
||||
list_comprehension: "list_comprehension",
|
||||
list_literal: "list",
|
||||
dict_literal: "dictionary",
|
||||
set_literal: "set",
|
||||
f_string: "format_string",
|
||||
import: "import_statement",
|
||||
import_from: "import_from_statement",
|
||||
assignment: "assignment",
|
||||
class_definition: "class_definition",
|
||||
},
|
||||
}
|
||||
|
||||
let initPromise: Promise<void> | undefined
|
||||
const languages = new Map<string, Language>()
|
||||
|
||||
async function loadLanguage(language: string) {
|
||||
if (!(language in mappings)) return null
|
||||
if (!initPromise) initPromise = Parser.init()
|
||||
await initPromise
|
||||
|
||||
const cached = languages.get(language)
|
||||
if (cached) return cached
|
||||
|
||||
const packageName = language === "C" ? "tree-sitter-c" : "tree-sitter-python"
|
||||
const wasmName = language === "C" ? "tree-sitter-c.wasm" : "tree-sitter-python.wasm"
|
||||
const wasmPath = Bun.resolveSync(`${packageName}/${wasmName}`, import.meta.dir)
|
||||
const loaded = await Language.load(wasmPath)
|
||||
languages.set(language, loaded)
|
||||
return loaded
|
||||
}
|
||||
|
||||
function collectNodes(root: Node, type: string, result: Node[] = []) {
|
||||
if (root.type === type) result.push(root)
|
||||
for (const child of root.children) collectNodes(child, type, result)
|
||||
return result
|
||||
}
|
||||
|
||||
function hasNode(root: Node, type: string): boolean {
|
||||
if (root.type === type) return true
|
||||
return root.children.some((child) => hasNode(child, type))
|
||||
}
|
||||
|
||||
function targetName(rule: AstRule) {
|
||||
return rule.label || rule.target || "指定语法"
|
||||
}
|
||||
|
||||
function rangeDescription(subject: string, rule: AstRule) {
|
||||
if (rule.message) return rule.message
|
||||
if (rule.exact !== undefined) return `${subject} 出现 ${rule.exact} 次`
|
||||
const parts: string[] = []
|
||||
if (rule.min !== undefined) parts.push(`至少 ${rule.min} 次`)
|
||||
if (rule.max !== undefined) parts.push(`至多 ${rule.max} 次`)
|
||||
return `${subject} ${parts.join("、")}`.trim()
|
||||
}
|
||||
|
||||
function rangePassed(count: number, rule: AstRule) {
|
||||
if (rule.exact !== undefined && count !== rule.exact) return false
|
||||
if (rule.min !== undefined && count < rule.min) return false
|
||||
if (rule.max !== undefined && count > rule.max) return false
|
||||
return true
|
||||
}
|
||||
|
||||
function functionCalls(root: Node, target: string, language: string) {
|
||||
const callType = language === "C" ? "call_expression" : "call"
|
||||
return collectNodes(root, callType).filter((call) => {
|
||||
const fn = call.childForFieldName("function")
|
||||
return fn?.type === "identifier" && fn.text === target
|
||||
})
|
||||
}
|
||||
|
||||
function methodCalls(root: Node, target: string, language: string) {
|
||||
if (language === "C") return []
|
||||
return collectNodes(root, "call").filter((call) => {
|
||||
const fn = call.childForFieldName("function")
|
||||
return (
|
||||
fn?.type === "attribute" &&
|
||||
fn.childForFieldName("attribute")?.text === target
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function evaluateRule(
|
||||
root: Node,
|
||||
rule: AstRule,
|
||||
language: string,
|
||||
mapping: Record<string, string>,
|
||||
): AstResult | null {
|
||||
const target = rule.target ?? ""
|
||||
const nodeType = mapping[target] ?? target
|
||||
|
||||
switch (rule.engine) {
|
||||
case "must_exist_node":
|
||||
return {
|
||||
description: rule.message || `必须使用 ${targetName(rule)}`,
|
||||
passed: hasNode(root, nodeType),
|
||||
}
|
||||
case "must_not_exist_node":
|
||||
return {
|
||||
description: rule.message || `不能使用 ${targetName(rule)}`,
|
||||
passed: !hasNode(root, nodeType),
|
||||
}
|
||||
case "count_node": {
|
||||
const count = collectNodes(root, nodeType).length
|
||||
return {
|
||||
description: rangeDescription(targetName(rule), rule),
|
||||
passed: rangePassed(count, rule),
|
||||
}
|
||||
}
|
||||
case "must_call_function":
|
||||
return {
|
||||
description: rule.message || `必须调用 ${target}()`,
|
||||
passed: functionCalls(root, target, language).length > 0,
|
||||
}
|
||||
case "must_not_call_function":
|
||||
return {
|
||||
description: rule.message || `不能调用 ${target}()`,
|
||||
passed: functionCalls(root, target, language).length === 0,
|
||||
}
|
||||
case "count_function_call": {
|
||||
const count = functionCalls(root, target, language).length
|
||||
return {
|
||||
description: rangeDescription(`${target}()`, rule),
|
||||
passed: rangePassed(count, rule),
|
||||
}
|
||||
}
|
||||
case "must_call_method":
|
||||
return {
|
||||
description: rule.message || `必须调用 .${target}()`,
|
||||
passed: methodCalls(root, target, language).length > 0,
|
||||
}
|
||||
case "must_not_call_method":
|
||||
return {
|
||||
description: rule.message || `不能调用 .${target}()`,
|
||||
passed: methodCalls(root, target, language).length === 0,
|
||||
}
|
||||
case "must_use_operator":
|
||||
return {
|
||||
description: rule.message || `必须使用 ${target} 运算符`,
|
||||
passed: hasNode(root, nodeType),
|
||||
}
|
||||
case "must_have_nesting": {
|
||||
const outer = rule.outer ?? ""
|
||||
const inner = rule.inner ?? ""
|
||||
const outerType = mapping[outer] ?? outer
|
||||
const innerType = mapping[inner] ?? inner
|
||||
const passed = collectNodes(root, outerType).some((node) =>
|
||||
node.children.some((child) => hasNode(child, innerType)),
|
||||
)
|
||||
return {
|
||||
description:
|
||||
rule.message ||
|
||||
(outer === inner
|
||||
? `必须使用 ${outer} 嵌套`
|
||||
: `必须在 ${outer} 中嵌套使用 ${inner}`),
|
||||
passed,
|
||||
}
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkAst(
|
||||
code: string,
|
||||
language: string,
|
||||
rules: AstRule[],
|
||||
): Promise<{ passed: boolean; results: AstResult[] }> {
|
||||
if (rules.length === 0) return { passed: true, results: [] }
|
||||
|
||||
const treeSitterLanguage = await loadLanguage(language)
|
||||
if (!treeSitterLanguage) return { passed: true, results: [] }
|
||||
|
||||
const parser = new Parser()
|
||||
try {
|
||||
parser.setLanguage(treeSitterLanguage)
|
||||
const tree = parser.parse(code)
|
||||
if (!tree) return { passed: true, results: [] }
|
||||
try {
|
||||
const mapping = mappings[language] ?? {}
|
||||
const results = rules
|
||||
.map((rule) => evaluateRule(tree.rootNode, rule, language, mapping))
|
||||
.filter((result): result is AstResult => result !== null)
|
||||
return { passed: results.every((result) => result.passed), results }
|
||||
} finally {
|
||||
tree.delete()
|
||||
}
|
||||
} catch {
|
||||
return { passed: true, results: [] }
|
||||
} finally {
|
||||
parser.delete()
|
||||
}
|
||||
}
|
||||
41
apps/api/src/judge/events.ts
Normal file
41
apps/api/src/judge/events.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
submissionUpdateSchema,
|
||||
type SubmissionUpdate,
|
||||
} from "@oj2/contract"
|
||||
|
||||
import { redis } from "../redis"
|
||||
|
||||
export const submissionUpdateChannel = "submission:updates"
|
||||
|
||||
interface SubmissionEvent {
|
||||
userId: number
|
||||
data: SubmissionUpdate
|
||||
}
|
||||
|
||||
export function userSubmissionTopic(userId: number) {
|
||||
return `submission:user:${userId}`
|
||||
}
|
||||
|
||||
export async function publishSubmissionUpdate(
|
||||
userId: number,
|
||||
data: SubmissionUpdate,
|
||||
) {
|
||||
const event: SubmissionEvent = {
|
||||
userId,
|
||||
data: submissionUpdateSchema.parse(data),
|
||||
}
|
||||
await redis.publish(submissionUpdateChannel, JSON.stringify(event))
|
||||
}
|
||||
|
||||
export function parseSubmissionEvent(raw: string): SubmissionEvent | null {
|
||||
try {
|
||||
const value = JSON.parse(raw) as { userId?: unknown; data?: unknown }
|
||||
if (typeof value.userId !== "number") return null
|
||||
return {
|
||||
userId: value.userId,
|
||||
data: submissionUpdateSchema.parse(value.data),
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
6
apps/api/src/judge/job.ts
Normal file
6
apps/api/src/judge/job.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export const judgeQueueName = "judge-submission"
|
||||
|
||||
export interface JudgeJobData {
|
||||
submissionId: string
|
||||
problemId: number
|
||||
}
|
||||
107
apps/api/src/judge/languages.ts
Normal file
107
apps/api/src/judge/languages.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
const defaultEnv = ["LANG=en_US.UTF-8", "LANGUAGE=en_US:en", "LC_ALL=en_US.UTF-8"]
|
||||
|
||||
export const languageConfigs: Record<string, Record<string, unknown>> = {
|
||||
C: {
|
||||
template: "",
|
||||
compile: {
|
||||
src_name: "main.c",
|
||||
exe_name: "main",
|
||||
max_cpu_time: 3000,
|
||||
max_real_time: 10000,
|
||||
max_memory: 256 * 1024 * 1024,
|
||||
compile_command:
|
||||
"/usr/bin/gcc -DONLINE_JUDGE -O2 -w -fmax-errors=3 -std=c17 {src_path} -lm -o {exe_path}",
|
||||
},
|
||||
run: {
|
||||
command: "{exe_path}",
|
||||
seccomp_rule: { "Standard IO": "c_cpp", "File IO": "c_cpp_file_io" },
|
||||
env: defaultEnv,
|
||||
},
|
||||
},
|
||||
"C++": {
|
||||
template: "",
|
||||
compile: {
|
||||
src_name: "main.cpp",
|
||||
exe_name: "main",
|
||||
max_cpu_time: 10000,
|
||||
max_real_time: 20000,
|
||||
max_memory: 1024 * 1024 * 1024,
|
||||
compile_command:
|
||||
"/usr/bin/g++ -DONLINE_JUDGE -O2 -w -fmax-errors=3 -std=c++20 {src_path} -lm -o {exe_path}",
|
||||
},
|
||||
run: {
|
||||
command: "{exe_path}",
|
||||
seccomp_rule: { "Standard IO": "c_cpp", "File IO": "c_cpp_file_io" },
|
||||
env: defaultEnv,
|
||||
},
|
||||
},
|
||||
Java: {
|
||||
template: "",
|
||||
compile: {
|
||||
src_name: "Main.java",
|
||||
exe_name: "Main",
|
||||
max_cpu_time: 5000,
|
||||
max_real_time: 10000,
|
||||
max_memory: -1,
|
||||
compile_command: "/usr/bin/javac {src_path} -d {exe_dir}",
|
||||
},
|
||||
run: {
|
||||
command: "/usr/bin/java -cp {exe_dir} -XX:MaxRAM={max_memory}k Main",
|
||||
seccomp_rule: null,
|
||||
env: defaultEnv,
|
||||
memory_limit_check_only: 1,
|
||||
},
|
||||
},
|
||||
Python3: {
|
||||
template: "",
|
||||
compile: {
|
||||
src_name: "solution.py",
|
||||
exe_name: "solution.py",
|
||||
max_cpu_time: 3000,
|
||||
max_real_time: 10000,
|
||||
max_memory: 128 * 1024 * 1024,
|
||||
compile_command: "/usr/bin/python3 -m py_compile {src_path}",
|
||||
},
|
||||
run: {
|
||||
command: "/usr/bin/python3 -BS {exe_path}",
|
||||
seccomp_rule: "general",
|
||||
env: defaultEnv,
|
||||
},
|
||||
},
|
||||
Golang: {
|
||||
template: "",
|
||||
compile: {
|
||||
src_name: "main.go",
|
||||
exe_name: "main",
|
||||
max_cpu_time: 3000,
|
||||
max_real_time: 5000,
|
||||
max_memory: 1024 * 1024 * 1024,
|
||||
compile_command: "/usr/bin/go build -o {exe_path} {src_path}",
|
||||
env: ["GOCACHE=/tmp", "GOPATH=/tmp", "GOMAXPROCS=1", ...defaultEnv],
|
||||
},
|
||||
run: {
|
||||
command: "{exe_path}",
|
||||
seccomp_rule: "golang",
|
||||
env: ["GOMAXPROCS=1", ...defaultEnv],
|
||||
memory_limit_check_only: 1,
|
||||
},
|
||||
},
|
||||
JavaScript: {
|
||||
template: "",
|
||||
compile: {
|
||||
src_name: "main.js",
|
||||
exe_name: "main.js",
|
||||
max_cpu_time: 3000,
|
||||
max_real_time: 5000,
|
||||
max_memory: 1024 * 1024 * 1024,
|
||||
compile_command: "/usr/bin/node --check {src_path}",
|
||||
env: defaultEnv,
|
||||
},
|
||||
run: {
|
||||
command: "/usr/bin/node {exe_path}",
|
||||
seccomp_rule: "node",
|
||||
env: defaultEnv,
|
||||
memory_limit_check_only: 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
355
apps/api/src/judge/run.ts
Normal file
355
apps/api/src/judge/run.ts
Normal file
@@ -0,0 +1,355 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
20
apps/api/src/judge/status.ts
Normal file
20
apps/api/src/judge/status.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export const JudgeStatus = {
|
||||
COMPILE_ERROR: -2,
|
||||
WRONG_ANSWER: -1,
|
||||
ACCEPTED: 0,
|
||||
CPU_TIME_LIMIT_EXCEEDED: 1,
|
||||
REAL_TIME_LIMIT_EXCEEDED: 2,
|
||||
MEMORY_LIMIT_EXCEEDED: 3,
|
||||
RUNTIME_ERROR: 4,
|
||||
SYSTEM_ERROR: 5,
|
||||
PENDING: 6,
|
||||
JUDGING: 7,
|
||||
PARTIALLY_ACCEPTED: 8,
|
||||
AST_CHECK_FAILED: 10,
|
||||
} as const
|
||||
|
||||
export type JudgeStatusValue = (typeof JudgeStatus)[keyof typeof JudgeStatus]
|
||||
|
||||
export function isAccepted(result: number) {
|
||||
return result === JudgeStatus.ACCEPTED || result === JudgeStatus.AST_CHECK_FAILED
|
||||
}
|
||||
12
apps/api/src/judge/template.ts
Normal file
12
apps/api/src/judge/template.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export function parseProblemTemplate(template: string) {
|
||||
const section = (name: string) =>
|
||||
template.match(
|
||||
new RegExp(`//${name} BEGIN\\n([\\s\\S]+?)//${name} END`),
|
||||
)?.[1] ?? ""
|
||||
|
||||
return {
|
||||
prepend: section("PREPEND"),
|
||||
template: section("TEMPLATE"),
|
||||
append: section("APPEND"),
|
||||
}
|
||||
}
|
||||
12
apps/api/src/queue.ts
Normal file
12
apps/api/src/queue.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Queue } from "bullmq"
|
||||
|
||||
import { judgeQueueName, type JudgeJobData } from "./judge/job"
|
||||
import { createBlockingRedis } from "./redis"
|
||||
|
||||
export const judgeQueue = new Queue<JudgeJobData>(judgeQueueName, {
|
||||
connection: createBlockingRedis(),
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: 100,
|
||||
removeOnFail: 500,
|
||||
},
|
||||
})
|
||||
19
apps/api/src/redis.ts
Normal file
19
apps/api/src/redis.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import Redis from "ioredis"
|
||||
|
||||
import { config } from "./config"
|
||||
|
||||
export const redis = new Redis(config.redisUrl, {
|
||||
maxRetriesPerRequest: 1,
|
||||
})
|
||||
|
||||
export function createBlockingRedis() {
|
||||
return new Redis(config.redisUrl, {
|
||||
maxRetriesPerRequest: null,
|
||||
})
|
||||
}
|
||||
|
||||
export function createSubscriberRedis() {
|
||||
return new Redis(config.redisUrl, {
|
||||
maxRetriesPerRequest: null,
|
||||
})
|
||||
}
|
||||
110
apps/api/src/routes/auth.ts
Normal file
110
apps/api/src/routes/auth.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import {
|
||||
loginRequestSchema,
|
||||
sessionUserSchema,
|
||||
userProfileSchema,
|
||||
} from "@oj2/contract"
|
||||
import { and, eq, sql } from "drizzle-orm"
|
||||
import { Hono } from "hono"
|
||||
|
||||
import { optionalAuth, type AppEnv } from "../auth/middleware"
|
||||
import { createSession, destroySession } from "../auth/session"
|
||||
import { verifyPassword } from "../auth/password"
|
||||
import { db, schema } from "../db"
|
||||
import { failure, success } from "../http"
|
||||
|
||||
export const authRoutes = new Hono<AppEnv>()
|
||||
|
||||
authRoutes.post("/auth/login", async (c) => {
|
||||
const parsed = loginRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!parsed.success) {
|
||||
return failure(c, 400, "invalid-request", "Username and password are required")
|
||||
}
|
||||
|
||||
const [user] = await db
|
||||
.select()
|
||||
.from(schema.user)
|
||||
.where(
|
||||
sql`lower(${schema.user.username}) = lower(${parsed.data.username})`,
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!user) {
|
||||
return failure(c, 401, "invalid-credentials", "Invalid username or password")
|
||||
}
|
||||
if (user.isDisabled) {
|
||||
return failure(c, 403, "account-disabled", "Your account has been disabled")
|
||||
}
|
||||
|
||||
const password = await verifyPassword(parsed.data.password, user.password)
|
||||
if (!password.valid) {
|
||||
return failure(c, 401, "invalid-credentials", "Invalid username or password")
|
||||
}
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const update: { lastLogin: string; password?: string } = { lastLogin: now }
|
||||
if (password.needsUpgrade) {
|
||||
update.password = await Bun.password.hash(parsed.data.password, {
|
||||
algorithm: "argon2id",
|
||||
})
|
||||
}
|
||||
await db.update(schema.user).set(update).where(eq(schema.user.id, user.id))
|
||||
await createSession(c, user.id)
|
||||
|
||||
return success(c, { ok: true })
|
||||
})
|
||||
|
||||
authRoutes.delete("/auth/session", async (c) => {
|
||||
await destroySession(c)
|
||||
return success(c, null)
|
||||
})
|
||||
|
||||
authRoutes.get("/me", optionalAuth, async (c) => {
|
||||
const authUser = c.get("user")
|
||||
if (!authUser) return success(c, null)
|
||||
|
||||
const [row] = await db
|
||||
.select({
|
||||
profile: schema.userProfile,
|
||||
user: schema.user,
|
||||
})
|
||||
.from(schema.userProfile)
|
||||
.innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id))
|
||||
.where(
|
||||
and(
|
||||
eq(schema.user.id, authUser.id),
|
||||
eq(schema.user.isDisabled, false),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!row) return failure(c, 404, "profile-not-found", "User profile does not exist")
|
||||
|
||||
const data = userProfileSchema.parse({
|
||||
id: row.profile.id,
|
||||
user: sessionUserSchema.parse({
|
||||
id: row.user.id,
|
||||
username: row.user.username,
|
||||
email: row.user.email,
|
||||
adminType: row.user.adminType,
|
||||
problemPermission: row.user.problemPermission,
|
||||
createTime: row.user.createTime,
|
||||
lastLogin: row.user.lastLogin,
|
||||
openApi: row.user.openApi,
|
||||
isDisabled: row.user.isDisabled,
|
||||
className: row.user.className,
|
||||
}),
|
||||
realName: row.profile.realName,
|
||||
acmProblemsStatus: row.profile.acmProblemsStatus,
|
||||
avatar: row.profile.avatar,
|
||||
blog: row.profile.blog,
|
||||
mood: row.profile.mood,
|
||||
github: row.profile.github,
|
||||
school: row.profile.school,
|
||||
major: row.profile.major,
|
||||
language: row.profile.language,
|
||||
acceptedNumber: row.profile.acceptedNumber,
|
||||
submissionNumber: row.profile.submissionNumber,
|
||||
})
|
||||
|
||||
return success(c, data)
|
||||
})
|
||||
86
apps/api/src/routes/judge-server.ts
Normal file
86
apps/api/src/routes/judge-server.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { createHash, timingSafeEqual } from "node:crypto"
|
||||
|
||||
import { eq } from "drizzle-orm"
|
||||
import { Hono, type Context } from "hono"
|
||||
import { z } from "zod"
|
||||
|
||||
import { config } from "../config"
|
||||
import { db, schema } from "../db"
|
||||
import { failure } from "../http"
|
||||
|
||||
const heartbeatSchema = z.object({
|
||||
hostname: z.string().min(1).max(128),
|
||||
judger_version: z.string().min(1).max(32),
|
||||
cpu_core: z.number().int().positive(),
|
||||
memory: z.number().min(0).max(100),
|
||||
cpu: z.number().min(0).max(100),
|
||||
action: z.literal("heartbeat"),
|
||||
service_url: z.string().min(1).max(256),
|
||||
})
|
||||
|
||||
export const judgeServerRoutes = new Hono()
|
||||
|
||||
function tokenMatches(value: string | undefined) {
|
||||
if (!value) return false
|
||||
const expected = createHash("sha256")
|
||||
.update(config.judgeServerToken)
|
||||
.digest("hex")
|
||||
const actualBuffer = Buffer.from(value)
|
||||
const expectedBuffer = Buffer.from(expected)
|
||||
return (
|
||||
actualBuffer.length === expectedBuffer.length &&
|
||||
timingSafeEqual(actualBuffer, expectedBuffer)
|
||||
)
|
||||
}
|
||||
|
||||
async function heartbeat(c: Context) {
|
||||
if (!tokenMatches(c.req.header("X-Judge-Server-Token"))) {
|
||||
return failure(c, 403, "invalid-judge-token", "Invalid token")
|
||||
}
|
||||
|
||||
const parsed = heartbeatSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!parsed.success) {
|
||||
return failure(c, 400, "invalid-heartbeat", "Invalid heartbeat payload")
|
||||
}
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const [existing] = await db
|
||||
.select({ id: schema.judgeServer.id })
|
||||
.from(schema.judgeServer)
|
||||
.where(eq(schema.judgeServer.hostname, parsed.data.hostname))
|
||||
.limit(1)
|
||||
|
||||
const common = {
|
||||
ip:
|
||||
c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ||
|
||||
c.req.header("x-real-ip") ||
|
||||
null,
|
||||
judgerVersion: parsed.data.judger_version,
|
||||
cpuCore: parsed.data.cpu_core,
|
||||
memoryUsage: parsed.data.memory,
|
||||
cpuUsage: parsed.data.cpu,
|
||||
lastHeartbeat: now,
|
||||
serviceUrl: parsed.data.service_url,
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
await db
|
||||
.update(schema.judgeServer)
|
||||
.set(common)
|
||||
.where(eq(schema.judgeServer.id, existing.id))
|
||||
} else {
|
||||
await db.insert(schema.judgeServer).values({
|
||||
...common,
|
||||
hostname: parsed.data.hostname,
|
||||
createTime: now,
|
||||
taskNumber: 0,
|
||||
isDisabled: false,
|
||||
})
|
||||
}
|
||||
|
||||
// JudgeServer 1.6.1 healthcheck still reads the legacy { error, data } envelope.
|
||||
return c.json({ error: null, data: null })
|
||||
}
|
||||
|
||||
judgeServerRoutes.post("/judge-server/heartbeat", heartbeat)
|
||||
judgeServerRoutes.post("/judge-server/heartbeat/", heartbeat)
|
||||
@@ -1,10 +1,32 @@
|
||||
import { problemSummarySchema } from "@oj2/contract"
|
||||
import { desc } from "drizzle-orm"
|
||||
import { problemDetailSchema, problemSummarySchema } from "@oj2/contract"
|
||||
import { and, count, desc, eq, isNull, notInArray } from "drizzle-orm"
|
||||
import { Hono } from "hono"
|
||||
|
||||
import { optionalAuth, type AppEnv } from "../auth/middleware"
|
||||
import { db, schema } from "../db"
|
||||
import { failure, success } from "../http"
|
||||
|
||||
export const problemRoutes = new Hono()
|
||||
export const problemRoutes = new Hono<AppEnv>()
|
||||
|
||||
function objectValue(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {}
|
||||
}
|
||||
|
||||
function stringArray(value: unknown): string[] {
|
||||
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []
|
||||
}
|
||||
|
||||
function publicTemplates(value: unknown) {
|
||||
const templates: Record<string, string> = {}
|
||||
for (const [language, raw] of Object.entries(objectValue(value))) {
|
||||
if (typeof raw !== "string") continue
|
||||
const match = raw.match(/\/\/TEMPLATE BEGIN\n([\s\S]+?)\/\/TEMPLATE END/)
|
||||
templates[language] = match?.[1] ?? ""
|
||||
}
|
||||
return templates
|
||||
}
|
||||
|
||||
problemRoutes.get("/problems", async (c) => {
|
||||
const rows = await db
|
||||
@@ -17,9 +39,111 @@ problemRoutes.get("/problems", async (c) => {
|
||||
acceptedNumber: schema.problem.acceptedNumber,
|
||||
})
|
||||
.from(schema.problem)
|
||||
.where(and(eq(schema.problem.visible, true), isNull(schema.problem.contestId)))
|
||||
.orderBy(desc(schema.problem.id))
|
||||
.limit(20)
|
||||
|
||||
const data = rows.map((row) => problemSummarySchema.parse(row))
|
||||
return c.json({ data })
|
||||
return success(c, data)
|
||||
})
|
||||
|
||||
problemRoutes.get("/problems/:displayId", optionalAuth, async (c) => {
|
||||
const [row] = await db
|
||||
.select({
|
||||
problem: schema.problem,
|
||||
creatorId: schema.user.id,
|
||||
creatorUsername: schema.user.username,
|
||||
})
|
||||
.from(schema.problem)
|
||||
.innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
|
||||
.where(
|
||||
and(
|
||||
eq(schema.problem.displayId, c.req.param("displayId")),
|
||||
eq(schema.problem.visible, true),
|
||||
isNull(schema.problem.contestId),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!row) return failure(c, 404, "problem-not-found", "Problem does not exist")
|
||||
|
||||
const tagRows = await db
|
||||
.select({ name: schema.problemTag.name })
|
||||
.from(schema.problemTags)
|
||||
.innerJoin(
|
||||
schema.problemTag,
|
||||
eq(schema.problemTags.problemtagId, schema.problemTag.id),
|
||||
)
|
||||
.where(eq(schema.problemTags.problemId, row.problem.id))
|
||||
|
||||
const user = c.get("user")
|
||||
let myStatus: number | null = null
|
||||
let myFailedCount = 0
|
||||
if (user) {
|
||||
const [profile] = await db
|
||||
.select({ status: schema.userProfile.acmProblemsStatus })
|
||||
.from(schema.userProfile)
|
||||
.where(eq(schema.userProfile.userId, user.id))
|
||||
.limit(1)
|
||||
const statuses = objectValue(objectValue(profile?.status).problems)
|
||||
const problemStatus = objectValue(statuses[String(row.problem.id)]).status
|
||||
if (typeof problemStatus === "number") myStatus = problemStatus
|
||||
|
||||
const [failed] = await db
|
||||
.select({ value: count() })
|
||||
.from(schema.submission)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.submission.userId, user.id),
|
||||
eq(schema.submission.problemId, row.problem.id),
|
||||
notInArray(schema.submission.result, [0, 10]),
|
||||
),
|
||||
)
|
||||
myFailedCount = failed?.value ?? 0
|
||||
}
|
||||
|
||||
const samples = Array.isArray(row.problem.samples) ? row.problem.samples : []
|
||||
const data = problemDetailSchema.parse({
|
||||
id: row.problem.id,
|
||||
_id: row.problem.displayId,
|
||||
title: row.problem.title,
|
||||
description: row.problem.description,
|
||||
inputDescription: row.problem.inputDescription,
|
||||
outputDescription: row.problem.outputDescription,
|
||||
samples,
|
||||
hint: row.problem.hint,
|
||||
languages: stringArray(row.problem.languages),
|
||||
template: publicTemplates(row.problem.template),
|
||||
createTime: row.problem.createTime,
|
||||
lastUpdateTime: row.problem.lastUpdateTime,
|
||||
timeLimit: row.problem.timeLimit,
|
||||
memoryLimit: row.problem.memoryLimit,
|
||||
difficulty: row.problem.difficulty,
|
||||
source: row.problem.source,
|
||||
prompt: row.problem.prompt,
|
||||
submissionNumber: row.problem.submissionNumber,
|
||||
acceptedNumber: row.problem.acceptedNumber,
|
||||
statisticInfo: objectValue(row.problem.statisticInfo),
|
||||
shareSubmission: row.problem.shareSubmission,
|
||||
contestId: row.problem.contestId,
|
||||
tags: tagRows.map((tag) => tag.name),
|
||||
createdBy: {
|
||||
id: row.creatorId,
|
||||
username: row.creatorUsername,
|
||||
realName: null,
|
||||
},
|
||||
myStatus,
|
||||
myFailedCount,
|
||||
allowFlowchart: row.problem.allowFlowchart,
|
||||
showFlowchart: row.problem.showFlowchart,
|
||||
mermaidCode: row.problem.allowFlowchart ? null : row.problem.mermaidCode,
|
||||
flowchartData: row.problem.allowFlowchart
|
||||
? null
|
||||
: objectValue(row.problem.flowchartData),
|
||||
flowchartHint: row.problem.flowchartHint,
|
||||
sqlConfig: row.problem.sqlConfig ? objectValue(row.problem.sqlConfig) : null,
|
||||
sqlDisplay: row.problem.sqlDisplay ? objectValue(row.problem.sqlDisplay) : null,
|
||||
})
|
||||
|
||||
return success(c, data)
|
||||
})
|
||||
|
||||
155
apps/api/src/routes/submission.ts
Normal file
155
apps/api/src/routes/submission.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { randomBytes } from "node:crypto"
|
||||
|
||||
import {
|
||||
createSubmissionRequestSchema,
|
||||
createSubmissionResponseSchema,
|
||||
submissionDetailSchema,
|
||||
} from "@oj2/contract"
|
||||
import { and, eq, isNull } from "drizzle-orm"
|
||||
import { Hono } from "hono"
|
||||
|
||||
import { requireAuth, type AppEnv } from "../auth/middleware"
|
||||
import { db, schema } from "../db"
|
||||
import { failure, success } from "../http"
|
||||
import { JudgeStatus } from "../judge/status"
|
||||
import { judgeQueue } from "../queue"
|
||||
|
||||
export const submissionRoutes = new Hono<AppEnv>()
|
||||
|
||||
function stringArray(value: unknown): string[] {
|
||||
return Array.isArray(value)
|
||||
? value.filter((item): item is string => typeof item === "string")
|
||||
: []
|
||||
}
|
||||
|
||||
function objectValue(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {}
|
||||
}
|
||||
|
||||
submissionRoutes.use("/submissions", requireAuth)
|
||||
submissionRoutes.use("/submissions/*", requireAuth)
|
||||
|
||||
submissionRoutes.post("/submissions", async (c) => {
|
||||
const parsed = createSubmissionRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
)
|
||||
if (!parsed.success) {
|
||||
return failure(c, 400, "invalid-request", "Invalid submission payload")
|
||||
}
|
||||
if (parsed.data.contestId) {
|
||||
return failure(
|
||||
c,
|
||||
400,
|
||||
"contest-not-supported",
|
||||
"Contest submissions are not part of the Phase 2 slice",
|
||||
)
|
||||
}
|
||||
|
||||
const [problem] = await db
|
||||
.select({
|
||||
id: schema.problem.id,
|
||||
languages: schema.problem.languages,
|
||||
})
|
||||
.from(schema.problem)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.problem.id, parsed.data.problemId),
|
||||
eq(schema.problem.visible, true),
|
||||
isNull(schema.problem.contestId),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exist")
|
||||
if (!stringArray(problem.languages).includes(parsed.data.language)) {
|
||||
return failure(
|
||||
c,
|
||||
400,
|
||||
"language-not-allowed",
|
||||
`${parsed.data.language} is not allowed in the problem`,
|
||||
)
|
||||
}
|
||||
|
||||
const user = c.get("user")!
|
||||
const submissionId = randomBytes(16).toString("hex")
|
||||
const createTime = new Date().toISOString()
|
||||
const forwarded = c.req.header("x-forwarded-for")?.split(",")[0]?.trim()
|
||||
const ip = forwarded || c.req.header("x-real-ip") || null
|
||||
|
||||
await db.insert(schema.submission).values({
|
||||
id: submissionId,
|
||||
problemId: problem.id,
|
||||
createTime,
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
code: parsed.data.code,
|
||||
result: JudgeStatus.PENDING,
|
||||
info: {},
|
||||
language: parsed.data.language,
|
||||
shared: false,
|
||||
statisticInfo: {},
|
||||
ip,
|
||||
contestId: null,
|
||||
})
|
||||
|
||||
try {
|
||||
await judgeQueue.add(
|
||||
"judge",
|
||||
{ submissionId, problemId: problem.id },
|
||||
{ jobId: submissionId },
|
||||
)
|
||||
} catch (error) {
|
||||
await db
|
||||
.update(schema.submission)
|
||||
.set({ result: JudgeStatus.SYSTEM_ERROR })
|
||||
.where(eq(schema.submission.id, submissionId))
|
||||
console.error("Failed to enqueue submission", error)
|
||||
return failure(c, 502, "queue-unavailable", "Judge queue is unavailable")
|
||||
}
|
||||
|
||||
return success(
|
||||
c,
|
||||
createSubmissionResponseSchema.parse({ submissionId }),
|
||||
201,
|
||||
)
|
||||
})
|
||||
|
||||
submissionRoutes.get("/submissions/:id", async (c) => {
|
||||
const user = c.get("user")!
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(schema.submission)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.submission.id, c.req.param("id")),
|
||||
eq(schema.submission.userId, user.id),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!row) {
|
||||
return failure(c, 404, "submission-not-found", "Submission does not exist")
|
||||
}
|
||||
|
||||
const data = submissionDetailSchema.parse({
|
||||
id: row.id,
|
||||
createTime: row.createTime,
|
||||
userId: row.userId,
|
||||
username: row.username,
|
||||
code: row.code,
|
||||
result: row.result,
|
||||
info: row.info,
|
||||
language: row.language,
|
||||
shared: row.shared,
|
||||
statisticInfo: objectValue(row.statisticInfo),
|
||||
ip: row.ip,
|
||||
contestId: row.contestId,
|
||||
problemId: row.problemId,
|
||||
showLink: true,
|
||||
canUnshare: true,
|
||||
})
|
||||
|
||||
return success(c, data)
|
||||
})
|
||||
58
apps/api/src/scripts/seed-dev.ts
Normal file
58
apps/api/src/scripts/seed-dev.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { eq, sql } from "drizzle-orm"
|
||||
|
||||
import { db, schema } from "../db"
|
||||
|
||||
const username = process.env.OJ2_DEV_USERNAME ?? "student"
|
||||
const password = process.env.OJ2_DEV_PASSWORD ?? "student123"
|
||||
const passwordHash = await Bun.password.hash(password, { algorithm: "argon2id" })
|
||||
const now = new Date().toISOString()
|
||||
|
||||
// Phase 1 imports rows with explicit ids, so PostgreSQL's sequence has not moved.
|
||||
await db.execute(
|
||||
sql`select setval(pg_get_serial_sequence('"user"', 'id'), coalesce(max(${schema.user.id}), 1), true) from ${schema.user}`,
|
||||
)
|
||||
|
||||
const [user] = await db
|
||||
.insert(schema.user)
|
||||
.values({
|
||||
username,
|
||||
password: passwordHash,
|
||||
rawPassword: password,
|
||||
email: `${username}@example.test`,
|
||||
createTime: now,
|
||||
adminType: "Regular User",
|
||||
problemPermission: "None",
|
||||
openApi: false,
|
||||
isDisabled: false,
|
||||
sessionKeys: [],
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: schema.user.username,
|
||||
set: {
|
||||
password: passwordHash,
|
||||
rawPassword: password,
|
||||
email: `${username}@example.test`,
|
||||
isDisabled: false,
|
||||
},
|
||||
})
|
||||
.returning({ id: schema.user.id, username: schema.user.username })
|
||||
|
||||
if (!user) throw new Error("Failed to seed development user")
|
||||
|
||||
const [profile] = await db
|
||||
.select({ id: schema.userProfile.id })
|
||||
.from(schema.userProfile)
|
||||
.where(eq(schema.userProfile.userId, user.id))
|
||||
.limit(1)
|
||||
|
||||
if (!profile) {
|
||||
await db.insert(schema.userProfile).values({
|
||||
userId: user.id,
|
||||
acmProblemsStatus: { problems: {}, contest_problems: {} },
|
||||
avatar: "/public/avatar/default.png",
|
||||
realName: "Phase 2 Student",
|
||||
})
|
||||
}
|
||||
|
||||
console.log(`Seeded development login: ${user.username} / ${password}`)
|
||||
process.exit(0)
|
||||
149
apps/api/src/websocket.ts
Normal file
149
apps/api/src/websocket.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { submissionUpdateSchema } from "@oj2/contract"
|
||||
import { and, eq } from "drizzle-orm"
|
||||
|
||||
import { db, schema } from "./db"
|
||||
import {
|
||||
parseSubmissionEvent,
|
||||
submissionUpdateChannel,
|
||||
userSubmissionTopic,
|
||||
} from "./judge/events"
|
||||
import { JudgeStatus } from "./judge/status"
|
||||
import { createSubscriberRedis } from "./redis"
|
||||
|
||||
export interface SubmissionSocketData {
|
||||
userId: number
|
||||
username: string
|
||||
}
|
||||
|
||||
function objectValue(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {}
|
||||
}
|
||||
|
||||
export function submissionWebSocketHandler(): Bun.WebSocketHandler<SubmissionSocketData> {
|
||||
return {
|
||||
open(ws) {
|
||||
ws.subscribe(userSubmissionTopic(ws.data.userId))
|
||||
},
|
||||
message(ws, message) {
|
||||
void handleMessage(ws, String(message))
|
||||
},
|
||||
close(ws) {
|
||||
ws.unsubscribe(userSubmissionTopic(ws.data.userId))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMessage(
|
||||
ws: Bun.ServerWebSocket<SubmissionSocketData>,
|
||||
raw: string,
|
||||
) {
|
||||
const [activeUser] = await db
|
||||
.select({ id: schema.user.id })
|
||||
.from(schema.user)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.user.id, ws.data.userId),
|
||||
eq(schema.user.isDisabled, false),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
if (!activeUser) {
|
||||
ws.close(1008, "Account disabled")
|
||||
return
|
||||
}
|
||||
|
||||
let message: { type?: unknown; timestamp?: unknown; submission_id?: unknown }
|
||||
try {
|
||||
message = JSON.parse(raw) as typeof message
|
||||
} catch {
|
||||
ws.send(JSON.stringify({ type: "error", message: "Invalid JSON" }))
|
||||
return
|
||||
}
|
||||
|
||||
if (message.type === "ping") {
|
||||
ws.send(JSON.stringify({ type: "pong", timestamp: message.timestamp }))
|
||||
return
|
||||
}
|
||||
if (message.type !== "subscribe" || typeof message.submission_id !== "string") {
|
||||
ws.send(JSON.stringify({ type: "error", message: "Invalid message" }))
|
||||
return
|
||||
}
|
||||
|
||||
const [submission] = await db
|
||||
.select({
|
||||
id: schema.submission.id,
|
||||
result: schema.submission.result,
|
||||
statisticInfo: schema.submission.statisticInfo,
|
||||
})
|
||||
.from(schema.submission)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.submission.id, message.submission_id),
|
||||
eq(schema.submission.userId, ws.data.userId),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!submission) {
|
||||
ws.send(JSON.stringify({ type: "error", message: "Submission not found" }))
|
||||
return
|
||||
}
|
||||
|
||||
const statistics = objectValue(submission.statisticInfo)
|
||||
const status =
|
||||
submission.result === JudgeStatus.PENDING
|
||||
? "pending"
|
||||
: submission.result === JudgeStatus.JUDGING
|
||||
? "judging"
|
||||
: submission.result === JudgeStatus.SYSTEM_ERROR
|
||||
? "error"
|
||||
: "finished"
|
||||
const parsed = submissionUpdateSchema.safeParse({
|
||||
type: "submission_update",
|
||||
submission_id: submission.id,
|
||||
result: submission.result,
|
||||
status,
|
||||
time_cost: statistics.time_cost,
|
||||
memory_cost: statistics.memory_cost,
|
||||
score: statistics.score,
|
||||
err_info: statistics.err_info,
|
||||
})
|
||||
if (parsed.success) ws.send(JSON.stringify(parsed.data))
|
||||
}
|
||||
|
||||
export async function bridgeSubmissionEvents(
|
||||
server: Bun.Server<SubmissionSocketData>,
|
||||
) {
|
||||
const subscriber = createSubscriberRedis()
|
||||
subscriber.on("message", (channel, raw) => {
|
||||
if (channel !== submissionUpdateChannel) return
|
||||
const event = parseSubmissionEvent(raw)
|
||||
if (!event) return
|
||||
void (async () => {
|
||||
const [activeUser] = await db
|
||||
.select({ id: schema.user.id })
|
||||
.from(schema.user)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.user.id, event.userId),
|
||||
eq(schema.user.isDisabled, false),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
if (!activeUser) return
|
||||
server.publish(
|
||||
userSubmissionTopic(event.userId),
|
||||
JSON.stringify(event.data),
|
||||
)
|
||||
})().catch((error) => {
|
||||
console.error("Failed to bridge submission event", error)
|
||||
})
|
||||
})
|
||||
subscriber.on("error", (error) => {
|
||||
console.error("Submission event subscriber error", error)
|
||||
})
|
||||
await subscriber.subscribe(submissionUpdateChannel)
|
||||
return subscriber
|
||||
}
|
||||
33
apps/api/src/worker.ts
Normal file
33
apps/api/src/worker.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Worker } from "bullmq"
|
||||
|
||||
import { config } from "./config"
|
||||
import { judgeQueueName, type JudgeJobData } from "./judge/job"
|
||||
import { judgeSubmission } from "./judge/run"
|
||||
import { createBlockingRedis } from "./redis"
|
||||
|
||||
const worker = new Worker<JudgeJobData>(
|
||||
judgeQueueName,
|
||||
async (job) => judgeSubmission(job.data),
|
||||
{
|
||||
connection: createBlockingRedis(),
|
||||
concurrency: config.judgeConcurrency,
|
||||
},
|
||||
)
|
||||
|
||||
worker.on("ready", () => {
|
||||
console.log(`Judge worker ready (concurrency=${config.judgeConcurrency})`)
|
||||
})
|
||||
worker.on("failed", (job, error) => {
|
||||
console.error(`Judge job ${job?.id ?? "unknown"} failed`, error)
|
||||
})
|
||||
worker.on("error", (error) => {
|
||||
console.error("Judge worker error", error)
|
||||
})
|
||||
|
||||
async function shutdown() {
|
||||
await worker.close()
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
process.on("SIGINT", shutdown)
|
||||
process.on("SIGTERM", shutdown)
|
||||
Reference in New Issue
Block a user