- 正式判题、样例重跑、运行时错误诊断都加了超时(默认 120 秒,时限放宽的题按比例放宽), 超时记系统错误、提示是中文;回包和试运行一样边读边数,超过 16MB 就掐断, 免得一个疯狂打印的错误程序把 worker 撑爆 - 做对题数、中等、困难、一次就对改成在锁里按定义现数:原来逐次 +1,两条挨着判完或重判 一条做对的提交都会多加,库里 372 人多了 1–4 道。0035 把存量一次订正 - 重判不再给提交次数、凌晨/早起计数 +1,编译错误按前后差值改;「错了几次才对」只数这条之前的 - 已拿到的成就数只 jsonb_set 那一个键,不再整块写回冲掉并发判题刚写的指标 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5aafcf172b
commit
55038cc78b
6 files changed
+4059
-70
No files matched your search
@@ -0,0 +1,46 @@
|
||||
-- 做对题数、中等、困难、一次就对四项按定义重数(判题结算从这一版起也是现数,见
|
||||
-- services/achievements.ts 的 solvedCounts)。原来逐次 +1,并发判完和重判会多加:
|
||||
-- 2026-10 的库里 372 人的做对题数比实际多 1–4 道。只改这四个键、只写值变了的行;
|
||||
-- 已经拿到的成就一个不收回。不含比赛,做对 = ACCEPTED(0) / AST_CHECK_FAILED(10)。
|
||||
UPDATE "user_stat" AS target
|
||||
SET "metrics" = target."metrics" || jsonb_build_object(
|
||||
'accepted_count', fresh.accepted,
|
||||
'mid_ac_count', fresh.mid,
|
||||
'hard_ac_count', fresh.hard,
|
||||
'first_try_ac_count', fresh.first_try
|
||||
)
|
||||
FROM (
|
||||
SELECT st."id",
|
||||
coalesce(c.accepted, 0) AS accepted,
|
||||
coalesce(c.mid, 0) AS mid,
|
||||
coalesce(c.hard, 0) AS hard,
|
||||
coalesce(f.first_try, 0) AS first_try
|
||||
FROM "user_stat" st
|
||||
LEFT JOIN (
|
||||
SELECT s."user_id",
|
||||
count(DISTINCT s."problem_id") FILTER (WHERE s."result" IN (0, 10))::int AS accepted,
|
||||
count(DISTINCT s."problem_id") FILTER (WHERE s."result" IN (0, 10) AND p."difficulty" = 'Mid')::int AS mid,
|
||||
count(DISTINCT s."problem_id") FILTER (WHERE s."result" IN (0, 10) AND p."difficulty" = 'High')::int AS hard
|
||||
FROM "submission" s
|
||||
JOIN "problem" p ON p."id" = s."problem_id"
|
||||
WHERE s."contest_id" IS NULL
|
||||
GROUP BY s."user_id"
|
||||
) c ON c."user_id" = st."user_id"
|
||||
LEFT JOIN (
|
||||
SELECT first."user_id", count(*) FILTER (WHERE first."result" IN (0, 10))::int AS first_try
|
||||
FROM (
|
||||
SELECT DISTINCT ON (s."user_id", s."problem_id") s."user_id", s."result"
|
||||
FROM "submission" s
|
||||
WHERE s."contest_id" IS NULL
|
||||
ORDER BY s."user_id", s."problem_id", s."create_time", s."id"
|
||||
) first
|
||||
GROUP BY first."user_id"
|
||||
) f ON f."user_id" = st."user_id"
|
||||
) fresh
|
||||
WHERE target."id" = fresh."id"
|
||||
AND (
|
||||
(target."metrics" -> 'accepted_count') IS DISTINCT FROM to_jsonb(fresh.accepted)
|
||||
OR (target."metrics" -> 'mid_ac_count') IS DISTINCT FROM to_jsonb(fresh.mid)
|
||||
OR (target."metrics" -> 'hard_ac_count') IS DISTINCT FROM to_jsonb(fresh.hard)
|
||||
OR (target."metrics" -> 'first_try_ac_count') IS DISTINCT FROM to_jsonb(fresh.first_try)
|
||||
);
|
||||
File diff suppressed because it is too large.
Load diff
@@ -246,6 +246,13 @@
|
||||
"when": 1791858070886,
|
||||
"tag": "0034_add_class_lesson_created_at",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 35,
|
||||
"version": "7",
|
||||
"when": 1792130722120,
|
||||
"tag": "0035_recount_solved_metrics",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -164,6 +164,42 @@ export async function compileFails(language: string, source: string) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判题机回包的总上限。判题机给每组输出留 16MB(见镜像里 judge_client.py 的
|
||||
* max_output_size),死循环打印能把每组都写满;api、worker 容器都只有 512MB,
|
||||
* 几组全满读进来再 JSON.parse 一遍就是好几百 MB。超了就掐断。
|
||||
*/
|
||||
export const MAX_RESPONSE_BYTES = 16 * 1024 * 1024
|
||||
|
||||
/** 边读边数,超过上限就掐断连接,返回 null */
|
||||
export async function readCapped(response: Response, limit: number) {
|
||||
if (!response.body) return ""
|
||||
const reader = response.body.getReader()
|
||||
const chunks: Uint8Array[] = []
|
||||
let size = 0
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
size += value.byteLength
|
||||
if (size > limit) {
|
||||
await reader.cancel()
|
||||
return null
|
||||
}
|
||||
chunks.push(value)
|
||||
}
|
||||
return Buffer.concat(chunks).toString("utf8")
|
||||
}
|
||||
|
||||
/**
|
||||
* 判题机自己有编译、运行的限时,这里只防它卡死不回。不设的话一次卡死就永久占住一个
|
||||
* worker(服务器上一共两个),而 BullMQ 会一直给它续锁、不当它卡住,全班停在「等待评分」。
|
||||
* 判题机每组的墙钟上限是 CPU 时限的 3 倍、两组并行,现有题目全是 1 秒,二十组全超时
|
||||
* 也就半分钟出头,两分钟足够;时限放宽的题按比例放宽。
|
||||
*/
|
||||
function judgeTimeoutMs(timeLimit: number) {
|
||||
return Math.max(120_000, timeLimit * 60)
|
||||
}
|
||||
|
||||
async function requestJudge(
|
||||
language: string,
|
||||
code: string,
|
||||
@@ -172,8 +208,22 @@ async function requestJudge(
|
||||
testCase: string | { input: string; output: string }[],
|
||||
output = false,
|
||||
) {
|
||||
const response = await postJudge(language, code, timeLimit, memoryLimit, testCase, { output })
|
||||
return (await response.json()) as JudgeResponse
|
||||
const timeoutMs = judgeTimeoutMs(timeLimit)
|
||||
try {
|
||||
const response = await postJudge(language, code, timeLimit, memoryLimit, testCase, {
|
||||
output,
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
})
|
||||
const body = await readCapped(response, MAX_RESPONSE_BYTES)
|
||||
if (body === null) throw new Error("判题机返回的内容太大")
|
||||
return JSON.parse(body) as JudgeResponse
|
||||
} catch (error) {
|
||||
// 这句会写进 err_info,学生能看到,别把 "The operation timed out." 原样给他
|
||||
if (error instanceof DOMException && error.name === "TimeoutError") {
|
||||
throw new Error(`判题机 ${timeoutMs / 1000} 秒没有回应`)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function persistResult(
|
||||
@@ -668,6 +718,7 @@ export async function judgeSubmission(job: JudgeJobData) {
|
||||
}
|
||||
}
|
||||
|
||||
const counted = countedBefore(job.rejudgedFrom)
|
||||
const saved = await persistResult(
|
||||
row.submission.id,
|
||||
row.problem.id,
|
||||
@@ -678,7 +729,7 @@ export async function judgeSubmission(job: JudgeJobData) {
|
||||
statisticInfo,
|
||||
row.submission.contestId,
|
||||
row.submission.createTime,
|
||||
countedBefore(job.rejudgedFrom),
|
||||
counted,
|
||||
)
|
||||
if (!saved) return
|
||||
|
||||
@@ -692,7 +743,7 @@ export async function judgeSubmission(job: JudgeJobData) {
|
||||
}
|
||||
|
||||
try {
|
||||
const unlocked = await updateAchievementsForSubmission(row.submission.id)
|
||||
const unlocked = await updateAchievementsForSubmission(row.submission.id, counted)
|
||||
await publishAchievementNotification(
|
||||
row.submission.userId,
|
||||
unlocked.map((achievement) => ({
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import type { TrialRunResponse } from "@oj2/contract"
|
||||
|
||||
import { config } from "../config"
|
||||
import { postJudge, statusValue, type JudgeCase, type JudgeResponse } from "./run"
|
||||
import {
|
||||
MAX_RESPONSE_BYTES,
|
||||
postJudge,
|
||||
readCapped,
|
||||
statusValue,
|
||||
type JudgeCase,
|
||||
type JudgeResponse,
|
||||
} from "./run"
|
||||
import { nativeRuntimeError, parsePythonTraceback } from "./runtime-diagnosis"
|
||||
import { JudgeStatus, type JudgeStatusValue } from "./status"
|
||||
import { shiftCompileLines } from "./template"
|
||||
@@ -28,13 +35,6 @@ const PER_USER_LIMIT = 2
|
||||
/** 判题机本身有编译、运行的限时,这里只防它卡死不回 */
|
||||
const JUDGE_TIMEOUT_MS = 60_000
|
||||
|
||||
/**
|
||||
* 判题机回包的总上限。判题机给每组输出留 16MB(见镜像里 judge_client.py 的
|
||||
* max_output_size),死循环打印能把每组都写满;api 容器只有 512MB,十组全满
|
||||
* 读进来再 JSON.parse 一遍就是好几百 MB。超了就掐断,告诉学生输出太多了。
|
||||
*/
|
||||
const MAX_RESPONSE_BYTES = 16 * 1024 * 1024
|
||||
|
||||
/** 回给学生的每组输出最多这么长。管理员生成测试点要原样的输出,不截 */
|
||||
const STUDENT_OUTPUT_LIMIT = 64 * 1024
|
||||
|
||||
@@ -72,25 +72,6 @@ async function acquire() {
|
||||
// 被 release() 叫醒时名额是直接转交过来的,active 不用再加
|
||||
}
|
||||
|
||||
/** 边读边数,超过上限就掐断连接,返回 null */
|
||||
async function readCapped(response: Response, limit: number) {
|
||||
if (!response.body) return ""
|
||||
const reader = response.body.getReader()
|
||||
const chunks: Uint8Array[] = []
|
||||
let size = 0
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
size += value.byteLength
|
||||
if (size > limit) {
|
||||
await reader.cancel()
|
||||
return null
|
||||
}
|
||||
chunks.push(value)
|
||||
}
|
||||
return Buffer.concat(chunks).toString("utf8")
|
||||
}
|
||||
|
||||
function truncate(text: string) {
|
||||
return text.length > STUDENT_OUTPUT_LIMIT
|
||||
? `${text.slice(0, STUDENT_OUTPUT_LIMIT)}\n……(输出太长,后面的没有显示)`
|
||||
|
||||
@@ -6,12 +6,13 @@ import {
|
||||
inArray,
|
||||
isNotNull,
|
||||
isNull,
|
||||
lt,
|
||||
ne,
|
||||
notInArray,
|
||||
sql,
|
||||
} from "drizzle-orm"
|
||||
|
||||
import { db, schema } from "../db"
|
||||
import { db, schema, type DbOrTx } from "../db"
|
||||
import { publishAchievementNotification } from "../events"
|
||||
import { calendarDay, dayNumber, localHour, localTime, weekStart } from "../time"
|
||||
import { findMetric } from "./achievement-metrics"
|
||||
@@ -101,7 +102,58 @@ async function unlockAchievements(
|
||||
return hits.filter((achievement) => insertedIds.has(achievement.id))
|
||||
}
|
||||
|
||||
export async function updateAchievementsForSubmission(submissionId: string) {
|
||||
const ACCEPTED_RESULTS = sql.join(
|
||||
[sql`${JudgeStatus.ACCEPTED}`, sql`${JudgeStatus.AST_CHECK_FAILED}`],
|
||||
sql`, `,
|
||||
)
|
||||
|
||||
/**
|
||||
* 「做对几道 / 中等几道 / 困难几道 / 一次就对几道」按定义从提交表现数,不再逐次 +1。
|
||||
*
|
||||
* 原来是读一遍这道题之前的提交、判断「是不是第一次做对」再 +1,两处漏:同一个人同一道题
|
||||
* 两条提交挨着判完,两边都看见对方已经做对(都不加)或都还没做对(加两次);重判一条
|
||||
* 做对的提交又会再加一次。2026-10 的库里 372 人的做对题数比实际多 1–4 道。
|
||||
* 现数是幂等的,怎么并发、怎么重判都是同一个答案。走 submission_public_metrics_idx,
|
||||
* 一个人的提交也就几百条。
|
||||
*/
|
||||
async function solvedCounts(tx: DbOrTx, userId: number) {
|
||||
const [row] = await tx.execute<{
|
||||
accepted: number
|
||||
mid: number
|
||||
hard: number
|
||||
first_try: number
|
||||
}>(
|
||||
sql`
|
||||
with mine as (
|
||||
select s.problem_id, s.result, s.create_time, s.id, p.difficulty
|
||||
from ${schema.submission} s
|
||||
join ${schema.problem} p on p.id = s.problem_id
|
||||
where s.user_id = ${userId} and s.contest_id is null
|
||||
)
|
||||
select
|
||||
count(distinct problem_id) filter (where result in (${ACCEPTED_RESULTS}))::int as accepted,
|
||||
count(distinct problem_id) filter (where result in (${ACCEPTED_RESULTS}) and difficulty = 'Mid')::int as mid,
|
||||
count(distinct problem_id) filter (where result in (${ACCEPTED_RESULTS}) and difficulty = 'High')::int as hard,
|
||||
(
|
||||
select count(*)::int from (
|
||||
select distinct on (problem_id) result from mine order by problem_id, create_time, id
|
||||
) first where result in (${ACCEPTED_RESULTS})
|
||||
) as first_try
|
||||
from mine
|
||||
`,
|
||||
)
|
||||
return row ?? { accepted: 0, mid: 0, hard: 0, first_try: 0 }
|
||||
}
|
||||
|
||||
/**
|
||||
* `countedAs`:这条提交上次结算时的结果,重判时由判题传进来;新提交是 null。
|
||||
* 重判不是又交了一次 —— 提交次数、凌晨/早起这类「每交一次 +1」的不能再加,
|
||||
* 编译错误按前后差值改。
|
||||
*/
|
||||
export async function updateAchievementsForSubmission(
|
||||
submissionId: string,
|
||||
countedAs: number | null = null,
|
||||
) {
|
||||
const [row] = await db
|
||||
.select({ submission: schema.submission, problem: schema.problem })
|
||||
.from(schema.submission)
|
||||
@@ -110,21 +162,8 @@ export async function updateAchievementsForSubmission(submissionId: string) {
|
||||
.limit(1)
|
||||
if (!row || row.submission.contestId !== null) return []
|
||||
|
||||
const priorRows = await db
|
||||
.select({ result: schema.submission.result })
|
||||
.from(schema.submission)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.submission.userId, row.submission.userId),
|
||||
eq(schema.submission.problemId, row.submission.problemId),
|
||||
isNull(schema.submission.contestId),
|
||||
ne(schema.submission.id, row.submission.id),
|
||||
),
|
||||
)
|
||||
const priorAccepted = priorRows.some((item) => isAccepted(item.result))
|
||||
const rejudged = countedAs !== null
|
||||
const accepted = isAccepted(row.submission.result)
|
||||
const firstAc = accepted && !priorAccepted
|
||||
const firstTry = accepted && priorRows.length === 0
|
||||
const date = calendarDay(row.submission.createTime)
|
||||
const hour = localHour(row.submission.createTime)
|
||||
|
||||
@@ -144,15 +183,29 @@ export async function updateAchievementsForSubmission(submissionId: string) {
|
||||
.for("update")
|
||||
if (!stat) throw new Error("User achievement stat could not be created")
|
||||
const value = asRecord(stat.metrics)
|
||||
value.submission_count = numberMetric(value, "submission_count") + 1
|
||||
// 在锁里数:同一个人的结算排成一队,后来的一定看得见先来的写进去的结果
|
||||
const solved = await solvedCounts(tx, row.submission.userId)
|
||||
// 这次让做对的题多了一道,才算「新做对」:每天做对几道、连续几周这些跟着它走
|
||||
const firstAc = accepted && solved.accepted > numberMetric(value, "accepted_count")
|
||||
value.accepted_count = solved.accepted
|
||||
value.mid_ac_count = solved.mid
|
||||
value.hard_ac_count = solved.hard
|
||||
value.first_try_ac_count = solved.first_try
|
||||
if (!rejudged) value.submission_count = numberMetric(value, "submission_count") + 1
|
||||
if (firstAc) {
|
||||
value.accepted_count = numberMetric(value, "accepted_count") + 1
|
||||
if (row.problem.difficulty === "Mid")
|
||||
value.mid_ac_count = numberMetric(value, "mid_ac_count") + 1
|
||||
if (row.problem.difficulty === "High")
|
||||
value.hard_ac_count = numberMetric(value, "hard_ac_count") + 1
|
||||
if (firstTry) value.first_try_ac_count = numberMetric(value, "first_try_ac_count") + 1
|
||||
value.max_wa_before_ac = Math.max(numberMetric(value, "max_wa_before_ac"), priorRows.length)
|
||||
// 只数这条之前的:重判一条老提交时,它后面的提交不算「做对之前错的」
|
||||
const [prior] = await tx
|
||||
.select({ value: count() })
|
||||
.from(schema.submission)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.submission.userId, row.submission.userId),
|
||||
eq(schema.submission.problemId, row.submission.problemId),
|
||||
isNull(schema.submission.contestId),
|
||||
lt(schema.submission.createTime, row.submission.createTime),
|
||||
),
|
||||
)
|
||||
value.max_wa_before_ac = Math.max(numberMetric(value, "max_wa_before_ac"), prior?.value ?? 0)
|
||||
const perDay = asRecord(value._ac_per_day)
|
||||
perDay[date] = (typeof perDay[date] === "number" ? perDay[date] : 0) + 1
|
||||
value._ac_per_day = perDay
|
||||
@@ -169,7 +222,8 @@ export async function updateAchievementsForSubmission(submissionId: string) {
|
||||
value.active_days = activeDates.length
|
||||
if (accepted) {
|
||||
const last = typeof value._last_ac_date === "string" ? value._last_ac_date : null
|
||||
if (last !== date) {
|
||||
// 比记下的还早(重判了一条以前的提交)就不动,和 nextWeekStreak 一样
|
||||
if (!last || date > last) {
|
||||
// 差一天要按日历日算,不能用 Date 相减:夏令时地区相邻两天差 23/25 小时,
|
||||
// 除 86400000 得到的不是 1,`=== 1` 会静默把连续打卡判成断掉。
|
||||
const current =
|
||||
@@ -187,11 +241,19 @@ export async function updateAchievementsForSubmission(submissionId: string) {
|
||||
if (!languages.includes(row.submission.language)) languages.push(row.submission.language)
|
||||
value._languages = languages
|
||||
value.languages_used = languages.length
|
||||
if (hour < 5) value.midnight_submissions = numberMetric(value, "midnight_submissions") + 1
|
||||
else if (hour < 7)
|
||||
value.early_bird_submissions = numberMetric(value, "early_bird_submissions") + 1
|
||||
if (row.submission.result === JudgeStatus.COMPILE_ERROR)
|
||||
value.compile_error_count = numberMetric(value, "compile_error_count") + 1
|
||||
if (!rejudged) {
|
||||
if (hour < 5) value.midnight_submissions = numberMetric(value, "midnight_submissions") + 1
|
||||
else if (hour < 7)
|
||||
value.early_bird_submissions = numberMetric(value, "early_bird_submissions") + 1
|
||||
}
|
||||
// 编译错误是从上线那天起逐次累计的(之前的历史没补),没法按定义重数,只能按差值改
|
||||
const compileError = Number(row.submission.result === JudgeStatus.COMPILE_ERROR)
|
||||
const wasCompileError = Number(countedAs === JudgeStatus.COMPILE_ERROR)
|
||||
if (compileError !== wasCompileError)
|
||||
value.compile_error_count = Math.max(
|
||||
0,
|
||||
numberMetric(value, "compile_error_count") + compileError - wasCompileError,
|
||||
)
|
||||
// 只算做对的、空行不算:原来交什么都算,贴一大段就拿到「长篇巨制」(0032 按这个口径重算过存量)
|
||||
if (accepted)
|
||||
value.max_code_lines = Math.max(
|
||||
@@ -218,12 +280,24 @@ export async function updateAchievementsForSubmission(submissionId: string) {
|
||||
eq(schema.achievement.visible, true),
|
||||
),
|
||||
)
|
||||
metrics.achievement_unlocked_count = meta?.value ?? 0
|
||||
const unlockedCount = meta?.value ?? 0
|
||||
metrics.achievement_unlocked_count = unlockedCount
|
||||
await setUnlockedCount(row.submission.userId, unlockedCount)
|
||||
return [...first, ...(await unlockAchievements(row.submission.userId, metrics, true))]
|
||||
}
|
||||
|
||||
/**
|
||||
* 只 `jsonb_set` 这一个键:这一步在锁外,整体写回 metrics 会把并发判题刚写的其它指标
|
||||
* 冲掉(和 refreshUnlockedCount 同一个理由)。
|
||||
*/
|
||||
async function setUnlockedCount(userId: number, value: number) {
|
||||
await db
|
||||
.update(schema.userStat)
|
||||
.set({ metrics, updateTime: new Date().toISOString() })
|
||||
.where(eq(schema.userStat.userId, row.submission.userId))
|
||||
return [...first, ...(await unlockAchievements(row.submission.userId, metrics, true))]
|
||||
.set({
|
||||
metrics: sql`jsonb_set(${schema.userStat.metrics}, '{achievement_unlocked_count}', to_jsonb(${value}::int))`,
|
||||
updateTime: new Date().toISOString(),
|
||||
})
|
||||
.where(eq(schema.userStat.userId, userId))
|
||||
}
|
||||
|
||||
export async function updateAchievementsForProblemSet(userId: number) {
|
||||
@@ -278,11 +352,9 @@ export async function updateAchievementsForProblemSet(userId: number) {
|
||||
eq(schema.achievement.visible, true),
|
||||
),
|
||||
)
|
||||
metrics.achievement_unlocked_count = meta?.value ?? 0
|
||||
await db
|
||||
.update(schema.userStat)
|
||||
.set({ metrics, updateTime: new Date().toISOString() })
|
||||
.where(eq(schema.userStat.userId, userId))
|
||||
const unlockedCount = meta?.value ?? 0
|
||||
metrics.achievement_unlocked_count = unlockedCount
|
||||
await setUnlockedCount(userId, unlockedCount)
|
||||
return [...first, ...(await unlockAchievements(userId, metrics, true))]
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user