按外部分析报告(reports/analysis)挑出来做的几条: - CI 在编完前端之后跑 `bunx vp run verify`,有一项不过就不部署(必须排在编前端之后: 前端类型检查要的 auto-imports.d.ts / components.d.ts 是 vite build 时才生成的) - 连不上判题机(连接被拒、解析不到、被重置)时,正式判题等 2/5/10 秒重试,不再当场记成 系统错误;超时、判题机回错、页面上的运行和自测不重试 - worker 每 10 秒刷一次「还活着」,compose 加 `oj2-api healthcheck --worker`,deploy.sh 起栈后 也等 worker 健康(docker 不会因 unhealthy 自动重启,只是看得见、部署时拦得住) - 三套 compose 的容器日志限 10MB × 3 - `/public/%` 这类畸形编码回 404 而不是 500;找不到的接口回统一的 JSON 错误 - 删掉没人用的「停用判题机」接口,契约不再给 task_number / is_disabled(列留在库里) - 删掉腐化的 apps/web/tests/submitButtonState.test.ts;契约包 zod 范围对齐到 ^4.6.6 - 修几处和实际对不上的注释与文档(表数、编译并发、AST 语言数、deploy.sh 流程、校验处数) ⚠️ 改了 compose 的日志配置,这次部署会把 postgres / redis / 判题机也重建一遍,各停几秒 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b344bd1dd6
commit
b046af4ba0
20 files changed
+178
-95
No files matched your search
@@ -81,6 +81,15 @@ jobs:
|
||||
- name: 编前端
|
||||
run: cd apps/web && bun run build
|
||||
|
||||
# 格式、lint、前后端类型检查、路由遮蔽、AST 节点,有一项不过就不部署(同步到服务器之前)。
|
||||
# 不加它的话,每次 push 都是直接上线、中间没有任何检查 —— vite build 不做类型检查。
|
||||
#
|
||||
# 必须排在「编前端」之后:前端类型检查要 unplugin 生成的 auto-imports.d.ts /
|
||||
# components.d.ts,它们不进版本库,vite build 时才生成;排在前面会报几百条
|
||||
# Cannot find name 'computed'(实测过)。
|
||||
- name: 检查
|
||||
run: bunx vp run verify
|
||||
|
||||
- name: 配置 SSH
|
||||
run: |
|
||||
mkdir -p ~/.ssh && chmod 700 ~/.ssh
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// 2026-08-26:旧 Django 后端下线,7 张框架表(auth_group、auth_group_permissions、
|
||||
// auth_permission、django_content_type、django_dramatiq_task、django_migrations、
|
||||
// django_session)已由 0002_drop_django_leftovers 删除,drizzle.config.ts 的
|
||||
// tablesFilter 随之移除。库里现在就是这 27 张业务表。
|
||||
// tablesFilter 随之移除。当时库里剩 27 张业务表(之后陆续加了新表,以本文件为准)。
|
||||
//
|
||||
// 2026-09-02:又清了一批只有 Django 时代写过、OJ2 一次都没读过的列 ——
|
||||
// 0008 删比赛 IP 白名单与 submission.ip(判题机的 judge_server.ip 保留,那是运维数据),
|
||||
|
||||
+11
-1
@@ -44,6 +44,10 @@ app.route('/api', submissionRoutes)
|
||||
app.route('/api', trialRunRoutes)
|
||||
app.route('/api/admin', adminRoutes)
|
||||
|
||||
// 没有这条的话,打错的接口地址回的是 Hono 默认的纯文本「404 Not Found」,前端 utils/api.ts 读不出
|
||||
// error.code / message,提示就成了笼统的「请求失败」。和其它错误同一个形状
|
||||
app.notFound((c) => c.json({ error: { code: 'not-found', message: 'Not found' } }, 404))
|
||||
|
||||
app.onError((error, c) => {
|
||||
console.error(error)
|
||||
return c.json({ error: { code: 'internal-error', message: 'Internal server error' } }, 500)
|
||||
@@ -63,7 +67,13 @@ const DEFAULT_AVATAR_SVG =
|
||||
* 这样开发(Vite 代理)和生产是同一条代码路径,少一处只在服务器上才出错的差异。
|
||||
*/
|
||||
async function serveUpload(pathname: string, prefix: string, directory: string) {
|
||||
const decoded = decodeURIComponent(pathname)
|
||||
// 畸形的百分号编码(`/public/upload/%`)decodeURIComponent 会抛 URIError,不接住就是 500
|
||||
let decoded: string
|
||||
try {
|
||||
decoded = decodeURIComponent(pathname)
|
||||
} catch {
|
||||
return new Response('Not found', { status: 404 })
|
||||
}
|
||||
const filename = basename(decoded)
|
||||
if (!filename || filename !== decoded.slice(prefix.length + 1)) {
|
||||
return new Response('Not found', { status: 404 })
|
||||
|
||||
@@ -16,7 +16,7 @@ import { parseProblemTemplate, shiftCompileLines } from './template'
|
||||
import { withJudgeSlot } from './slots'
|
||||
import { runSqlCase } from './sql'
|
||||
import { nativeRuntimeError, parsePythonTraceback, type RuntimeErrorInfo } from './runtime-diagnosis'
|
||||
import { compileError, judge, type JudgeCase, type JudgeResponse } from './sandbox'
|
||||
import { compileError, judge, JudgeUnreachableError, type JudgeCase, type JudgeResponse } from './sandbox'
|
||||
import { readInfo } from '../services/test-case'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { resolve as resolvePath } from 'node:path'
|
||||
@@ -79,6 +79,13 @@ function judgeTimeoutMs(timeLimit: number) {
|
||||
return Math.max(120_000, timeLimit * 60)
|
||||
}
|
||||
|
||||
/**
|
||||
* 连不上判题机时隔多久再试,一共再试这么多次。判题机容器重启(部署时重建、OOM 被拉起来)
|
||||
* 要几秒,合计十几秒盖得住;原来是当场记成系统错误,那几秒里交的全班都得自己重交。
|
||||
* 只重试「连不上」:超时(已经等了两分钟)、判题机回了错都不重试。等的时候不占名额
|
||||
*/
|
||||
const UNREACHABLE_RETRY_DELAYS_MS = [2_000, 5_000, 10_000]
|
||||
|
||||
async function requestJudge(
|
||||
language: string,
|
||||
code: string,
|
||||
@@ -88,6 +95,7 @@ async function requestJudge(
|
||||
output = false,
|
||||
) {
|
||||
const timeoutMs = judgeTimeoutMs(timeLimit)
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
// 超时从拿到名额才开始算:排队等名额不算判题机没回应
|
||||
return await withJudgeSlot('judge', { holdMs: timeoutMs + 30_000 }, () =>
|
||||
@@ -98,7 +106,11 @@ async function requestJudge(
|
||||
if (error instanceof DOMException && error.name === 'TimeoutError') {
|
||||
throw new Error(`判题机 ${timeoutMs / 1000} 秒没有回应`)
|
||||
}
|
||||
throw error
|
||||
const delay = UNREACHABLE_RETRY_DELAYS_MS[attempt]
|
||||
if (!(error instanceof JudgeUnreachableError) || delay === undefined) throw error
|
||||
console.warn(`[judge] ${error.message},${delay / 1000} 秒后重试(第 ${attempt + 1} 次)`)
|
||||
await Bun.sleep(delay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -106,8 +106,30 @@ function headers() {
|
||||
return { authorization: `Bearer ${config.judgeServerToken}` }
|
||||
}
|
||||
|
||||
/**
|
||||
* 判题机根本连不上:连接被拒、主机名解析不到、连接被重置 —— 判题机容器正在重启、OOM 被杀
|
||||
* 的那几秒就是这样。和「判题机回了错」「超时没回」分开,正式判题碰到它会等几秒再试
|
||||
* (judge/run.ts 的 requestJudge),而不是当场把学生的提交记成系统错误
|
||||
*/
|
||||
export class JudgeUnreachableError extends Error {
|
||||
constructor(cause: unknown) {
|
||||
super(`连不上判题机(${cause instanceof Error ? cause.message : String(cause)})`)
|
||||
this.name = 'JudgeUnreachableError'
|
||||
}
|
||||
}
|
||||
|
||||
/** fetch 判题机。网络层失败(Bun 里是带 code 的 TypeError)换成 JudgeUnreachableError;超时、中止原样抛 */
|
||||
async function judgeFetch(path: string, init: RequestInit & { signal?: AbortSignal }) {
|
||||
try {
|
||||
return await fetch(new URL(path, config.judgeServerUrl), init)
|
||||
} catch (error) {
|
||||
if (!init.signal?.aborted && error instanceof TypeError) throw new JudgeUnreachableError(error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function runCommand(command: Record<string, unknown>, signal?: AbortSignal) {
|
||||
const response = await fetch(new URL('/run', config.judgeServerUrl), {
|
||||
const response = await judgeFetch('/run', {
|
||||
method: 'POST',
|
||||
signal,
|
||||
headers: { ...headers(), 'content-type': 'application/json' },
|
||||
@@ -120,7 +142,7 @@ async function runCommand(command: Record<string, unknown>, signal?: AbortSignal
|
||||
}
|
||||
|
||||
async function readFile(fileId: string, signal?: AbortSignal) {
|
||||
const response = await fetch(new URL(`/file/${fileId}`, config.judgeServerUrl), { signal, headers: headers() })
|
||||
const response = await judgeFetch(`/file/${fileId}`, { signal, headers: headers() })
|
||||
if (!response.ok) throw new Error(`判题机取文件返回 HTTP ${response.status}`)
|
||||
return new Uint8Array(await response.arrayBuffer())
|
||||
}
|
||||
|
||||
+12
-1
@@ -9,7 +9,7 @@
|
||||
* oj2-api # 等同 serve
|
||||
* oj2-api serve # HTTP + WebSocket
|
||||
* oj2-api worker # BullMQ 判题消费者
|
||||
* oj2-api healthcheck # 探活,给 Dockerfile 的 HEALTHCHECK 用
|
||||
* oj2-api healthcheck # 探活,给 compose 的 healthcheck 用;--worker 探判题 worker
|
||||
* oj2-api sql-child # SQL 判题子进程,由服务自己 spawn,不该手动调
|
||||
* oj2-api migrate # 执行待办的数据库迁移,部署时由 docker/deploy.sh 调
|
||||
* oj2-api recount # 把题目/用户的计数列、成就的已解锁数重算回与明细一致,默认只读预演
|
||||
@@ -54,7 +54,18 @@ switch (command) {
|
||||
// 运行镜像是 debian-slim,没有 curl/wget,探活让二进制自己做。
|
||||
// 只打 /health,不碰库 —— 库挂了该由库自己的 healthcheck 报,
|
||||
// 不该让 api 容器跟着被判成不健康、进而被重启。
|
||||
//
|
||||
// `--worker`:worker 没有 HTTP,看它每 10 秒刷一次的时间戳(worker.ts),60 秒没刷就算挂了
|
||||
case 'healthcheck': {
|
||||
if (process.argv.includes('--worker')) {
|
||||
const { readFileSync } = await import('node:fs')
|
||||
try {
|
||||
const at = Number(readFileSync('/tmp/oj2-worker-alive', 'utf8'))
|
||||
process.exit(Date.now() - at < 60_000 ? 0 : 1)
|
||||
} catch {
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
const { config } = await import('./config')
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${config.port}/health`, {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
updateJudgeServerRequestSchema,
|
||||
updateWebsiteConfigRequestSchema,
|
||||
type DashboardInfo,
|
||||
type JudgeServer,
|
||||
@@ -28,7 +27,7 @@ import { TEACHER_ROLES } from '../helpers'
|
||||
import { onlineUserIds } from '../../auth/presence'
|
||||
import { JudgeStatus } from '../../judge/status'
|
||||
import { suggestActiveClass } from '../../services/class-activity'
|
||||
import { classPrefixCondition, queryInteger } from '../helpers'
|
||||
import { classPrefixCondition } from '../helpers'
|
||||
|
||||
export const adminConfRoutes = new Hono<AppEnv>()
|
||||
|
||||
@@ -98,8 +97,10 @@ adminConfRoutes.get('/judge-servers', requireSuperAdmin, async (c) => {
|
||||
return success(c, {
|
||||
// 后台要显示 token 才能拿去配判题机。这个接口是超管专属的
|
||||
token: config.judgeServerToken,
|
||||
// task_number、is_disabled 两列是旧 JudgeServer 时代的:判题不经调度、也没有「停用」这回事,
|
||||
// 列留在库里(删列是破坏性迁移,不值得),不往外给
|
||||
servers: rows.map(
|
||||
(row) =>
|
||||
({ taskNumber: _taskNumber, isDisabled: _isDisabled, ...row }) =>
|
||||
({
|
||||
...row,
|
||||
status: isAlive(row.lastHeartbeat) ? 'normal' : 'abnormal',
|
||||
@@ -108,21 +109,6 @@ adminConfRoutes.get('/judge-servers', requireSuperAdmin, async (c) => {
|
||||
} satisfies JudgeServerList)
|
||||
})
|
||||
|
||||
adminConfRoutes.put('/judge-servers/:id', requireSuperAdmin, async (c) => {
|
||||
const parsed = await parseBody(c, updateJudgeServerRequestSchema, 'isDisabled is required')
|
||||
if (!parsed.success) return parsed.response
|
||||
const updated = await db
|
||||
.update(schema.judgeServer)
|
||||
.set({ isDisabled: parsed.data.isDisabled })
|
||||
.where(eq(schema.judgeServer.id, queryInteger(c.req.param('id'), 0, { min: 1 })))
|
||||
.returning({ id: schema.judgeServer.id })
|
||||
if (updated.length === 0) return failure(c, 404, 'judge-server-not-found', 'Judge server does not exist')
|
||||
// 旧后端在这里会 process_pending_task() 把积压的待判任务重新分发。
|
||||
// 新架构不需要:任务在 BullMQ 里排着,worker 恢复就自己接着消费,不存在「没有新提交
|
||||
// 就一直 waiting」那种情况 —— 那是旧的自研分发器才有的问题。
|
||||
return success(c, null)
|
||||
})
|
||||
|
||||
adminConfRoutes.delete('/judge-servers/:hostname', requireSuperAdmin, async (c) => {
|
||||
const deleted = await db
|
||||
.delete(schema.judgeServer)
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* 不是合法 id 而返回 404 或者一堆看不懂的结果。阶段 4 真实发生过一次:
|
||||
* 两个教师用的分析端点被 `/problems/:id` 吃掉,评审时才发现。
|
||||
*
|
||||
* 加路由时顺手跑一下,比事后靠人眼在 200 多条路由里看出顺序问题可靠。
|
||||
* 加路由时顺手跑一下,比事后靠人眼在两百来条路由里看出顺序问题可靠。
|
||||
*
|
||||
* 局限:靠正则读源码,只认 `xxxRoutes.get('字面量', …)` 这种写法(单双引号都认),
|
||||
* 以及 `xxxRoutes.route('字面量', 子路由)` 的嵌套挂载(按挂载位置展开)。
|
||||
|
||||
+27
-2
@@ -1,4 +1,5 @@
|
||||
import { Worker } from 'bullmq'
|
||||
import { writeFileSync } from 'node:fs'
|
||||
|
||||
import { config } from './config'
|
||||
import { startJudgeHeartbeat } from './judge/heartbeat'
|
||||
@@ -8,8 +9,11 @@ import { flowchartQueueName, type FlowchartJobData } from './flowchart/job'
|
||||
import { evaluateFlowchart } from './flowchart/run'
|
||||
import { createBlockingRedis } from './redis'
|
||||
|
||||
const judgeConnection = createBlockingRedis()
|
||||
const flowchartConnection = createBlockingRedis()
|
||||
|
||||
const worker = new Worker<JudgeJobData>(judgeQueueName, async (job) => judgeSubmission(job.data), {
|
||||
connection: createBlockingRedis(),
|
||||
connection: judgeConnection,
|
||||
concurrency: config.judgeConcurrency,
|
||||
})
|
||||
|
||||
@@ -21,7 +25,7 @@ const flowchartWorker = new Worker<FlowchartJobData>(
|
||||
evaluateFlowchart(job.data, {
|
||||
isFinalAttempt: job.attemptsMade + 1 >= (job.opts.attempts ?? 1),
|
||||
}),
|
||||
{ connection: createBlockingRedis(), concurrency: 2 },
|
||||
{ connection: flowchartConnection, concurrency: 2 },
|
||||
)
|
||||
|
||||
worker.on('ready', () => {
|
||||
@@ -48,7 +52,28 @@ flowchartWorker.on('error', (error) => console.error('Flowchart worker error', e
|
||||
|
||||
const stopHeartbeat = startJudgeHeartbeat()
|
||||
|
||||
/**
|
||||
* 给 `oj2-api healthcheck --worker` 看的「还活着」:两个消费者都在跑、连 Redis 的连接都正常,
|
||||
* 才每 10 秒刷一次时间戳。事件循环卡死、和 Redis 断了、消费者停了,时间戳都会停下来,
|
||||
* 容器就被判成 unhealthy —— 原来 worker 没有健康检查,挂死了 docker 照样显示 running,
|
||||
* 判题队列静默积压
|
||||
*/
|
||||
// main.ts 的 healthcheck 写死了同一个路径:那边不能 import 这个文件(一 import 就把 worker 起来了)
|
||||
const WORKER_ALIVE_FILE = '/tmp/oj2-worker-alive'
|
||||
const markAlive = () => {
|
||||
if (!worker.isRunning() || !flowchartWorker.isRunning()) return
|
||||
if (judgeConnection.status !== 'ready' || flowchartConnection.status !== 'ready') return
|
||||
try {
|
||||
writeFileSync(WORKER_ALIVE_FILE, String(Date.now()))
|
||||
} catch (error) {
|
||||
console.error('Failed to write worker alive file', error)
|
||||
}
|
||||
}
|
||||
const aliveTimer = setInterval(markAlive, 10_000)
|
||||
worker.on('ready', markAlive)
|
||||
|
||||
async function shutdown() {
|
||||
clearInterval(aliveTimer)
|
||||
stopHeartbeat()
|
||||
await worker.close()
|
||||
await flowchartWorker.close()
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { getSubmitButtonState } from '../src/oj/problem/components/submitButtonState.ts'
|
||||
|
||||
const idleInput = {
|
||||
isAuthed: true,
|
||||
hasCode: true,
|
||||
isFormatting: false,
|
||||
isSubmitting: false,
|
||||
isJudging: false,
|
||||
isCooldown: false,
|
||||
}
|
||||
|
||||
test('shows a disabled loading state while formatting', () => {
|
||||
assert.deepEqual(getSubmitButtonState({ ...idleInput, isFormatting: true }), {
|
||||
disabled: true,
|
||||
label: '格式化中',
|
||||
icon: 'eos-icons:loading',
|
||||
})
|
||||
})
|
||||
|
||||
test('shows submitting immediately after formatting', () => {
|
||||
assert.deepEqual(getSubmitButtonState({ ...idleInput, isSubmitting: true }), {
|
||||
disabled: true,
|
||||
label: '正在提交',
|
||||
icon: 'eos-icons:loading',
|
||||
})
|
||||
})
|
||||
|
||||
test('preserves existing login, judging, cooldown, and idle states', () => {
|
||||
assert.deepEqual(getSubmitButtonState({ ...idleInput, isAuthed: false }), {
|
||||
disabled: true,
|
||||
label: '请先登录',
|
||||
icon: 'ph:play-fill',
|
||||
})
|
||||
assert.deepEqual(getSubmitButtonState({ ...idleInput, isJudging: true }), {
|
||||
disabled: true,
|
||||
label: '正在评分',
|
||||
icon: 'eos-icons:loading',
|
||||
})
|
||||
assert.deepEqual(getSubmitButtonState({ ...idleInput, isCooldown: true }), {
|
||||
disabled: true,
|
||||
label: '正在冷却',
|
||||
icon: 'ph:lightbulb-fill',
|
||||
})
|
||||
assert.deepEqual(getSubmitButtonState(idleInput), {
|
||||
disabled: false,
|
||||
label: '提交代码',
|
||||
icon: 'ph:play-fill',
|
||||
})
|
||||
})
|
||||
@@ -99,7 +99,7 @@
|
||||
"name": "@oj2/contract",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3",
|
||||
"zod": "^4.6.6",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -11,8 +11,17 @@
|
||||
# 不设它的话 postgres 会在空目录上初始化一个全新的空库,测试点和题面图片也全都不在,
|
||||
# 而且**不报错**。`deploy.sh` 为此专门设了自检。
|
||||
|
||||
# 每个容器的日志最多 3 个 10MB 的文件。docker 默认的 json-file 不设上限,判题、worker
|
||||
# 天天打日志,2 核小服务器的磁盘迟早被写满 —— 磁盘一满,postgres 写不进、判题跟着失败。
|
||||
x-logging: &logging
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: 10m
|
||||
max-file: '3'
|
||||
|
||||
services:
|
||||
oj-postgres:
|
||||
logging: *logging
|
||||
image: postgres:18-alpine
|
||||
container_name: oj-postgres
|
||||
restart: always
|
||||
@@ -67,6 +76,7 @@ services:
|
||||
retries: 10
|
||||
|
||||
oj-redis:
|
||||
logging: *logging
|
||||
image: redis:8-alpine
|
||||
container_name: oj-redis
|
||||
restart: always
|
||||
@@ -87,6 +97,7 @@ services:
|
||||
retries: 10
|
||||
|
||||
oj-judge:
|
||||
logging: *logging
|
||||
# 自己构建的判题沙箱(go-judge + gcc / python,见 docker/judge/)。
|
||||
# ⚠️ 这个 tag 不在任何 registry 上:本机 docker/judge/build.sh 构建,
|
||||
# 服务器/机房用 docker load 装。镜像不在本地时 compose 会去 pull 然后报找不到。
|
||||
@@ -102,12 +113,13 @@ services:
|
||||
- ${DATA_DIR:-../data}/backend/test_case:/test_case:ro
|
||||
environment:
|
||||
ES_AUTH_TOKEN: ${OJ2_JUDGE_TOKEN:?请在 docker/.env 里设置 OJ2_JUDGE_TOKEN}
|
||||
# 同一时刻最多六份在编译(正式判题 2、试运行 2、提交前编译检查 2),g++ 编一份 iostream
|
||||
# 同一时刻最多四份在编译(judge/slots.ts 两个名额池:正式判题 2、学生页面上的运行和编译检查 2),g++ 编一份 iostream
|
||||
# 峰值约 70MB、bits/stdc++.h 约 195MB,再加学生程序本身。512m 不够撑满,超了是容器里
|
||||
# 随机杀进程、学生看到系统错误
|
||||
mem_limit: 1g
|
||||
|
||||
oj-api:
|
||||
logging: *logging
|
||||
build: &build
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile
|
||||
@@ -149,6 +161,7 @@ services:
|
||||
cpu_shares: 2048
|
||||
|
||||
oj-worker:
|
||||
logging: *logging
|
||||
build: *build
|
||||
image: oj2-api:latest
|
||||
container_name: oj-worker
|
||||
@@ -160,10 +173,19 @@ services:
|
||||
environment: *api-env
|
||||
# 同一个镜像,换个子命令就是判题消费者
|
||||
command: ["oj2-api", "worker"]
|
||||
# worker 每 10 秒刷一次「还活着」,60 秒没刷就是挂了(oj2-api healthcheck --worker)。
|
||||
# unhealthy 只是标出来、deploy.sh 起栈时会等它,docker 不会因此自动重启容器
|
||||
healthcheck:
|
||||
test: ["CMD", "oj2-api", "healthcheck", "--worker"]
|
||||
interval: 30s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
# SQL 判题的子进程各允许 512MB 数据段(judge/sql 的 ulimit -d),两个并发就能顶满 512m
|
||||
mem_limit: 1g
|
||||
|
||||
oj-web:
|
||||
logging: *logging
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile
|
||||
|
||||
@@ -25,8 +25,17 @@
|
||||
# 是后端认的名字。忘了设 OJ2_JUDGE_TOKEN 的话 docker compose 会直接报错退出;忘了设
|
||||
# JUDGE_SERVER_TOKEN 的话后端会随机生成一个并在启动日志里告警,判题请求会被 401 挡掉。
|
||||
|
||||
# 每个容器的日志最多 3 个 10MB 的文件。docker 默认的 json-file 不设上限,判题、worker
|
||||
# 天天打日志,2 核小服务器的磁盘迟早被写满 —— 磁盘一满,postgres 写不进、判题跟着失败。
|
||||
x-logging: &logging
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: 10m
|
||||
max-file: '3'
|
||||
|
||||
services:
|
||||
postgres:
|
||||
logging: *logging
|
||||
image: postgres:18-alpine
|
||||
container_name: oj2-postgres
|
||||
restart: unless-stopped
|
||||
@@ -48,6 +57,7 @@ services:
|
||||
retries: 10
|
||||
|
||||
redis:
|
||||
logging: *logging
|
||||
image: redis:8-alpine
|
||||
container_name: oj2-redis
|
||||
restart: unless-stopped
|
||||
@@ -60,6 +70,7 @@ services:
|
||||
retries: 10
|
||||
|
||||
judge:
|
||||
logging: *logging
|
||||
# 自己构建的判题沙箱(go-judge + gcc / python,见 docker/judge/)。
|
||||
# ⚠️ 这个 tag 不在任何 registry 上:本机 docker/judge/build.sh 构建,
|
||||
# 服务器/机房用 docker load 装。镜像不在本地时 compose 会去 pull 然后报找不到。
|
||||
|
||||
@@ -15,8 +15,17 @@
|
||||
# 测试点(`data/backend/test_case`)和题面图片(`data/backend/public/upload`)都在旧目录里,
|
||||
# 不设 `DATA_DIR` 就会挂一堆空目录上去:判题全错、图片 404。**沿用旧数据必须设它。**
|
||||
|
||||
# 每个容器的日志最多 3 个 10MB 的文件。docker 默认的 json-file 不设上限,判题、worker
|
||||
# 天天打日志,2 核小服务器的磁盘迟早被写满 —— 磁盘一满,postgres 写不进、判题跟着失败。
|
||||
x-logging: &logging
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: 10m
|
||||
max-file: '3'
|
||||
|
||||
services:
|
||||
oj-redis:
|
||||
logging: *logging
|
||||
image: redis:8-alpine
|
||||
container_name: oj-redis
|
||||
restart: always
|
||||
@@ -29,6 +38,7 @@ services:
|
||||
retries: 10
|
||||
|
||||
oj-judge:
|
||||
logging: *logging
|
||||
# 自己构建的判题沙箱(go-judge + gcc / python,见 docker/judge/)。
|
||||
# ⚠️ 这个 tag 不在任何 registry 上:本机 docker/judge/build.sh 构建,
|
||||
# 服务器/机房用 docker load 装。镜像不在本地时 compose 会去 pull 然后报找不到。
|
||||
@@ -48,6 +58,7 @@ services:
|
||||
mem_limit: 2g
|
||||
|
||||
oj-api:
|
||||
logging: *logging
|
||||
build: &build
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile
|
||||
@@ -85,6 +96,7 @@ services:
|
||||
mem_limit: 4g
|
||||
|
||||
oj-worker:
|
||||
logging: *logging
|
||||
build: *build
|
||||
image: oj2-api:latest
|
||||
container_name: oj-worker
|
||||
@@ -95,9 +107,18 @@ services:
|
||||
- ${DATA_DIR:-../data}/backend:/data
|
||||
environment: *api-env
|
||||
command: ["oj2-api", "worker"]
|
||||
# worker 每 10 秒刷一次「还活着」,60 秒没刷就是挂了(oj2-api healthcheck --worker)。
|
||||
# unhealthy 只是标出来、deploy.sh 起栈时会等它,docker 不会因此自动重启容器
|
||||
healthcheck:
|
||||
test: ["CMD", "oj2-api", "healthcheck", "--worker"]
|
||||
interval: 30s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
mem_limit: 2g
|
||||
|
||||
oj-web:
|
||||
logging: *logging
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile
|
||||
|
||||
+12
-1
@@ -4,7 +4,7 @@
|
||||
# 全部由 docker/compose.debian.yml 管。
|
||||
#
|
||||
# cd /root/OJDeploy/OJ2
|
||||
# docker/deploy.sh # 自检 → 构建 → 起栈 → 冒烟
|
||||
# docker/deploy.sh # 自检 → 构建 → 迁移 → 起栈 → 冒烟
|
||||
# docker/deploy.sh --check # 只自检,不动任何容器
|
||||
# docker/deploy.sh --no-build # 跳过构建(只改了 env / compose 时用)
|
||||
# docker/deploy.sh --prebuilt # 用现成产物,不在这台机器上编(CI 走这条)
|
||||
@@ -171,6 +171,17 @@ done
|
||||
[ "$status" = healthy ] || die "oj-api 没起来(状态 $status)
|
||||
看日志:docker logs oj-api --tail 50"
|
||||
|
||||
# worker 的健康检查每 30 秒一次,第一次也要等满 30 秒,所以这里多给一些时间。
|
||||
# 它不起来的话站点照样能开,但交上去的题没人判 —— 学生那边一直「等待评分」
|
||||
wstatus=starting
|
||||
for _ in $(seq 45); do
|
||||
wstatus=$(docker inspect -f '{{.State.Health.Status}}' oj-worker 2>/dev/null || echo starting)
|
||||
[ "$wstatus" = healthy ] && break
|
||||
sleep 2
|
||||
done
|
||||
[ "$wstatus" = healthy ] || die "oj-worker 没起来(状态 $wstatus)—— 站点能开,但提交没人判
|
||||
看日志:docker logs oj-worker --tail 50"
|
||||
|
||||
# ---------------------------------------------------------------- 冒烟
|
||||
say "冒烟(端口 $port)"
|
||||
code() { curl -s -o /dev/null -w '%{http_code}' "http://localhost:$port$1"; }
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@
|
||||
|
||||
## 闸门的三处形态
|
||||
|
||||
1. **入参 `safeParse`**(58 处,全部保留)—— 请求体进来的那一刻校验,对不上回 400。
|
||||
1. **入参 `safeParse`**(请求体一律走 `http.ts` 的 `parseBody`,全部保留)—— 请求体进来的那一刻校验,对不上回 400。
|
||||
2. **`db/schema.ts` 的 `.$type<>()`** —— 枚举型的列(`submission.result` / `.language`、
|
||||
`problem.difficulty` / `.languages`、`achievement.rarity`、`exercise.type`…)和几个形状
|
||||
确定的 JSONB(`problem.template` / `.astRules` / `.sqlConfig` / `.sqlDisplay`、
|
||||
|
||||
+3
-2
@@ -57,8 +57,9 @@ docker exec -it oj-postgres psql -U onlinejudge -d postgres -c '\password online
|
||||
|
||||
### 服务器:push 就部署
|
||||
|
||||
`.github/workflows/deploy.yml` —— 在 runner 上编好产物、rsync 到服务器、在服务器上
|
||||
跑 `docker/deploy.sh --prebuilt`。触发的是 push 到 **github** 这个 remote
|
||||
`.github/workflows/deploy.yml` —— 在 runner 上编好产物、跑一遍 `bunx vp run verify`
|
||||
(格式、lint、前后端类型检查、路由遮蔽、AST 节点,**不过就不部署**)、rsync 到服务器、
|
||||
在服务器上跑 `docker/deploy.sh --prebuilt`。触发的是 push 到 **github** 这个 remote
|
||||
(`origin` 是 `git.xuyue.cc`,它会镜像到 github,所以**平时 push `origin` 通常几秒后
|
||||
也就部署了**,偶尔不跟、要 `git ls-remote github` 核实。Gitea 自己不跑这份工作流 —— 没有
|
||||
`.gitea/workflows` 时它的 Actions 会退回来读这份工作流,所以 job 上卡了
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
"zod": "^4.6.6"
|
||||
},
|
||||
"sideEffects": false
|
||||
}
|
||||
@@ -334,9 +334,7 @@ export const judgeServerSchema = z.object({
|
||||
cpuUsage: z.number(),
|
||||
lastHeartbeat: z.string(),
|
||||
createTime: z.string(),
|
||||
taskNumber: z.number().int(),
|
||||
serviceUrl: z.string().nullable(),
|
||||
isDisabled: z.boolean(),
|
||||
/** 心跳在 6 秒内才算在线,与 dashboard 的判活口径一致 */
|
||||
status: z.enum(['normal', 'abnormal']),
|
||||
})
|
||||
@@ -346,10 +344,6 @@ export const judgeServerListSchema = z.object({
|
||||
servers: z.array(judgeServerSchema),
|
||||
})
|
||||
|
||||
export const updateJudgeServerRequestSchema = z.object({
|
||||
isDisabled: z.boolean(),
|
||||
})
|
||||
|
||||
export const orphanTestCaseSchema = z.object({
|
||||
id: z.string(),
|
||||
createTime: z.number(),
|
||||
@@ -1247,7 +1241,6 @@ export type ImportUsersRequest = z.input<typeof importUsersRequestSchema>
|
||||
export type DeleteUsersRequest = z.input<typeof deleteUsersRequestSchema>
|
||||
export type ResetPasswordResponse = z.infer<typeof resetPasswordResponseSchema>
|
||||
export type UpdateWebsiteConfigRequest = z.input<typeof updateWebsiteConfigRequestSchema>
|
||||
export type UpdateJudgeServerRequest = z.input<typeof updateJudgeServerRequestSchema>
|
||||
export type UploadImageResponse = z.infer<typeof uploadImageResponseSchema>
|
||||
export type CreateContestRequest = z.input<typeof createContestRequestSchema>
|
||||
export type UpdateContestRequest = z.input<typeof updateContestRequestSchema>
|
||||
|
||||
@@ -285,8 +285,8 @@ export const AST_OPERATOR_TARGETS_BY_LANGUAGE: Record<string, Record<string, str
|
||||
}
|
||||
|
||||
/**
|
||||
* 判题机真正能做 AST 检查的语言。设计文档写的是"支持全部 6 种语言",落地的只有
|
||||
* 这两种 —— 其余语言 judge/ast.ts 的 loadLanguage 返回 null,规则一条都不会跑。
|
||||
* 判题机真正能做 AST 检查的语言(C / C++ / Python 三种,就是上面这张表的键)。设计文档写的是
|
||||
* "支持全部 6 种语言",其余语言 judge/ast.ts 的 loadLanguage 返回 null,规则一条都不会跑。
|
||||
* 所以后台不给别的语言开 tab,题目页也不把它们的规则展示成「要求」。
|
||||
*/
|
||||
export const AST_SUPPORTED_LANGUAGES = Object.keys(AST_NODE_TARGETS_BY_LANGUAGE)
|
||||
|
||||
Reference in new issue
Block a user