Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: implement sse for retrieving testcase results #2285

Draft
wants to merge 10 commits into
base: main
Choose a base branch
from
4 changes: 3 additions & 1 deletion apps/backend/apps/client/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { CacheModule } from '@nestjs/cache-manager'
import { Module, type OnApplicationBootstrap } from '@nestjs/common'
import { ConfigModule } from '@nestjs/config'
import { APP_GUARD, APP_FILTER, HttpAdapterHost } from '@nestjs/core'
import { EventEmitterModule } from '@nestjs/event-emitter'
import type { Server } from 'http'
import { OpenTelemetryModule } from 'nestjs-otel'
import { LoggerModule } from 'nestjs-pino'
Expand Down Expand Up @@ -52,7 +53,8 @@ import { WorkbookModule } from './workbook/workbook.module'
StorageModule,
AssignmentModule,
LoggerModule.forRoot(pinoLoggerModuleOption),
OpenTelemetryModule.forRoot()
OpenTelemetryModule.forRoot(),
EventEmitterModule.forRoot()
],
controllers: [AppController],
providers: [
Expand Down
26 changes: 22 additions & 4 deletions apps/backend/apps/client/src/submission/submission-sub.service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { CACHE_MANAGER } from '@nestjs/cache-manager'
import { Inject, Injectable, Logger, type OnModuleInit } from '@nestjs/common'
import { EventEmitter2 } from '@nestjs/event-emitter'
import { Nack, AmqpConnection } from '@golevelup/nestjs-rabbitmq'
import {
ResultStatus,
Expand All @@ -24,7 +25,9 @@ import {
RESULT_QUEUE,
RUN_MESSAGE_TYPE,
Status,
submissionTestcaseEvent,
TEST_SUBMISSION_EXPIRE_TIME,
testTestcaseEvent,
USER_TESTCASE_MESSAGE_TYPE
} from '@libs/constants'
import { UnprocessableDataException } from '@libs/exception'
Expand All @@ -38,6 +41,7 @@ export class SubmissionSubscriptionService implements OnModuleInit {
constructor(
private readonly prisma: PrismaService,
private readonly amqpConnection: AmqpConnection,
private readonly eventEmitter: EventEmitter2,
@Inject(CACHE_MANAGER) private readonly cacheManager: Cache
) {}

Expand Down Expand Up @@ -138,6 +142,11 @@ export class SubmissionSubscriptionService implements OnModuleInit {
}

await this.cacheManager.set(key, testcase, TEST_SUBMISSION_EXPIRE_TIME)

this.eventEmitter.emit(testTestcaseEvent(userId), {
userTest: isUserTest,
testcaseResult: testcase
})
}

parseError(msg: JudgerResponse, status: ResultStatus): string {
Expand Down Expand Up @@ -187,7 +196,12 @@ export class SubmissionSubscriptionService implements OnModuleInit {
memoryUsage: msg.judgeResult.memory
}

await this.updateTestcaseJudgeResult(submissionResult)
const resultStatus = await this.updateTestcaseJudgeResult(submissionResult)

this.eventEmitter.emit(submissionTestcaseEvent(msg.submissionId), {
result: resultStatus ? resultStatus : ResultStatus.Judging,
testcaseResult: submissionResult
})
}

@Span()
Expand Down Expand Up @@ -236,7 +250,7 @@ export class SubmissionSubscriptionService implements OnModuleInit {
async updateTestcaseJudgeResult(
submissionResult: Partial<SubmissionResult> &
Pick<SubmissionResult, 'result' | 'submissionId'>
): Promise<void> {
): Promise<ResultStatus | void> {
// TODO: submission의 값들이 아닌 submissionResult의 id 값으로 접근할 수 있도록 수정
const { id } = await this.prisma.submissionResult.findFirstOrThrow({
where: {
Expand All @@ -259,11 +273,13 @@ export class SubmissionSubscriptionService implements OnModuleInit {
}
})

await this.updateSubmissionResult(submissionResult.submissionId)
return await this.updateSubmissionResult(submissionResult.submissionId)
}

@Span()
async updateSubmissionResult(submissionId: number): Promise<void> {
async updateSubmissionResult(
submissionId: number
): Promise<ResultStatus | void> {
const submission = await this.prisma.submission.findUnique({
where: {
id: submissionId,
Expand Down Expand Up @@ -313,6 +329,8 @@ export class SubmissionSubscriptionService implements OnModuleInit {

await this.updateProblemScore(submission.id)
await this.updateProblemAccepted(submission.problemId, allAccepted)

return submissionResult
}

@Span()
Expand Down
85 changes: 83 additions & 2 deletions apps/backend/apps/client/src/submission/submission.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,14 @@ import {
Req,
Query,
DefaultValuePipe,
Headers
Headers,
Sse,
ParseIntPipe
} from '@nestjs/common'
import { EventEmitter2 } from '@nestjs/event-emitter'
import { Observable } from 'rxjs'
import { AuthNotNeededIfOpenSpace, AuthenticatedRequest } from '@libs/auth'
import { submissionTestcaseEvent, testTestcaseEvent } from '@libs/constants'
import {
CursorValidationPipe,
GroupIDPipe,
Expand All @@ -24,7 +29,10 @@ import { SubmissionService } from './submission.service'

@Controller('submission')
export class SubmissionController {
constructor(private readonly submissionService: SubmissionService) {}
constructor(
private readonly submissionService: SubmissionService,
private readonly eventEmitter: EventEmitter2
) {}

/**
* 아직 채점되지 않은 제출 기록을 만들고, 채점 서버에 채점 요청을 보냅니다.
Expand Down Expand Up @@ -97,6 +105,40 @@ export class SubmissionController {
return await this.submissionService.getTestResult(req.user.id)
}

@Sse('result/test')
async getTestTestcaseResult(
@Req() req: AuthenticatedRequest
): Promise<Observable<MessageEvent>> {
const userId = req.user.id

return new Observable<MessageEvent>((subscriber) => {
/*
TODO: payload 타입 정의
payload 구조:
{
"userTest": true,
"testcaseResult": {
"id": 1,
"result": "Accepted",
"output": "Hello World"
}
}
*/
const listener = (payload) => {
subscriber.next({
data: JSON.stringify(payload)
} as MessageEvent)
}

const event = testTestcaseEvent(userId)
this.eventEmitter.on(event, listener)
req.on('close', () => {
this.eventEmitter.off(event, listener)
if (!subscriber.closed) subscriber.complete()
})
})
}

/**
* 유저가 생성한 테스트케이스에 대해 실행을 요청합니다.
* 채점 결과는 Cache에 저장됩니다.
Expand Down Expand Up @@ -163,6 +205,45 @@ export class SubmissionController {
contestId
)
}

@Sse('result/:submissionId')
async getSubmissionTestcaseResult(
@Req() req: AuthenticatedRequest,
@Param('submissionId', ParseIntPipe) submissionId: number
): Promise<Observable<MessageEvent>> {
const userId = req.user.id
await this.submissionService.checkSubmissionId(submissionId, userId)

return new Observable<MessageEvent>((subscriber) => {
/*
TODO: payload 타입 정의

payload 구조:
{
result: ResultStatus
testcaseResult: {
submissionId: number,
problemTestcaseId: number,
result: ResultStatus,
cpuTime: bigint,
memoryUsage: number
}
}
*/
const listener = (payload) => {
subscriber.next({
data: JSON.stringify(payload)
} as MessageEvent)
}

const event = submissionTestcaseEvent(submissionId)
this.eventEmitter.on(event, listener)
req.on('close', () => {
this.eventEmitter.off(event, listener)
if (!subscriber.closed) subscriber.complete()
})
})
}
}

@Controller('contest/:contestId/submission')
Expand Down
9 changes: 9 additions & 0 deletions apps/backend/apps/client/src/submission/submission.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -826,4 +826,13 @@ export class SubmissionService {

return { data: submissions, total }
}

async checkSubmissionId(submissionId: number, userId: number) {
await this.prisma.submission.findFirstOrThrow({
where: {
id: submissionId,
userId
}
})
}
}
9 changes: 9 additions & 0 deletions apps/backend/libs/constants/src/submission.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,12 @@ export const Status = (code: number) => {
return ResultStatus.ServerError
}
}

const SUBMISSION_TESTCASE_EVENT = 'submission.submission'
const TEST_TESTCASE_EVENT = 'submission.test'

export const submissionTestcaseEvent = (submissionId: number) =>
`${SUBMISSION_TESTCASE_EVENT}:${submissionId}`

export const testTestcaseEvent = (userId: number) =>
`${TEST_TESTCASE_EVENT}:${userId}`
1 change: 1 addition & 0 deletions apps/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"@nestjs/common": "^10.4.15",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.15",
"@nestjs/event-emitter": "^2.1.1",
"@nestjs/graphql": "^12.2.2",
"@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,22 @@

import { ScrollArea, ScrollBar } from '@/components/shadcn/scroll-area'
import { cn, getResultColor } from '@/libs/utils'
import type { TestResultDetail } from '@/types/type'
import { useState, type ReactNode } from 'react'
import { IoMdClose } from 'react-icons/io'
import { WhitespaceVisualizer } from '../WhitespaceVisualizer'
import AddUserTestcaseDialog from './AddUserTestcaseDialog'
import TestcaseTable from './TestcaseTable'
import { useTestResults } from './useTestResults'

interface TestResultDetail {
id: number
originalId: number //에러
input: string
expectedOutput: string // 추가
output: string
result: string
isUserTestcase: boolean
}
export default function TestcasePanel() {
const [testcaseTabList, setTestcaseTabList] = useState<TestResultDetail[]>([])
const [currentTab, setCurrentTab] = useState<number>(0)
Expand Down Expand Up @@ -37,7 +45,7 @@ export default function TestcasePanel() {

const MAX_OUTPUT_LENGTH = 100000
const testResults = useTestResults()
const processedData = testResults.map((testcase) => ({
const processedData: TestResultDetail[] = testResults.map((testcase) => ({
...testcase,
output:
testcase.output.length > MAX_OUTPUT_LENGTH
Expand All @@ -46,7 +54,7 @@ export default function TestcasePanel() {
}))
const summaryData = processedData.map(({ id, result, isUserTestcase }) => ({
id,
result,
result: result || 'Pending', //Pending 문제가 아니라 인터페이스 문제같음
isUserTestcase
}))

Expand Down
Loading
Loading