Skip to content
Open
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
0a23cba
feat(be): separate response into package
lukekeum Jul 31, 2026
dbfd6c9
feat(be): add Finished property into JudgeResponse
lukekeum Aug 2, 2026
66cb1ce
feat(be): add finished trigger for judge result completion check
qkrrudals886-boop Aug 4, 2026
e7bdc69
Merge branch 't2813-batch-submission-result' of https://github.com/sk…
lukekeum Aug 4, 2026
87b6878
feat(be): add submission response dto
lukekeum Aug 4, 2026
5c53f64
fix(be): change judger handler type to submission response
lukekeum Aug 4, 2026
a31594d
chore(be): add TODO comment
lukekeum Aug 4, 2026
d82265b
feat(be): add submission response struct
lukekeum Aug 5, 2026
fdc6a23
feat(be): add submission response send logic
lukekeum Aug 5, 2026
303ef6e
chore(be): remove dead code from batch update refactor
qkrrudals886-boop Aug 5, 2026
6a59540
fix(be): replace outdated check with testcase filtering
lukekeum Aug 9, 2026
4f3c884
test(be): add tests for batch update handleJudgerMessage/updateTestca…
qkrrudals886-boop Aug 10, 2026
b1e5742
chore: revert gitignore additions unrelated to batch update scope
qkrrudals886-boop Aug 10, 2026
5da035e
Merge branch 'main' into t2813-batch-submission-result
lshtar13 Aug 14, 2026
9723891
chore(be): remove debugging code
lukekeum Aug 14, 2026
e22e457
chore(be): rename validation function
lukekeum Aug 14, 2026
b964be6
fix(iris): initialize judgeResponses as empty slice
lukekeum Aug 14, 2026
ffe581a
feat(be): add finished flag and validation to submission response
lukekeum Aug 14, 2026
a9578be
Merge branch 'main' into t2813-batch-submission-result
lshtar13 Aug 15, 2026
5d312aa
Merge branch 'main' into t2813-batch-submission-result
lshtar13 Aug 15, 2026
0f7a25f
Merge branch 'main' into t2813-batch-submission-result
lukekeum Aug 20, 2026
725afbf
Revert "Merge branch 'main' into t2813-batch-submission-result"
lukekeum Aug 20, 2026
2338411
Merge branch 'main' into t2813-batch-submission-result
lukekeum Aug 21, 2026
98b1aa0
Merge branch 'main' into t2813-batch-submission-result
lshtar13 Aug 21, 2026
6001749
Merge branch 'main' into t2813-batch-submission-result
lukekeum Aug 22, 2026
54826ac
fix(iris): remove Finished field from responses
lukekeum Aug 22, 2026
ee1fe19
fix(be): remove finished property from responses
lukekeum Aug 22, 2026
cbaf8bb
feat(be): add submission message type handling
lukekeum Aug 22, 2026
0813dfb
chore(be): add return after handling submission
lukekeum Aug 22, 2026
9042a39
chore(be): remove SubmissionResponse checker
lukekeum Aug 22, 2026
414ccef
test(be): change parseResonse to parseJudgerResponse
lukekeum Aug 22, 2026
190a6e2
chore(be): remove finished field from spec file
lukekeum Aug 22, 2026
888e6d5
refactor(be): simplify message handler dispatch
lukekeum Aug 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { Type } from 'class-transformer'
import {
IsBoolean,
IsNotEmpty,
IsNumber,
IsOptional,
IsString,
Max,
Min
Min,
ValidateNested
} from 'class-validator'

class JudgeResult {
Expand Down Expand Up @@ -35,4 +37,18 @@ export class JudgerResponse {
@Type(() => JudgeResult)
@IsOptional()
judgeResult?: JudgeResult

@IsBoolean()
@IsOptional()
finished?: boolean
}

export class SubmissionResponse {
@IsNumber()
@IsNotEmpty()
submissionId: number

@ValidateNested({ each: true })
@Type(() => JudgerResponse)
judgeResults: JudgerResponse[]
}
Comment thread
Copilot marked this conversation as resolved.
249 changes: 151 additions & 98 deletions apps/backend/apps/client/src/submission/submission-sub.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import {
} from '@libs/constants'
import { UnprocessableDataException } from '@libs/exception'
import { PrismaService } from '@libs/prisma'
import { JudgerResponse } from './class/judger-response.dto'
import { JudgerResponse, SubmissionResponse } from './class/judger-response.dto'

@Injectable()
export class SubmissionSubscriptionService implements OnModuleInit {
Expand All @@ -42,7 +42,10 @@ export class SubmissionSubscriptionService implements OnModuleInit {
this.amqpService.setMessageHandlers({
onRunMessage: async (msg: object, isUserTest: boolean) => {
try {
const res = await this.validateJudgerResponse(msg)
const res = await this.parseResponse(msg)
if (res instanceof SubmissionResponse) {
return // Ack
}
await this.handleRunMessage(res, res.submissionId, isUserTest)
} catch (error) {
if (
Expand All @@ -60,10 +63,19 @@ export class SubmissionSubscriptionService implements OnModuleInit {
},
onJudgeMessage: async (msg: object) => {
try {
const res = await this.validateJudgerResponse(msg)
const res = await this.parseResponse(msg)

if (res instanceof JudgerResponse) {
// JudgerResponse 메시지는 처리하지 않습니다.
return // Ack
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

const isOudated = await this.isOutdatedTestcase(res)
if (isOudated) return
const validResponse = await this.filterOutdatedTestcases(
res.submissionId,
res.judgeResults
)

res.judgeResults = validResponse
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

await this.handleJudgerMessage(res)
} catch (error) {
Expand Down Expand Up @@ -216,44 +228,69 @@ export class SubmissionSubscriptionService implements OnModuleInit {
* @throws {ValidationError[]} 유효성 검사 실패 시 발생
*/
@Span()
async validateJudgerResponse(msg: object): Promise<JudgerResponse> {
async parseResponse(
msg: object
): Promise<JudgerResponse | SubmissionResponse> {
const isSubmissionResult = Boolean(msg['finished'])

if (isSubmissionResult) {
const res: SubmissionResponse = plainToInstance(SubmissionResponse, msg)
await validateOrReject(res)

return res
}

const res: JudgerResponse = plainToInstance(JudgerResponse, msg)
await validateOrReject(res)

return res
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

/**
* 채점 결과가 도착한 테스트케이스가 최신 상태인지(유효한지) 확인합니다.
* 도착한 테스트케이스들이 최신 상태인지(유효한지) 확인합니다.
*
* 문제 출제자가 테스트케이스를 수정하거나 새로 업로드하면(`uploadTestcaseZip` 등),
* 기존 테스트케이스들은 모두 `isOutdated: true`로 설정됩니다.
*
* 1. 응답에 포함된 `testcaseId`가 현재 유효한지(`isOutdated: false`) 확인합니다.
* 2. 해당 테스트케이스가 존재하지 않으면(즉, Outdated 되었거나 삭제된 경우), `true`를 반환합니다.
* 2. 해당 테스트케이스가 존재하지 않으면(즉, Outdated 되었거나 삭제된 경우), 반환값에서 제외합니다.
*
* @param {JudgerResponse} res 채점 서버로부터 수신한 응답 메시지 객체
* @returns {Promise<boolean>} 테스트케이스가 만료(Outdated)되었으면 `true`, 유효하면 `false`
* @param {number} submissionId 보내진 응답의 제출 ID
* @param {JudgerResponse[]} res 채점 서버로부터 수신한 채점 결과 배열
* @returns {Promise<JudgerResponse[]>} 유효한 채점 결과만 담은 배열
*/
@Span()
async isOutdatedTestcase(res: JudgerResponse): Promise<boolean> {
const testcase = await this.prisma.problemTestcase.count({
async filterOutdatedTestcases(
submissionId: number,
res: JudgerResponse[]
): Promise<JudgerResponse[]> {
const testCaseIds = res
.map((v) => v.judgeResult?.testcaseId)
.filter((v) => v !== undefined)

const validTestcases = await this.prisma.problemTestcase.findMany({
select: { id: true },
where: {
id: res.judgeResult?.testcaseId,
id: { in: testCaseIds },
isOutdated: false,
problem: {
submission: {
some: { id: res.submissionId }
some: { id: submissionId }
}
}
}
})

return testcase === 0
const validIds = new Set(validTestcases.map((v) => v.id))

return res.filter((v) => {
const id = v.judgeResult?.testcaseId
return id !== undefined && validIds.has(id)
})
}

/**
* 채점 서버로부터 수신한 개별 테스트케이스의 채점 결과 메시지를 처리합니다.
* 채점 서버로부터 수신한 채점 결과 메시지를 처리합니다.
*
* 1. 메시지의 상태 코드(`resultCode`)를 파싱하여 `ResultStatus`를 결정합니다.
* 2. 에러 상태(ServerError, CompileError)인 경우, `handleJudgeError`를 호출하여 예외 처리를 수행하고 종료합니다.
Expand All @@ -265,33 +302,47 @@ export class SubmissionSubscriptionService implements OnModuleInit {
* @throws {UnprocessableDataException} 정상 결과(`judgeResult`)가 누락된 경우 예외 발생
*/
@Span()
async handleJudgerMessage(msg: JudgerResponse): Promise<void> {
const status = Status(msg.resultCode)
async handleJudgerMessage(msg: SubmissionResponse): Promise<void> {
const submissionResults: {
submissionId: number
problemTestcaseId: number
result: ResultStatus
cpuTime: bigint
memoryUsage: number
output: string | undefined
}[] = []

for (const value of msg.judgeResults) {
const status = Status(value.resultCode)

if (
status === ResultStatus.ServerError ||
status === ResultStatus.CompileError
) {
await this.handleJudgeError(status, value)
return
}

if (
status === ResultStatus.ServerError ||
status === ResultStatus.CompileError
) {
await this.handleJudgeError(status, msg)
return
}
if (!value.judgeResult) {
throw new UnprocessableDataException(
`JudgeResult is missing for submission ${msg.submissionId} - cannot process judge response`
)
}

if (!msg.judgeResult) {
throw new UnprocessableDataException(
'JudgeResult is missing for submission ${msg.submissionId} - cannot process judge response'
)
}
const submissionResult = {
submissionId: value.submissionId,
problemTestcaseId: value.judgeResult.testcaseId,
result: status,
cpuTime: BigInt(value.judgeResult.cpuTime),
memoryUsage: value.judgeResult.memory,
output: value.judgeResult.output,
finished: value.finished!
}

const submissionResult = {
submissionId: msg.submissionId,
problemTestcaseId: msg.judgeResult.testcaseId,
result: status,
cpuTime: BigInt(msg.judgeResult.cpuTime),
memoryUsage: msg.judgeResult.memory,
output: msg.judgeResult.output
submissionResults.push(submissionResult)
}

await this.updateTestcaseJudgeResult(submissionResult)
await this.updateTestcaseJudgeResult(submissionResults)
}

/**
Expand Down Expand Up @@ -343,7 +394,7 @@ export class SubmissionSubscriptionService implements OnModuleInit {
* 개별 테스트케이스의 채점 결과를 DB에 반영하고, 후속 처리를 수행합니다.
*
* 1. `SubmissionResult` 테이블에 해당 테스트케이스의 채점 결과(성공 여부, 시간, 메모리, 출력 등)를 업데이트합니다.
* 2. 유효한 채점 결과(Judging, ServerError 등이 아닌 확정된 상태)라면, `updateTestcaseStats`를 호출하여 테스트케이스별 통계를 갱신합니다.
* 2. 유효한 채점 결과(Judging, ServerError 등이 아닌 확정된 상태)라면, 테스트케이스별 통계를 갱신합니다.
* 3. `updateSubmissionResult`를 호출하여, 해당 제출(Submission)의 전체 채점 완료 여부를 확인하고 최종 결과를 갱신합니다.
*
* @param {Partial<SubmissionResult> & Pick<SubmissionResult, 'result' | 'submissionId' | 'problemTestcaseId'>} submissionResult
Expand All @@ -352,75 +403,77 @@ export class SubmissionSubscriptionService implements OnModuleInit {
* */
@Span()
async updateTestcaseJudgeResult(
submissionResult: Partial<SubmissionResult> &
Pick<SubmissionResult, 'result' | 'submissionId' | 'problemTestcaseId'>
submissionResults: (Partial<SubmissionResult> &
Pick<
SubmissionResult,
'result' | 'submissionId' | 'problemTestcaseId'
> & {
finished?: boolean
})[]
): Promise<void> {
await this.prisma.submissionResult.update({
where: {
// eslint-disable-next-line @typescript-eslint/naming-convention
submissionId_problemTestcaseId: {
submissionId: submissionResult.submissionId,
problemTestcaseId: submissionResult.problemTestcaseId
}
},
data: {
result: submissionResult.result,
cpuTime: submissionResult.cpuTime,
memoryUsage: submissionResult.memoryUsage,
output: submissionResult.output
}
})
if (submissionResults.length === 0) return

const submissionId = submissionResults[0].submissionId

const invalidSubmissionStatuses: Array<ResultStatus> = [
ResultStatus.Judging,
ResultStatus.ServerError,
ResultStatus.Blind,
ResultStatus.Canceled
]
if (
invalidSubmissionStatuses.every(
(result) => result !== submissionResult.result
)
) {
this.updateTestcaseStats(
submissionResult.problemTestcaseId,
submissionResult.result === ResultStatus.Accepted
)
}

await this.updateSubmissionResult(submissionResult.submissionId)
}

/**
* 개별 테스트케이스의 실행 통계를 업데이트합니다.
*
* 매 실행 시마다 `submissionCount`를 1씩 증가시키며,
* 결과가 `Accepted`인 경우 `acceptedCount`도 1씩 증가시킵니다.
*
* @param {number} testcaseId 통계를 업데이트할 테스트케이스 ID
* @param {boolean} isAccepted 채점 결과가 정답(Accepted)인지 여부
* @returns {Promise<void>}
*/
@Span()
async updateTestcaseStats(
testcaseId: number,
isAccepted: boolean
): Promise<void> {
const testcaseStats = {
where: {
id: testcaseId
},
data: {
submissionCount: {
increment: 1
},
acceptedCount: {
increment: isAccepted ? 1 : 0
}
}
}
const statsTargets = submissionResults.filter(
(submissionResult) =>
!invalidSubmissionStatuses.includes(submissionResult.result)
)

await this.prisma.problemTestcase.update(testcaseStats)
await this.prisma.$transaction([
Comment thread
lshtar13 marked this conversation as resolved.
this.prisma.$executeRaw`
UPDATE "submission_result" AS sr
SET "result" = v.result::"ResultStatus",
"cpu_time" = v.cpu_time,
"memory_usage" = v.memory_usage,
"output" = v.output,
"update_time" = NOW()
FROM (
VALUES ${Prisma.join(
submissionResults.map(
(r) => Prisma.sql`(
${r.problemTestcaseId}::int,
${r.result}::text,
${r.cpuTime ?? null}::bigint,
${r.memoryUsage ?? null}::int,
${r.output ?? null}::text
)`
)
)}
) AS v(problem_test_case_id, result, cpu_time, memory_usage, output)
WHERE sr."submission_id" = ${submissionId}
AND sr."problem_test_case_id" = v.problem_test_case_id;
`,
...(statsTargets.length > 0
? [
this.prisma.$executeRaw`
UPDATE "problem_testcase" as pt
SET "submission_count" = pt."submission_count" + 1,
"accepted_count" = pt."accepted_count" + v.accepted
FROM (
VALUES ${Prisma.join(
statsTargets.map(
(r) => Prisma.sql`(
${r.problemTestcaseId}::int,
${r.result === ResultStatus.Accepted ? 1 : 0}::int
)`
)
)}
) AS v(problem_test_case_id, accepted)
WHERE pt."id" = v.problem_test_case_id
`
]
: [])
])

await this.updateSubmissionResult(submissionId)
}

/**
Expand Down
Loading
Loading