From 07d028513c00248ac60f69581e2c877e3c72a7db Mon Sep 17 00:00:00 2001 From: Jaehyeon Kim <65964601+Jaehyeon1020@users.noreply.github.com> Date: Wed, 8 Jan 2025 14:55:07 +0000 Subject: [PATCH 1/9] feat(be): implement sse endpoint for submission testcase result --- apps/backend/apps/client/src/app.module.ts | 4 ++- .../src/submission/submission-sub.service.ts | 8 +++++ .../src/submission/submission.controller.ts | 36 +++++++++++++++++-- .../src/submission/submission.service.ts | 9 +++++ .../constants/src/submission.constants.ts | 13 +++++++ apps/backend/package.json | 1 + pnpm-lock.yaml | 20 +++++++++++ 7 files changed, 88 insertions(+), 3 deletions(-) diff --git a/apps/backend/apps/client/src/app.module.ts b/apps/backend/apps/client/src/app.module.ts index d418fd7524..014ef67963 100644 --- a/apps/backend/apps/client/src/app.module.ts +++ b/apps/backend/apps/client/src/app.module.ts @@ -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' @@ -50,7 +51,8 @@ import { WorkbookModule } from './workbook/workbook.module' AnnouncementModule, StorageModule, LoggerModule.forRoot(pinoLoggerModuleOption), - OpenTelemetryModule.forRoot() + OpenTelemetryModule.forRoot(), + EventEmitterModule.forRoot() ], controllers: [AppController], providers: [ diff --git a/apps/backend/apps/client/src/submission/submission-sub.service.ts b/apps/backend/apps/client/src/submission/submission-sub.service.ts index 195ec6555e..09012d94c6 100644 --- a/apps/backend/apps/client/src/submission/submission-sub.service.ts +++ b/apps/backend/apps/client/src/submission/submission-sub.service.ts @@ -1,5 +1,6 @@ import { CACHE_MANAGER } from '@nestjs/cache-manager' import { Inject, Injectable, Logger, type OnModuleInit } from '@nestjs/common' +import type { EventEmitter2 } from '@nestjs/event-emitter' import { Nack, AmqpConnection } from '@golevelup/nestjs-rabbitmq' import { ResultStatus, @@ -24,6 +25,7 @@ import { RESULT_QUEUE, RUN_MESSAGE_TYPE, Status, + submissionTestcaseEvent, TEST_SUBMISSION_EXPIRE_TIME, USER_TESTCASE_MESSAGE_TYPE } from '@libs/constants' @@ -38,6 +40,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 ) {} @@ -188,6 +191,11 @@ export class SubmissionSubscriptionService implements OnModuleInit { } await this.updateTestcaseJudgeResult(submissionResult) + + this.eventEmitter.emit( + submissionTestcaseEvent(msg.submissionId), + submissionResult + ) } @Span() diff --git a/apps/backend/apps/client/src/submission/submission.controller.ts b/apps/backend/apps/client/src/submission/submission.controller.ts index e71b0e74d8..2328abdd4c 100644 --- a/apps/backend/apps/client/src/submission/submission.controller.ts +++ b/apps/backend/apps/client/src/submission/submission.controller.ts @@ -7,9 +7,14 @@ import { Req, Query, DefaultValuePipe, - Headers + Headers, + Sse, + ParseIntPipe } from '@nestjs/common' +import type { EventEmitter2 } from '@nestjs/event-emitter' +import { Observable } from 'rxjs' import { AuthNotNeededIfOpenSpace, AuthenticatedRequest } from '@libs/auth' +import { submissionTestcaseEvent } from '@libs/constants' import { CursorValidationPipe, GroupIDPipe, @@ -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 + ) {} /** * 아직 채점되지 않은 제출 기록을 만들고, 채점 서버에 채점 요청을 보냅니다. @@ -163,6 +171,30 @@ export class SubmissionController { contestId ) } + + @Sse('result/:submissionId') + async getSubmissionTestcaseResult( + @Req() req: AuthenticatedRequest, + @Param('submissionId', ParseIntPipe) submissionId: number + ): Promise> { + const userId = req.user.id + await this.submissionService.checkSubmissionId(submissionId, userId) + + return new Observable((subscriber) => { + const listener = (payload) => { + if (payload.submissionId === submissionId) { + subscriber.next(payload) + } + } + + 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') diff --git a/apps/backend/apps/client/src/submission/submission.service.ts b/apps/backend/apps/client/src/submission/submission.service.ts index 88d23d69dd..7c19521668 100644 --- a/apps/backend/apps/client/src/submission/submission.service.ts +++ b/apps/backend/apps/client/src/submission/submission.service.ts @@ -825,4 +825,13 @@ export class SubmissionService { return { data: submissions, total } } + + async checkSubmissionId(submissionId: number, userId: number) { + await this.prisma.submission.findFirstOrThrow({ + where: { + id: submissionId, + userId + } + }) + } } diff --git a/apps/backend/libs/constants/src/submission.constants.ts b/apps/backend/libs/constants/src/submission.constants.ts index c2d42dcfcb..b9a59d3a2f 100644 --- a/apps/backend/libs/constants/src/submission.constants.ts +++ b/apps/backend/libs/constants/src/submission.constants.ts @@ -45,3 +45,16 @@ export const Status = (code: number) => { return ResultStatus.ServerError } } + +const SUBMISSION_TESTCASE_EVENT = 'submission.submission' +const TEST_TESTCASE_EVENT = 'submission.test' +const USERTEST_TESTCASE_EVENT = 'submission.usertest' + +export const submissionTestcaseEvent = (submissionId: number) => + `${SUBMISSION_TESTCASE_EVENT}:${submissionId}` + +export const testTestcaseEvent = (userId: number) => + `${TEST_TESTCASE_EVENT}:${userId}` + +export const userTestTestcaseEvent = (userId: number) => + `${USERTEST_TESTCASE_EVENT}:${userId}` diff --git a/apps/backend/package.json b/apps/backend/package.json index 9f5ae6e453..86f33e67e6 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 86c461f3c4..3fda30c463 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -101,6 +101,9 @@ importers: '@nestjs/core': specifier: ^10.4.15 version: 10.4.15(@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/event-emitter': + specifier: ^2.1.1 + version: 2.1.1(@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15) '@nestjs/graphql': specifier: ^12.2.2 version: 12.2.2(@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15)(class-transformer@0.5.1)(class-validator@0.14.1)(graphql@16.10.0)(reflect-metadata@0.2.2)(ts-morph@16.0.0) @@ -2504,6 +2507,12 @@ packages: '@nestjs/websockets': optional: true + '@nestjs/event-emitter@2.1.1': + resolution: {integrity: sha512-6L6fBOZTyfFlL7Ih/JDdqlCzZeCW0RjCX28wnzGyg/ncv5F/EOeT1dfopQr1loBRQ3LTgu8OWM7n4zLN4xigsg==} + peerDependencies: + '@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 + '@nestjs/core': ^8.0.0 || ^9.0.0 || ^10.0.0 + '@nestjs/graphql@12.2.2': resolution: {integrity: sha512-lUDy/1uqbRA1kBKpXcmY0aHhcPbfeG52Wg5+9Jzd1d57dwSjCAmuO+mWy5jz9ugopVCZeK0S/kdAMvA+r9fNdA==} peerDependencies: @@ -6597,6 +6606,9 @@ packages: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} + eventemitter2@6.4.9: + resolution: {integrity: sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==} + eventemitter3@3.1.2: resolution: {integrity: sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==} @@ -13659,6 +13671,12 @@ snapshots: transitivePeerDependencies: - encoding + '@nestjs/event-emitter@2.1.1(@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15)': + dependencies: + '@nestjs/common': 10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/core': 10.4.15(@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.1) + eventemitter2: 6.4.9 + '@nestjs/graphql@12.2.2(@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15)(class-transformer@0.5.1)(class-validator@0.14.1)(graphql@16.10.0)(reflect-metadata@0.2.2)(ts-morph@16.0.0)': dependencies: '@graphql-tools/merge': 9.0.11(graphql@16.10.0) @@ -18521,6 +18539,8 @@ snapshots: event-target-shim@5.0.1: {} + eventemitter2@6.4.9: {} + eventemitter3@3.1.2: {} eventemitter3@5.0.1: {} From 1f519c75fa08444436338e2cef51f3f6ce04c187 Mon Sep 17 00:00:00 2001 From: Jaehyeon Kim <65964601+Jaehyeon1020@users.noreply.github.com> Date: Thu, 9 Jan 2025 09:24:15 +0000 Subject: [PATCH 2/9] fix(be): modify to return submission result --- .../src/submission/submission-sub.service.ts | 20 +++++++++++------- .../src/submission/submission.controller.ts | 21 ++++++++++++++++--- 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/apps/backend/apps/client/src/submission/submission-sub.service.ts b/apps/backend/apps/client/src/submission/submission-sub.service.ts index 09012d94c6..8d07ea82e8 100644 --- a/apps/backend/apps/client/src/submission/submission-sub.service.ts +++ b/apps/backend/apps/client/src/submission/submission-sub.service.ts @@ -190,12 +190,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), - submissionResult - ) + this.eventEmitter.emit(submissionTestcaseEvent(msg.submissionId), { + result: resultStatus ? resultStatus : ResultStatus.Judging, + testcaseResult: submissionResult + }) } @Span() @@ -244,7 +244,7 @@ export class SubmissionSubscriptionService implements OnModuleInit { async updateTestcaseJudgeResult( submissionResult: Partial & Pick - ): Promise { + ): Promise { // TODO: submission의 값들이 아닌 submissionResult의 id 값으로 접근할 수 있도록 수정 const { id } = await this.prisma.submissionResult.findFirstOrThrow({ where: { @@ -267,11 +267,13 @@ export class SubmissionSubscriptionService implements OnModuleInit { } }) - await this.updateSubmissionResult(submissionResult.submissionId) + return await this.updateSubmissionResult(submissionResult.submissionId) } @Span() - async updateSubmissionResult(submissionId: number): Promise { + async updateSubmissionResult( + submissionId: number + ): Promise { const submission = await this.prisma.submission.findUnique({ where: { id: submissionId, @@ -321,6 +323,8 @@ export class SubmissionSubscriptionService implements OnModuleInit { await this.updateProblemScore(submission.id) await this.updateProblemAccepted(submission.problemId, allAccepted) + + return submissionResult } @Span() diff --git a/apps/backend/apps/client/src/submission/submission.controller.ts b/apps/backend/apps/client/src/submission/submission.controller.ts index 2328abdd4c..41fcdc7f1f 100644 --- a/apps/backend/apps/client/src/submission/submission.controller.ts +++ b/apps/backend/apps/client/src/submission/submission.controller.ts @@ -181,10 +181,25 @@ export class SubmissionController { await this.submissionService.checkSubmissionId(submissionId, userId) return new Observable((subscriber) => { - const listener = (payload) => { - if (payload.submissionId === submissionId) { - subscriber.next(payload) + /* + 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) From b5d65b322215b233ced74c35b2117006ede7fe72 Mon Sep 17 00:00:00 2001 From: donghun1214 Date: Fri, 10 Jan 2025 13:07:37 +0000 Subject: [PATCH 3/9] feat(be): implement sse endpoint for test api --- .../src/submission/submission-sub.service.ts | 5 +++ .../src/submission/submission.controller.ts | 34 +++++++++++++++++++ .../constants/src/submission.constants.ts | 4 --- 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/apps/backend/apps/client/src/submission/submission-sub.service.ts b/apps/backend/apps/client/src/submission/submission-sub.service.ts index 8d07ea82e8..5bc818af15 100644 --- a/apps/backend/apps/client/src/submission/submission-sub.service.ts +++ b/apps/backend/apps/client/src/submission/submission-sub.service.ts @@ -141,6 +141,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 { diff --git a/apps/backend/apps/client/src/submission/submission.controller.ts b/apps/backend/apps/client/src/submission/submission.controller.ts index 41fcdc7f1f..a9997ce1bd 100644 --- a/apps/backend/apps/client/src/submission/submission.controller.ts +++ b/apps/backend/apps/client/src/submission/submission.controller.ts @@ -105,6 +105,40 @@ export class SubmissionController { return await this.submissionService.getTestResult(req.user.id) } + @Sse('result/test') + async getTestTestcaseResult( + @Req() req: AuthenticatedRequest + ): Promise> { + const userId = req.user.id + + return new Observable((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에 저장됩니다. diff --git a/apps/backend/libs/constants/src/submission.constants.ts b/apps/backend/libs/constants/src/submission.constants.ts index b9a59d3a2f..a8ef254d19 100644 --- a/apps/backend/libs/constants/src/submission.constants.ts +++ b/apps/backend/libs/constants/src/submission.constants.ts @@ -48,13 +48,9 @@ export const Status = (code: number) => { const SUBMISSION_TESTCASE_EVENT = 'submission.submission' const TEST_TESTCASE_EVENT = 'submission.test' -const USERTEST_TESTCASE_EVENT = 'submission.usertest' export const submissionTestcaseEvent = (submissionId: number) => `${SUBMISSION_TESTCASE_EVENT}:${submissionId}` export const testTestcaseEvent = (userId: number) => `${TEST_TESTCASE_EVENT}:${userId}` - -export const userTestTestcaseEvent = (userId: number) => - `${USERTEST_TESTCASE_EVENT}:${userId}` From f701dc9a41cd17c29dfc8fbd61bd72ecea208f85 Mon Sep 17 00:00:00 2001 From: Kimhyojung0810 Date: Sun, 12 Jan 2025 03:45:50 +0900 Subject: [PATCH 4/9] feat(fe): test sse error --- .../TestcasePanel/TestcasePanel.tsx | 14 +- .../TestcasePanel/useTestResults.ts | 186 ++++++++---------- 2 files changed, 98 insertions(+), 102 deletions(-) diff --git a/apps/frontend/app/(client)/(code-editor)/_components/TestcasePanel/TestcasePanel.tsx b/apps/frontend/app/(client)/(code-editor)/_components/TestcasePanel/TestcasePanel.tsx index b0e32452ca..13a9e58d4b 100644 --- a/apps/frontend/app/(client)/(code-editor)/_components/TestcasePanel/TestcasePanel.tsx +++ b/apps/frontend/app/(client)/(code-editor)/_components/TestcasePanel/TestcasePanel.tsx @@ -2,7 +2,6 @@ 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' @@ -10,6 +9,15 @@ 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([]) const [currentTab, setCurrentTab] = useState(0) @@ -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 @@ -46,7 +54,7 @@ export default function TestcasePanel() { })) const summaryData = processedData.map(({ id, result, isUserTestcase }) => ({ id, - result, + result: result || 'Pending', //Pending 문제가 아니라 인터페이스 문제같음 isUserTestcase })) diff --git a/apps/frontend/app/(client)/(code-editor)/_components/TestcasePanel/useTestResults.ts b/apps/frontend/app/(client)/(code-editor)/_components/TestcasePanel/useTestResults.ts index 1d18395b27..bedc1fd8eb 100644 --- a/apps/frontend/app/(client)/(code-editor)/_components/TestcasePanel/useTestResults.ts +++ b/apps/frontend/app/(client)/(code-editor)/_components/TestcasePanel/useTestResults.ts @@ -1,121 +1,109 @@ -import { safeFetcherWithAuth } from '@/libs/utils' -import type { TestResult } from '@/types/type' -import { useQueries } from '@tanstack/react-query' -import { useEffect, useRef } from 'react' +import { useEffect, useRef, useState } from 'react' import { toast } from 'sonner' -import { useTestPollingStore } from '../context/TestPollingStoreProvider' import { useTestcaseStore } from '../context/TestcaseStoreProvider' -const MAX_ATTEMPTS = 10 -const REFETCH_INTERVAL = 2000 +interface TestResultDetail { + id: number + originalId: number // 추가 + input: string + expectedOutput: string // 추가 + output: string + result: string + isUserTestcase: boolean +} +interface SSETestcaseResult { + id: number // 백엔드 /test 고려 + submissionId: number + problemTestcaseId: number + result: string + cpuTime: string + memoryUsage: number + output?: string +} + +interface SSEData { + result: string + testcaseResult: SSETestcaseResult + userTest?: boolean +} + +const useSSE = (endpoint: string) => { + const [results, setResults] = useState([]) // SSE에서 수신된 데이터를 저장하고 + const [isError, setIsError] = useState(false) // 에러 상태를 관리하고 + const eventSourceRef = useRef(null) // 연결 참고 -const useGetTestResult = (type: 'sample' | 'user') => { - const attempts = useRef(0) - const setIsTesting = useTestPollingStore((state) => state.setIsTesting) - const stopPolling = useTestPollingStore((state) => state.stopPolling) + useEffect(() => { + const eventSource = new EventSource(endpoint) // SSE 연결 생성 + eventSourceRef.current = eventSource - const baseUrl = type === 'sample' ? 'submission/test' : 'submission/user-test' + eventSource.onmessage = (event: MessageEvent) => { + try { + const data: SSEData = JSON.parse(event.data) + setResults((prev) => [...prev, data]) - const getTestResult = async () => { - const res = await safeFetcherWithAuth.get(baseUrl, { - next: { - revalidate: 0 + // 모든 테스트 케이스를 실행한게 아닐 경우 Judging을 반환하는 구조임 + if (data.result !== 'Judging') { + eventSource.close() // 즉, 최종 결과가 반환되면 모두 실행한 것으로 파악하고 SSE 연결 종료 + } + } catch (error) { + console.error('Error parsing SSE message:', error) } - }) + } - const results: TestResult[] = await res.json() - - const allJudged = results.every( - (submission) => submission.result !== 'Judging' - ) - - if (allJudged) { - // Test execution is finished - attempts.current = 0 - setIsTesting(false) - stopPolling(type) - } else if (attempts.current < MAX_ATTEMPTS) { - // Retry - attempts.current += 1 - } else { - // No more retry - attempts.current = 0 - setIsTesting(false) - stopPolling(type) - toast.error('Judging took too long. Please try again later.') + eventSource.onerror = () => { + setIsError(true) + toast.error('Failed to receive test results.') + eventSource.close() } - return results - } + return () => { + eventSource.close() + } + }, [endpoint]) - return getTestResult + return { results, isError } } export const useTestResults = () => { - const getSampleTestResult = useGetTestResult('sample') - const getUserTestResult = useGetTestResult('user') - const { - samplePollingEnabled, - userPollingEnabled, - setIsTesting, - stopPolling - } = useTestPollingStore((state) => state) - - const { data, isError } = useQueries({ - queries: [ - { - queryKey: ['submission', 'test'], - queryFn: getSampleTestResult, - throwOnError: false, - refetchInterval: REFETCH_INTERVAL, - enabled: samplePollingEnabled - }, - { - queryKey: ['submission', 'user-test'], - queryFn: getUserTestResult, - throwOnError: false, - refetchInterval: REFETCH_INTERVAL, - enabled: userPollingEnabled - } - ], - combine: (results) => ({ - data: results.flatMap((result) => result.data ?? []), - isError: results.some((result) => result.isError) - }) - }) - const testcases = useTestcaseStore((state) => state.getTestcases()) - let userTestcaseCount = 1 - let sampleTestcaseCount = 1 - const testResults = - data.length > 0 - ? testcases.map((testcase, index) => { - const testResult = data.find((item) => item.id === testcase.id) - if (testcase.isUserTestcase) { - testcase.id = userTestcaseCount++ - } else { - testcase.id = sampleTestcaseCount++ - } - return { - id: testcase.id, - originalId: index + 1, - input: testcase.input, - expectedOutput: testcase.output, - output: testResult?.output ?? '', - result: testResult?.result ?? '', - isUserTestcase: testcase.isUserTestcase - } - }) - : [] + + const { results: sampleResults, isError: sampleError } = + useSSE('/submission/:id') + const { results: userResults, isError: userError } = + useSSE('/submission/test') + const [testResults, setTestResults] = useState([]) // 타입 명시 에러 해결 useEffect(() => { - if (isError) { + if (sampleError || userError) { toast.error('Failed to execute some testcases. Please try again later.') - setIsTesting(false) - stopPolling('sample') - stopPolling('user') } - }, [isError]) + }, [sampleError, userError]) + //TO-DO: 여기서 지금 루프 에러나는 듯 + useEffect(() => { + const allResults = [...sampleResults, ...userResults] + + const enrichedResults = testcases.map((testcase, index) => { + const testResult = allResults.find((result) => { + if (result.userTest) { + return result.testcaseResult.id === testcase.id + } else { + return result.testcaseResult.problemTestcaseId === testcase.id + } + }) + + return { + id: testcase.id, + originalId: index + 1, + input: testcase.input, + expectedOutput: testcase.output, + output: testResult?.testcaseResult?.output ?? '', + result: testResult?.testcaseResult?.result ?? 'Pending', + isUserTestcase: testcase.isUserTestcase + } + }) + + setTestResults(enrichedResults) + }, [sampleResults, userResults, testcases]) return testResults } From 4c337002270c673048f844f4111b17d4539902f2 Mon Sep 17 00:00:00 2001 From: Kimhyojung0810 Date: Sun, 12 Jan 2025 03:46:35 +0900 Subject: [PATCH 5/9] fix(fe): fix run time error --- .../_components/TestcasePanel/useTestResults.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/frontend/app/(client)/(code-editor)/_components/TestcasePanel/useTestResults.ts b/apps/frontend/app/(client)/(code-editor)/_components/TestcasePanel/useTestResults.ts index bedc1fd8eb..e06d55f5ef 100644 --- a/apps/frontend/app/(client)/(code-editor)/_components/TestcasePanel/useTestResults.ts +++ b/apps/frontend/app/(client)/(code-editor)/_components/TestcasePanel/useTestResults.ts @@ -102,7 +102,12 @@ export const useTestResults = () => { } }) - setTestResults(enrichedResults) + // 런타임 에러 해결 시도 + setTestResults((prev) => { + const hasChanged = + JSON.stringify(prev) !== JSON.stringify(enrichedResults) + return hasChanged ? enrichedResults : prev + }) }, [sampleResults, userResults, testcases]) return testResults From dde565a0368e368588a082fc9b2cfe86eec47bb9 Mon Sep 17 00:00:00 2001 From: Kimhyojung0810 Date: Sun, 12 Jan 2025 03:48:55 +0900 Subject: [PATCH 6/9] fix(fe): fix run time error --- .../_components/TestcasePanel/useTestResults.ts | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/apps/frontend/app/(client)/(code-editor)/_components/TestcasePanel/useTestResults.ts b/apps/frontend/app/(client)/(code-editor)/_components/TestcasePanel/useTestResults.ts index e06d55f5ef..253b4e5569 100644 --- a/apps/frontend/app/(client)/(code-editor)/_components/TestcasePanel/useTestResults.ts +++ b/apps/frontend/app/(client)/(code-editor)/_components/TestcasePanel/useTestResults.ts @@ -66,23 +66,23 @@ const useSSE = (endpoint: string) => { export const useTestResults = () => { const testcases = useTestcaseStore((state) => state.getTestcases()) + const [initialTestcases] = useState(testcases) // 초기 데이터 저장 const { results: sampleResults, isError: sampleError } = useSSE('/submission/:id') const { results: userResults, isError: userError } = useSSE('/submission/test') - const [testResults, setTestResults] = useState([]) // 타입 명시 에러 해결 + const [testResults, setTestResults] = useState([]) useEffect(() => { if (sampleError || userError) { toast.error('Failed to execute some testcases. Please try again later.') } }, [sampleError, userError]) - //TO-DO: 여기서 지금 루프 에러나는 듯 useEffect(() => { const allResults = [...sampleResults, ...userResults] - const enrichedResults = testcases.map((testcase, index) => { + const enrichedResults = initialTestcases.map((testcase, index) => { const testResult = allResults.find((result) => { if (result.userTest) { return result.testcaseResult.id === testcase.id @@ -102,13 +102,9 @@ export const useTestResults = () => { } }) - // 런타임 에러 해결 시도 - setTestResults((prev) => { - const hasChanged = - JSON.stringify(prev) !== JSON.stringify(enrichedResults) - return hasChanged ? enrichedResults : prev - }) - }, [sampleResults, userResults, testcases]) + // 런타임 에러는 해결 + setTestResults(enrichedResults) + }, [sampleResults, userResults, initialTestcases]) return testResults } From 56439b84e389e5429c16c5d511aff0dd16cf1ae5 Mon Sep 17 00:00:00 2001 From: Kimhyojung0810 Date: Sun, 12 Jan 2025 04:14:10 +0900 Subject: [PATCH 7/9] feat(fe): add comments --- .../TestcasePanel/useTestResults.ts | 31 +++++++++++++++---- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/apps/frontend/app/(client)/(code-editor)/_components/TestcasePanel/useTestResults.ts b/apps/frontend/app/(client)/(code-editor)/_components/TestcasePanel/useTestResults.ts index 253b4e5569..0a6d324bc1 100644 --- a/apps/frontend/app/(client)/(code-editor)/_components/TestcasePanel/useTestResults.ts +++ b/apps/frontend/app/(client)/(code-editor)/_components/TestcasePanel/useTestResults.ts @@ -27,20 +27,28 @@ interface SSEData { userTest?: boolean } +// SSE를 통해 데이터를 수신하는 커스텀 훅 const useSSE = (endpoint: string) => { - const [results, setResults] = useState([]) // SSE에서 수신된 데이터를 저장하고 + // SSE를 통해 수신된 데이터를 저장 + + const [results, setResults] = useState([]) const [isError, setIsError] = useState(false) // 에러 상태를 관리하고 - const eventSourceRef = useRef(null) // 연결 참고 + + // EventSource 인스턴스를 관리하기 위한 ref + const eventSourceRef = useRef(null) useEffect(() => { - const eventSource = new EventSource(endpoint) // SSE 연결 생성 + // 본격 SSE 연결 생성 + const eventSource = new EventSource(endpoint) eventSourceRef.current = eventSource + // SSE로 데이터 수신 시 호출되는 핸들러 eventSource.onmessage = (event: MessageEvent) => { try { const data: SSEData = JSON.parse(event.data) setResults((prev) => [...prev, data]) + // 만약 전체 제출 상태가 Judging이 아니라면, 최종 결과로 간주하고 SSE 연결 종료 // 모든 테스트 케이스를 실행한게 아닐 경우 Judging을 반환하는 구조임 if (data.result !== 'Judging') { eventSource.close() // 즉, 최종 결과가 반환되면 모두 실행한 것으로 파악하고 SSE 연결 종료 @@ -50,12 +58,15 @@ const useSSE = (endpoint: string) => { } } + // SSE 연결 에러 시 호출되는 핸들러 + // TO-DO eventSource.onerror = () => { setIsError(true) - toast.error('Failed to receive test results.') + toast.error('Failed to receive test results.') // 지금 이 에러 메세지가 **두번** 뜨고 있음 ->SSE 연결 중 에러 발생 eventSource.close() } + // 컴포넌트 언마운트 시 SSE 연결 정리 return () => { eventSource.close() } @@ -64,24 +75,32 @@ const useSSE = (endpoint: string) => { return { results, isError } } +// 테스트 결과를 처리하는 커스텀 훅 export const useTestResults = () => { + // 상태 관리: 테스트케이스 데이터를 가져오기 const testcases = useTestcaseStore((state) => state.getTestcases()) const [initialTestcases] = useState(testcases) // 초기 데이터 저장 const { results: sampleResults, isError: sampleError } = - useSSE('/submission/:id') + useSSE('/submission/:id') //엔드 포인트 const { results: userResults, isError: userError } = useSSE('/submission/test') const [testResults, setTestResults] = useState([]) + // SSE 에러 발생 시 사용자에게 알림 + // TO-DO useEffect(() => { if (sampleError || userError) { - toast.error('Failed to execute some testcases. Please try again later.') + toast.error('Failed to execute some testcases. Please try again later.') // 이 에러도 한번 발생. SSE 연결 문제 } }, [sampleError, userError]) + // SSE에서 수신된 데이터를 테스트케이스와 매핑하여 결과 생성 useEffect(() => { + // 모든 SSE 결과를 통합 const allResults = [...sampleResults, ...userResults] + // 초기 테스트케이스 데이터와 SSE 결과를 매핑하여 최종 결과 생성 + // 매핑 부분 좀 더 확인 필요 const enrichedResults = initialTestcases.map((testcase, index) => { const testResult = allResults.find((result) => { if (result.userTest) { From efb9c51919317bf77efbb065ebce3aaa90c7617f Mon Sep 17 00:00:00 2001 From: Kimhyojung0810 Date: Sun, 12 Jan 2025 15:08:31 +0900 Subject: [PATCH 8/9] feat(fe): change sse endpoint --- .../_components/TestcasePanel/useTestResults.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/frontend/app/(client)/(code-editor)/_components/TestcasePanel/useTestResults.ts b/apps/frontend/app/(client)/(code-editor)/_components/TestcasePanel/useTestResults.ts index 0a6d324bc1..5b547b061b 100644 --- a/apps/frontend/app/(client)/(code-editor)/_components/TestcasePanel/useTestResults.ts +++ b/apps/frontend/app/(client)/(code-editor)/_components/TestcasePanel/useTestResults.ts @@ -81,10 +81,12 @@ export const useTestResults = () => { const testcases = useTestcaseStore((state) => state.getTestcases()) const [initialTestcases] = useState(testcases) // 초기 데이터 저장 - const { results: sampleResults, isError: sampleError } = - useSSE('/submission/:id') //엔드 포인트 - const { results: userResults, isError: userError } = - useSSE('/submission/test') + const { results: sampleResults, isError: sampleError } = useSSE( + '/submission/result/:submissionId' + ) //엔드 포인트 + const { results: userResults, isError: userError } = useSSE( + '/submission/result/test' + ) const [testResults, setTestResults] = useState([]) // SSE 에러 발생 시 사용자에게 알림 From 2c2106a47e04074b8741b8eda2963b6993d094e4 Mon Sep 17 00:00:00 2001 From: Jaehyeon Kim <65964601+Jaehyeon1020@users.noreply.github.com> Date: Sun, 12 Jan 2025 07:07:28 +0000 Subject: [PATCH 9/9] fix(be): remove type import to use event-emitter --- .../apps/client/src/submission/submission-sub.service.ts | 3 ++- .../apps/client/src/submission/submission.controller.ts | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/backend/apps/client/src/submission/submission-sub.service.ts b/apps/backend/apps/client/src/submission/submission-sub.service.ts index 5bc818af15..17adfe198d 100644 --- a/apps/backend/apps/client/src/submission/submission-sub.service.ts +++ b/apps/backend/apps/client/src/submission/submission-sub.service.ts @@ -1,6 +1,6 @@ import { CACHE_MANAGER } from '@nestjs/cache-manager' import { Inject, Injectable, Logger, type OnModuleInit } from '@nestjs/common' -import type { EventEmitter2 } from '@nestjs/event-emitter' +import { EventEmitter2 } from '@nestjs/event-emitter' import { Nack, AmqpConnection } from '@golevelup/nestjs-rabbitmq' import { ResultStatus, @@ -27,6 +27,7 @@ import { Status, submissionTestcaseEvent, TEST_SUBMISSION_EXPIRE_TIME, + testTestcaseEvent, USER_TESTCASE_MESSAGE_TYPE } from '@libs/constants' import { UnprocessableDataException } from '@libs/exception' diff --git a/apps/backend/apps/client/src/submission/submission.controller.ts b/apps/backend/apps/client/src/submission/submission.controller.ts index 5820f06864..0a1305cec9 100644 --- a/apps/backend/apps/client/src/submission/submission.controller.ts +++ b/apps/backend/apps/client/src/submission/submission.controller.ts @@ -11,10 +11,10 @@ import { Sse, ParseIntPipe } from '@nestjs/common' -import type { EventEmitter2 } from '@nestjs/event-emitter' +import { EventEmitter2 } from '@nestjs/event-emitter' import { Observable } from 'rxjs' import { AuthNotNeededIfOpenSpace, AuthenticatedRequest } from '@libs/auth' -import { submissionTestcaseEvent } from '@libs/constants' +import { submissionTestcaseEvent, testTestcaseEvent } from '@libs/constants' import { CursorValidationPipe, GroupIDPipe,