From 1994198f11c2cd7954cea974da37fb869e9aa922 Mon Sep 17 00:00:00 2001 From: weno7 Date: Thu, 8 Oct 2026 20:30:05 +0900 Subject: [PATCH 1/3] feat(progress): add websocket progress server --- apps/progress/README.md | 49 ++++++++++ apps/progress/package.json | 20 ++++ apps/progress/src/progressStore.ts | 104 +++++++++++++++++++++ apps/progress/src/rabbitConsumer.ts | 81 ++++++++++++++++ apps/progress/src/server.ts | 39 ++++++++ apps/progress/src/types.ts | 71 ++++++++++++++ apps/progress/src/wsServer.ts | 114 +++++++++++++++++++++++ apps/progress/tsconfig.json | 12 +++ pnpm-lock.yaml | 139 +++++++++++++++++++++++----- 9 files changed, 606 insertions(+), 23 deletions(-) create mode 100755 apps/progress/README.md create mode 100755 apps/progress/package.json create mode 100755 apps/progress/src/progressStore.ts create mode 100755 apps/progress/src/rabbitConsumer.ts create mode 100755 apps/progress/src/server.ts create mode 100755 apps/progress/src/types.ts create mode 100755 apps/progress/src/wsServer.ts create mode 100755 apps/progress/tsconfig.json diff --git a/apps/progress/README.md b/apps/progress/README.md new file mode 100755 index 0000000000..c019597e6e --- /dev/null +++ b/apps/progress/README.md @@ -0,0 +1,49 @@ +# progress-server (v2) + +지난 회의에서 정리한 구조("NestJS는 결과를 모아서 DB 저장, Progress Server는 같은 결과를 +RabbitMQ에서 따로 받아서 진행률을 WebSocket으로 push")를 그대로 반영한 스켈레톤입니다. +`npx tsc --noEmit` 통과 확인했고, `db.ts`만 실제 DB/API 연동으로 채우면 바로 돌아갑니다. + +## 핵심 알고리즘 (src/progressStore.ts) + +1. RabbitMQ에서 TC 결과 메시지가 온다 (`submissionId` + `testcaseId` + 결과가 항상 같이 옴 — + codedang 코드로 확인됨, 그래서 "SubmissionId 못 받아서 임시 저장" 로직은 없음). +2. 해당 submissionId의 상태가 메모리에 없으면, 총 TC 개수를 처음 한 번만 조회해서 상태를 만든다. +3. 같은 testcaseId가 이미 처리됐으면 무시한다 (RabbitMQ는 최소 1번 이상 전달을 보장하는 방식이라 + 중복 수신 가능 → dedupe 필수). +4. 새 testcaseId면 완료 집합에 추가하고 `완료 개수 / 전체 개수`로 퍼센트를 계산한다. +5. judgeResult가 없는 메시지(컴파일 에러/서버 에러)는 나머지 TC를 기다리지 않고 즉시 종료 처리한다. +6. 계산된 진행상황을 WebSocket 쪽 broadcast 함수로 넘긴다. +7. 채점이 끝나면(done/error) 약간의 유예 후 메모리에서 상태를 정리한다 (뒤늦게 오는 중복 메시지 대응). + +## 파일 구성 + +- `src/types.ts` — Iris 메시지 스펙(JudgerResultMessage) 및 내부 상태 타입 +- `src/progressStore.ts` — **알고리즘 핵심.** submission별 진행상황 관리 +- `src/rabbitConsumer.ts` — `iris.e.direct.judge` exchange에 Progress Server 전용 큐를 + bind해서 TC 결과를 구독 (NestJS의 `iris.q.judge.result`와 별개 큐 — 서로 경쟁하지 않고 + 각자 전체 메시지를 받음) +- `src/wsServer.ts` — WebSocket 연결/구독 관리, submission별 broadcast +- `src/db.ts` — 총 TC 개수 조회 (지금은 자리표시자, 실제 연동 필요) +- `src/server.ts` — 위 모듈들을 엮어서 실행 + +## 실행 + +``` +npm install +AMQP_URL=amqp:// npm run dev +``` + +## 아직 팀이랑 정해야 하는 것 (코드만으로는 답이 안 나오는 부분) + +1. **RabbitMQ 접속 정보** — Progress Server가 `iris.e.direct.judge` exchange에 자체 큐를 + bind하는 방식으로 가도 되는지, host/vhost/계정 정보를 받을 수 있는지. +2. **총 TC 개수 조회 방법** (`db.ts`) — codedang DB에 read-only로 직접 붙을지, 아니면 + NestJS가 내부 API를 하나 열어줄지. 전자가 더 간단하지만 DB 스키마/커넥션 정보 공유가 필요함. +3. **WebSocket 구독 프로토콜** — 지금은 `{ type: "subscribe", submissionId }`로 임의로 + 정했는데, 프론트엔드(정빈님/다른 팀원)와 실제 메시지 포맷을 맞춰야 함. +4. **Progress Server를 나중에 여러 대로 늘릴 때** — 지금 구현은 메모리(Map)에 상태를 + 들고 있어서 인스턴스가 1대일 때만 정확하게 동작함. 여러 대로 늘리면 "RabbitMQ 메시지는 + 인스턴스 A가 받고, 그 유저의 WebSocket은 인스턴스 B에 연결된" 상황이 생겨서 진행률이 + 전달되지 않을 수 있음. 지금 범위 밖이지만, 나중에 필요해지면 Redis pub/sub 같은 것으로 + 인스턴스 간에 진행상황을 공유하는 방식을 고려해야 함. diff --git a/apps/progress/package.json b/apps/progress/package.json new file mode 100755 index 0000000000..de131ae1ec --- /dev/null +++ b/apps/progress/package.json @@ -0,0 +1,20 @@ +{ + "name": "@codedang/progress", + "version": "0.0.1", + "private": true, + "scripts": { + "dev": "ts-node-dev --respawn src/server.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "amqplib": "^0.10.4", + "ws": "^8.18.0" + }, + "devDependencies": { + "@types/amqplib": "^0.10.5", + "@types/node": "^20.14.0", + "@types/ws": "^8.5.10", + "ts-node-dev": "^2.0.0", + "typescript": "^5.5.0" + } +} diff --git a/apps/progress/src/progressStore.ts b/apps/progress/src/progressStore.ts new file mode 100755 index 0000000000..a29d4ee0d3 --- /dev/null +++ b/apps/progress/src/progressStore.ts @@ -0,0 +1,104 @@ +import { + SandboxStatusCode, + type ProgressEvent, + type ProgressStatus, + type ProgressUpdateMessage +} from './types' + +interface SubmissionState { + submissionId: number + total: number + /** 재전송 메시지 중복 집계 방지용 */ + seenCurrents: Set + lastStatusCode: SandboxStatusCode + status: ProgressStatus + updatedAt: number +} + +// 끝난 직후 구독한 클라이언트도 최종 상태를 받을 수 있도록 잠시 보관 +const FINISHED_TTL_MS = 60_000 +// 메시지가 끊긴 제출(Iris 장애, 채점 중단 등) 정리용 +const IDLE_TTL_MS = 5 * 60_000 + +export class ProgressStore { + private readonly states = new Map() + private readonly timers = new Map() + + /** 무시한 메시지(중복, 이미 끝난 제출)면 null을 반환한다. */ + handleProgress(event: ProgressEvent): ProgressUpdateMessage | null { + const state = this.states.get(event.submissionId) ?? this.createState(event) + + if (state.status !== 'judging' || state.seenCurrents.has(event.current)) { + return null + } + + state.seenCurrents.add(event.current) + state.total = event.total + state.lastStatusCode = event.statusCode + state.status = this.resolveStatus(state) + state.updatedAt = Date.now() + + this.states.set(state.submissionId, state) + this.scheduleCleanup( + state.submissionId, + state.status === 'judging' ? IDLE_TTL_MS : FINISHED_TTL_MS + ) + + return this.toMessage(state) + } + + getSnapshot(submissionId: number): ProgressUpdateMessage | null { + const state = this.states.get(submissionId) + return state ? this.toMessage(state) : null + } + + private resolveStatus(state: SubmissionState): ProgressStatus { + // 컴파일 에러·서버 에러는 이후 테스트케이스가 실행되지 않는다. + if ( + state.lastStatusCode === SandboxStatusCode.COMPILE_ERROR || + state.lastStatusCode === SandboxStatusCode.SERVER_ERROR + ) { + return 'error' + } + // current의 시작값(0 or 1)과 무관하도록 받은 개수로 판단 + if (state.seenCurrents.size >= state.total) { + return 'done' + } + return 'judging' + } + + private toMessage(state: SubmissionState): ProgressUpdateMessage { + const completed = Math.min(state.seenCurrents.size, state.total) + return { + type: 'progress', + submissionId: state.submissionId, + status: state.status, + completed, + total: state.total, + percentage: Math.floor((completed / state.total) * 100), + lastStatusCode: state.lastStatusCode, + updatedAt: state.updatedAt + } + } + + private createState(event: ProgressEvent): SubmissionState { + return { + submissionId: event.submissionId, + total: event.total, + seenCurrents: new Set(), + lastStatusCode: event.statusCode, + status: 'judging', + updatedAt: Date.now() + } + } + + private scheduleCleanup(submissionId: number, delayMs: number) { + clearTimeout(this.timers.get(submissionId)) + const timer = setTimeout(() => { + this.states.delete(submissionId) + this.timers.delete(submissionId) + }, delayMs) + timer.unref() + this.timers.set(submissionId, timer) + } +} diff --git a/apps/progress/src/rabbitConsumer.ts b/apps/progress/src/rabbitConsumer.ts new file mode 100755 index 0000000000..71eae451f6 --- /dev/null +++ b/apps/progress/src/rabbitConsumer.ts @@ -0,0 +1,81 @@ +import amqp from 'amqplib' +import { isIrisProgressPayload, type ProgressEvent } from './types' + +// NestJS 백엔드와 동일한 exchange/routing key (apps/backend/libs/constants/src/rabbitmq.constants.ts) +const EXCHANGE = 'iris.e.direct.judge' +const ROUTING_KEY = 'judge.result' +// 백엔드 큐와 이름이 달라야 같은 메시지의 복사본을 각각 받는다. +const QUEUE = 'progress.q.judge.result' + +const PROGRESS_MESSAGE_TYPE = 'progress' + +// 서버가 꺼져 있던 동안 쌓인 오래된 진행률은 버린다. +const MESSAGE_TTL_MS = 60_000 + +function toProgressEvent(raw: amqp.ConsumeMessage): ProgressEvent | null { + // submission id는 body가 아니라 messageId에 문자열로 온다. + const submissionId = Number(raw.properties.messageId) + if (!Number.isInteger(submissionId) || submissionId <= 0) { + return null + } + + const payload: unknown = JSON.parse(raw.content.toString()) + if (!isIrisProgressPayload(payload)) { + return null + } + + return { submissionId, ...payload } +} + +export async function startProgressConsumer( + amqpUrl: string, + onProgress: (event: ProgressEvent) => void +) { + const connection = await amqp.connect(amqpUrl) + const channel = await connection.createChannel() + + await channel.assertExchange(EXCHANGE, 'direct', { durable: true }) + await channel.assertQueue(QUEUE, { + durable: true, + messageTtl: MESSAGE_TTL_MS + }) + await channel.bindQueue(QUEUE, EXCHANGE, ROUTING_KEY) + await channel.prefetch(20) + + await channel.consume(QUEUE, (raw) => { + if (!raw) { + return + } + + if (raw.properties.type !== PROGRESS_MESSAGE_TYPE) { + channel.ack(raw) + return + } + + try { + const event = toProgressEvent(raw) + if (!event) { + console.warn( + '[progress-server] invalid progress message', + raw.properties.messageId, + raw.content.toString() + ) + channel.nack(raw, false, false) + return + } + + onProgress(event) + channel.ack(raw) + } catch (e) { + console.error('[progress-server] failed to process message', e) + // 재시도해도 같은 에러라 requeue하지 않는다. + channel.nack(raw, false, false) + } + }) + + console.log( + `[progress-server] consuming ${QUEUE} bound to ${EXCHANGE}/${ROUTING_KEY}` + ) + + return { connection, channel } +} diff --git a/apps/progress/src/server.ts b/apps/progress/src/server.ts new file mode 100755 index 0000000000..ee678089e2 --- /dev/null +++ b/apps/progress/src/server.ts @@ -0,0 +1,39 @@ +import { ProgressStore } from './progressStore' +import { startProgressConsumer } from './rabbitConsumer' +import { startWebSocketServer } from './wsServer' + +// 백엔드(libs/amqp/src/amqp.module.ts)와 같은 환경변수를 쓴다. +// 기본값은 루트 docker-compose.yml의 로컬 RabbitMQ 설정이다. +function getAmqpUrl() { + const protocol = process.env.RABBITMQ_SSL === 'true' ? 'amqps' : 'amqp' + const user = process.env.RABBITMQ_DEFAULT_USER ?? 'skku' + const pass = process.env.RABBITMQ_DEFAULT_PASS ?? '1234' + const host = process.env.RABBITMQ_HOST ?? 'localhost' + const port = process.env.RABBITMQ_PORT ?? '5672' + const vhost = process.env.RABBITMQ_DEFAULT_VHOST ?? 'vh' + + return `${protocol}://${encodeURIComponent(user)}:${encodeURIComponent(pass)}@${host}:${port}/${encodeURIComponent(vhost)}` +} + +async function main() { + const wsPort = Number(process.env.WS_PORT ?? 8080) + + const store = new ProgressStore() + const { broadcast } = startWebSocketServer(wsPort, store) + + await startProgressConsumer(getAmqpUrl(), (event) => { + const update = store.handleProgress(event) + if (update) { + broadcast(update) + } + }) + + console.log( + `[progress-server] websocket listening on ws://localhost:${wsPort}` + ) +} + +main().catch((e) => { + console.error('[progress-server] fatal error', e) + process.exit(1) +}) diff --git a/apps/progress/src/types.ts b/apps/progress/src/types.ts new file mode 100755 index 0000000000..6da34d212e --- /dev/null +++ b/apps/progress/src/types.ts @@ -0,0 +1,71 @@ +/** + * Iris sandbox 실행 결과 코드 (apps/iris/src/service/sandbox/status_code.go) + * NOTE: RUN_SUCCESS는 정상 종료라는 뜻일 뿐 정답(Accepted)이 아니다. + */ +export const SandboxStatusCode = { + RUN_SUCCESS: 0, + CPU_TIME_LIMIT_EXCEEDED: 1, + REAL_TIME_LIMIT_EXCEEDED: 2, + MEMORY_LIMIT_EXCEEDED: 3, + RUNTIME_ERROR: 4, + COMPILE_ERROR: 5, + SEGMENTATION_FAULT_ERROR: 6, + SERVER_ERROR: 7 +} as const + +export type SandboxStatusCode = + (typeof SandboxStatusCode)[keyof typeof SandboxStatusCode] + +export interface IrisProgressPayload { + total: number + current: number + statusCode: SandboxStatusCode +} + +export interface ProgressEvent extends IrisProgressPayload { + submissionId: number +} + +/** error: 컴파일 에러·서버 에러로 중단 */ +export type ProgressStatus = 'judging' | 'done' | 'error' + +export interface ProgressUpdateMessage { + type: 'progress' + submissionId: number + status: ProgressStatus + completed: number + total: number + percentage: number + lastStatusCode: SandboxStatusCode + updatedAt: number +} + +const STATUS_CODES: readonly number[] = Object.values(SandboxStatusCode) + +function isSandboxStatusCode(value: unknown): value is SandboxStatusCode { + return typeof value === 'number' && STATUS_CODES.includes(value) +} + +export function isIrisProgressPayload( + value: unknown +): value is IrisProgressPayload { + if ( + typeof value !== 'object' || + value === null || + !('total' in value) || + !('current' in value) || + !('statusCode' in value) + ) { + return false + } + const { total, current, statusCode } = value + return ( + typeof total === 'number' && + Number.isInteger(total) && + total > 0 && + typeof current === 'number' && + Number.isInteger(current) && + current >= 0 && + isSandboxStatusCode(statusCode) + ) +} diff --git a/apps/progress/src/wsServer.ts b/apps/progress/src/wsServer.ts new file mode 100755 index 0000000000..c5def33400 --- /dev/null +++ b/apps/progress/src/wsServer.ts @@ -0,0 +1,114 @@ +import { WebSocketServer, WebSocket, type RawData } from 'ws' +import type { ProgressStore } from './progressStore' +import type { ProgressUpdateMessage } from './types' + +interface ClientMessage { + type: 'subscribe' | 'unsubscribe' + submissionId: number +} + +function parseClientMessage(raw: RawData): ClientMessage | null { + let data: unknown + try { + data = JSON.parse(raw.toString()) + } catch { + return null + } + if ( + typeof data !== 'object' || + data === null || + !('type' in data) || + !('submissionId' in data) + ) { + return null + } + const { type, submissionId } = data + if ( + (type !== 'subscribe' && type !== 'unsubscribe') || + typeof submissionId !== 'number' || + !Number.isInteger(submissionId) + ) { + return null + } + return { type, submissionId } +} + +/** + * 클라이언트 → 서버: { type: 'subscribe' | 'unsubscribe', submissionId } + * 서버 → 클라이언트: ProgressUpdateMessage (구독 시 현재 스냅샷을 먼저 보냄) + */ +export function startWebSocketServer(port: number, store: ProgressStore) { + const subscribers = new Map>() + const wss = new WebSocketServer({ port }) + + function addSubscriber(id: number, ws: WebSocket) { + const conns = subscribers.get(id) ?? new Set() + conns.add(ws) + subscribers.set(id, conns) + } + + function removeSubscriber(id: number, ws: WebSocket) { + const conns = subscribers.get(id) + if (!conns) { + return + } + conns.delete(ws) + if (conns.size === 0) { + subscribers.delete(id) + } + } + + wss.on('connection', (ws) => { + const subscribedIds = new Set() + + ws.on('message', (raw) => { + const message = parseClientMessage(raw) + if (!message) { + return + } + + if (message.type === 'unsubscribe') { + subscribedIds.delete(message.submissionId) + removeSubscriber(message.submissionId, ws) + return + } + + const snapshot = store.getSnapshot(message.submissionId) + if (snapshot) { + ws.send(JSON.stringify(snapshot)) + } + // 이미 끝난 채점이면 스냅샷만 보내고 구독은 하지 않는다. + if (snapshot && snapshot.status !== 'judging') { + return + } + subscribedIds.add(message.submissionId) + addSubscriber(message.submissionId, ws) + }) + + ws.on('close', () => { + for (const id of subscribedIds) { + removeSubscriber(id, ws) + } + }) + }) + + function broadcast(update: ProgressUpdateMessage) { + const conns = subscribers.get(update.submissionId) + if (!conns) { + return + } + + const payload = JSON.stringify(update) + for (const ws of conns) { + if (ws.readyState === WebSocket.OPEN) { + ws.send(payload) + } + } + + if (update.status !== 'judging') { + subscribers.delete(update.submissionId) + } + } + + return { wss, broadcast } +} diff --git a/apps/progress/tsconfig.json b/apps/progress/tsconfig.json new file mode 100755 index 0000000000..2bb3ebab91 --- /dev/null +++ b/apps/progress/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "moduleResolution": "node", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist" + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b2dd9d02d9..2a53616d0b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -895,6 +895,31 @@ importers: specifier: ^3.2.4 version: 3.2.4(@types/node@22.17.1)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(msw@2.10.4(@types/node@22.17.1)(typescript@5.9.2))(terser@5.43.1)(yaml@2.8.0) + apps/progress: + dependencies: + amqplib: + specifier: ^0.10.4 + version: 0.10.9 + ws: + specifier: ^8.18.0 + version: 8.18.3 + devDependencies: + '@types/amqplib': + specifier: ^0.10.5 + version: 0.10.7 + '@types/node': + specifier: ^20.14.0 + version: 20.19.43 + '@types/ws': + specifier: ^8.5.10 + version: 8.18.1 + ts-node-dev: + specifier: ^2.0.0 + version: 2.0.0(@swc/core@1.13.3(@swc/helpers@0.5.17))(@types/node@20.19.43)(typescript@5.9.2) + typescript: + specifier: ^5.5.0 + version: 5.9.2 + packages: '@alloc/quick-lru@5.2.0': @@ -5925,6 +5950,9 @@ packages: '@types/node@14.18.63': resolution: {integrity: sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==} + '@types/node@20.19.43': + resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==} + '@types/node@22.17.1': resolution: {integrity: sha512-y3tBaz+rjspDTylNjAX37jEC3TETEFGNJL6uQDxwF9/8GLLIjW1rvVHlynyuUKMnMr1Roq8jOv3vkopBjC4/VA==} @@ -6002,6 +6030,12 @@ packages: '@types/statuses@2.0.6': resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} + '@types/strip-bom@3.0.0': + resolution: {integrity: sha512-xevGOReSYGM7g/kUBZzPqCrR/KYAo+F0yiPc85WFTJa0MSLtyFTVTU6cJu/aV4mid7IffDIWqo69THF2o4JiEQ==} + + '@types/strip-json-comments@0.0.30': + resolution: {integrity: sha512-7NQmHra/JILCd1QqpSzl8+mJRc8ZHz3uDm8YV1Ks9IhK0epEiTw8aIErbvH9PI+6XbqhyIQy3462nEsn7UVzjQ==} + '@types/tough-cookie@4.0.5': resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} @@ -6496,10 +6530,6 @@ packages: peerDependencies: amqplib: '*' - amqplib@0.10.8: - resolution: {integrity: sha512-Tfn1O9sFgAP8DqeMEpt2IacsVTENBpblB3SqLdn0jK2AeX8iyCvbptBc8lyATT9bQ31MsjVwUSQ1g8f4jHOUfw==} - engines: {node: '>=10'} - amqplib@0.10.9: resolution: {integrity: sha512-jwSftI4QjS3mizvnSnOrPGYiUnm1vI2OP1iXeOUz5pb74Ua0nbf6nPyyTzuiCLEE3fMpaJORXh2K/TQ08H5xGA==} engines: {node: '>=10'} @@ -7711,6 +7741,9 @@ packages: duplexer@0.1.2: resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} + dynamic-dedupe@0.3.0: + resolution: {integrity: sha512-ssuANeD+z97meYOqd50e04Ze5qp4bPqo8cCkI4TRjZkzAUgIDTrXV1R8QCdINpiI+hw14+rYazvTRdQrz0/rFQ==} + eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} @@ -12276,6 +12309,17 @@ packages: ts-morph@16.0.0: resolution: {integrity: sha512-jGNF0GVpFj0orFw55LTsQxVYEUOCWBAbR5Ls7fTYE5pQsbW18ssTb/6UXx/GYAEjS+DQTp8VoTw0vqYMiaaQuw==} + ts-node-dev@2.0.0: + resolution: {integrity: sha512-ywMrhCfH6M75yftYvrvNarLEY+SUXtUvU8/0Z6llrHQVBx12GiFk5sStF8UdfE/yfzk9IAq7O5EEbTQsxlBI8w==} + engines: {node: '>=0.8.0'} + hasBin: true + peerDependencies: + node-notifier: '*' + typescript: '*' + peerDependenciesMeta: + node-notifier: + optional: true + ts-node@10.9.2: resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} hasBin: true @@ -12301,6 +12345,9 @@ packages: resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} engines: {node: '>=6'} + tsconfig@7.0.0: + resolution: {integrity: sha512-vZXmzPrL+EmC4T/4rVlT2jNVMWCi/O4DIiSj3UHg1OE5kCKbk4mfrXc6dZksLgRM/TZlKnousKH9bbTazUWRRw==} + tslib@2.6.3: resolution: {integrity: sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==} @@ -15529,8 +15576,8 @@ snapshots: '@golevelup/nestjs-discovery': 4.0.3(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6) '@nestjs/common': 11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2) - amqp-connection-manager: 4.1.14(amqplib@0.10.8) - amqplib: 0.10.8 + amqp-connection-manager: 4.1.14(amqplib@0.10.9) + amqplib: 0.10.9 lodash: 4.17.21 reflect-metadata: 0.2.2 rxjs: 7.8.2 @@ -19185,7 +19232,7 @@ snapshots: '@types/amqplib@0.10.7': dependencies: - '@types/node': 22.17.1 + '@types/node': 20.19.43 '@types/apollo-upload-client@18.0.0(@types/react@19.1.10)(graphql-ws@6.0.6(graphql@16.11.0)(ws@8.18.3))(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(subscriptions-transport-ws@0.11.0(graphql@16.11.0))': dependencies: @@ -19422,6 +19469,10 @@ snapshots: '@types/node@14.18.63': {} + '@types/node@20.19.43': + dependencies: + undici-types: 6.21.0 + '@types/node@22.17.1': dependencies: undici-types: 6.21.0 @@ -19511,6 +19562,10 @@ snapshots: '@types/statuses@2.0.6': {} + '@types/strip-bom@3.0.0': {} + + '@types/strip-json-comments@0.0.30': {} + '@types/tough-cookie@4.0.5': {} '@types/trusted-types@2.0.7': {} @@ -19529,7 +19584,7 @@ snapshots: '@types/ws@8.18.1': dependencies: - '@types/node': 22.17.1 + '@types/node': 20.19.43 '@typescript-eslint/eslint-plugin@8.39.0(@typescript-eslint/parser@8.39.0(eslint@9.33.0(jiti@2.4.2))(typescript@5.9.2))(eslint@9.33.0(jiti@2.4.2))(typescript@5.9.2)': dependencies: @@ -20096,16 +20151,11 @@ snapshots: estraverse: 1.9.3 optional: true - amqp-connection-manager@4.1.14(amqplib@0.10.8): + amqp-connection-manager@4.1.14(amqplib@0.10.9): dependencies: - amqplib: 0.10.8 + amqplib: 0.10.9 promise-breaker: 6.0.0 - amqplib@0.10.8: - dependencies: - buffer-more-ints: 1.0.0 - url-parse: 1.5.10 - amqplib@0.10.9: dependencies: buffer-more-ints: 1.0.0 @@ -20135,7 +20185,6 @@ snapshots: dependencies: normalize-path: 3.0.0 picomatch: 2.3.1 - optional: true apollo-upload-client@18.0.1(@apollo/client@3.13.9(@types/react@19.1.10)(graphql-ws@6.0.6(graphql@16.11.0)(ws@8.18.3))(graphql@16.11.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(subscriptions-transport-ws@0.11.0(graphql@16.11.0)))(graphql@16.11.0): dependencies: @@ -20423,8 +20472,7 @@ snapshots: big-integer@1.6.52: {} - binary-extensions@2.3.0: - optional: true + binary-extensions@2.3.0: {} binary-version-check@6.1.0: dependencies: @@ -20767,7 +20815,6 @@ snapshots: readdirp: 3.6.0 optionalDependencies: fsevents: 2.3.3 - optional: true chokidar@4.0.3: dependencies: @@ -21454,6 +21501,10 @@ snapshots: duplexer@0.1.2: {} + dynamic-dedupe@0.3.0: + dependencies: + xtend: 4.0.2 + eastasianwidth@0.2.0: {} ecdsa-sig-formatter@1.0.11: @@ -22954,7 +23005,6 @@ snapshots: is-binary-path@2.1.0: dependencies: binary-extensions: 2.3.0 - optional: true is-boolean-object@1.2.2: dependencies: @@ -25663,7 +25713,6 @@ snapshots: readdirp@3.6.0: dependencies: picomatch: 2.3.1 - optional: true readdirp@4.1.2: {} @@ -26443,8 +26492,7 @@ snapshots: strip-final-newline@3.0.0: {} - strip-json-comments@2.0.1: - optional: true + strip-json-comments@2.0.1: {} strip-json-comments@3.1.1: {} @@ -26778,6 +26826,44 @@ snapshots: '@ts-morph/common': 0.17.0 code-block-writer: 11.0.3 + ts-node-dev@2.0.0(@swc/core@1.13.3(@swc/helpers@0.5.17))(@types/node@20.19.43)(typescript@5.9.2): + dependencies: + chokidar: 3.6.0 + dynamic-dedupe: 0.3.0 + minimist: 1.2.8 + mkdirp: 1.0.4 + resolve: 1.22.10 + rimraf: 2.7.1 + source-map-support: 0.5.21 + tree-kill: 1.2.2 + ts-node: 10.9.2(@swc/core@1.13.3(@swc/helpers@0.5.17))(@types/node@20.19.43)(typescript@5.9.2) + tsconfig: 7.0.0 + typescript: 5.9.2 + transitivePeerDependencies: + - '@swc/core' + - '@swc/wasm' + - '@types/node' + + ts-node@10.9.2(@swc/core@1.13.3(@swc/helpers@0.5.17))(@types/node@20.19.43)(typescript@5.9.2): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + '@tsconfig/node10': 1.0.11 + '@tsconfig/node12': 1.0.11 + '@tsconfig/node14': 1.0.3 + '@tsconfig/node16': 1.0.4 + '@types/node': 20.19.43 + acorn: 8.15.0 + acorn-walk: 8.3.4 + arg: 4.1.3 + create-require: 1.1.1 + diff: 4.0.2 + make-error: 1.3.6 + typescript: 5.9.2 + v8-compile-cache-lib: 3.0.1 + yn: 3.1.1 + optionalDependencies: + '@swc/core': 1.13.3(@swc/helpers@0.5.17) + ts-node@10.9.2(@swc/core@1.13.3(@swc/helpers@0.5.17))(@types/node@22.17.1)(typescript@5.9.2): dependencies: '@cspotcode/source-map-support': 0.8.1 @@ -26818,6 +26904,13 @@ snapshots: minimist: 1.2.8 strip-bom: 3.0.0 + tsconfig@7.0.0: + dependencies: + '@types/strip-bom': 3.0.0 + '@types/strip-json-comments': 0.0.30 + strip-bom: 3.0.0 + strip-json-comments: 2.0.1 + tslib@2.6.3: {} tslib@2.8.1: {} From 0b865ca3a405b67bfb8aae8ccad61a7a0d3caa68 Mon Sep 17 00:00:00 2001 From: weno7 Date: Thu, 8 Oct 2026 20:46:09 +0900 Subject: [PATCH 2/3] docs(progress): update readme --- apps/progress/README.md | 131 ++++++++++++++++++++++++++-------------- 1 file changed, 87 insertions(+), 44 deletions(-) diff --git a/apps/progress/README.md b/apps/progress/README.md index c019597e6e..0d1cfe87ab 100755 --- a/apps/progress/README.md +++ b/apps/progress/README.md @@ -1,49 +1,92 @@ -# progress-server (v2) - -지난 회의에서 정리한 구조("NestJS는 결과를 모아서 DB 저장, Progress Server는 같은 결과를 -RabbitMQ에서 따로 받아서 진행률을 WebSocket으로 push")를 그대로 반영한 스켈레톤입니다. -`npx tsc --noEmit` 통과 확인했고, `db.ts`만 실제 DB/API 연동으로 채우면 바로 돌아갑니다. - -## 핵심 알고리즘 (src/progressStore.ts) - -1. RabbitMQ에서 TC 결과 메시지가 온다 (`submissionId` + `testcaseId` + 결과가 항상 같이 옴 — - codedang 코드로 확인됨, 그래서 "SubmissionId 못 받아서 임시 저장" 로직은 없음). -2. 해당 submissionId의 상태가 메모리에 없으면, 총 TC 개수를 처음 한 번만 조회해서 상태를 만든다. -3. 같은 testcaseId가 이미 처리됐으면 무시한다 (RabbitMQ는 최소 1번 이상 전달을 보장하는 방식이라 - 중복 수신 가능 → dedupe 필수). -4. 새 testcaseId면 완료 집합에 추가하고 `완료 개수 / 전체 개수`로 퍼센트를 계산한다. -5. judgeResult가 없는 메시지(컴파일 에러/서버 에러)는 나머지 TC를 기다리지 않고 즉시 종료 처리한다. -6. 계산된 진행상황을 WebSocket 쪽 broadcast 함수로 넘긴다. -7. 채점이 끝나면(done/error) 약간의 유예 후 메모리에서 상태를 정리한다 (뒤늦게 오는 중복 메시지 대응). - -## 파일 구성 - -- `src/types.ts` — Iris 메시지 스펙(JudgerResultMessage) 및 내부 상태 타입 -- `src/progressStore.ts` — **알고리즘 핵심.** submission별 진행상황 관리 -- `src/rabbitConsumer.ts` — `iris.e.direct.judge` exchange에 Progress Server 전용 큐를 - bind해서 TC 결과를 구독 (NestJS의 `iris.q.judge.result`와 별개 큐 — 서로 경쟁하지 않고 - 각자 전체 메시지를 받음) -- `src/wsServer.ts` — WebSocket 연결/구독 관리, submission별 broadcast -- `src/db.ts` — 총 TC 개수 조회 (지금은 자리표시자, 실제 연동 필요) -- `src/server.ts` — 위 모듈들을 엮어서 실행 +# Progress Server -## 실행 +Iris가 테스트케이스를 하나 채점할 때마다 보내는 `progress` 메시지를 받아, +채점 진행률을 WebSocket으로 프론트에 실시간 push하는 서버입니다. ``` -npm install -AMQP_URL=amqp:// npm run dev +Iris ──(RabbitMQ)──▶ Progress Server ──(WebSocket)──▶ Frontend +``` + +## 동작 방식 + +1. `iris.e.direct.judge` exchange(`judge.result` routing key)에 전용 큐 + `progress.q.judge.result`를 bind합니다. + 백엔드 큐(`iris.q.judge.result`)와 이름이 달라서, 같은 메시지의 복사본을 각각 받습니다. +2. `properties.type`이 `progress`인 메시지만 처리하고, 나머지 타입은 ack 후 무시합니다. +3. 제출별로 받은 `current`를 기록해 `완료 개수 / total`로 진행률을 계산합니다. + 같은 `current`가 다시 오면 무시합니다(중복 집계 방지). +4. 계산 결과를 해당 제출을 구독 중인 WebSocket 클라이언트에게 보냅니다. +5. 모든 테스트케이스를 받으면 `done`, 컴파일 에러·서버 에러면 `error`로 끝납니다. + +## Iris 메시지 스펙 + +| 위치 | 필드 | 설명 | +| ---------- | ------------ | -------------------------------- | +| properties | `type` | `progress` | +| properties | `message_id` | submission id | +| body | `total` | 테스트케이스 개수 | +| body | `current` | 현재 테스트케이스 순서 | +| body | `statusCode` | sandbox 실행 결과 코드 (아래 표) | + +| 코드 | 상태 | +| ---- | ------------------------ | +| 0 | RUN_SUCCESS | +| 1 | CPU_TIME_LIMIT_EXCEEDED | +| 2 | REAL_TIME_LIMIT_EXCEEDED | +| 3 | MEMORY_LIMIT_EXCEEDED | +| 4 | RUNTIME_ERROR | +| 5 | COMPILE_ERROR | +| 6 | SEGMENTATION_FAULT_ERROR | +| 7 | SERVER_ERROR | + +> `RUN_SUCCESS`는 프로세스가 정상 종료됐다는 뜻일 뿐 정답(Accepted)이 아닙니다. +> 정답/오답 판정은 기존처럼 `GET submission/{id}`로 확인합니다. + +## WebSocket 프로토콜 + +클라이언트 → 서버 + +```json +{ "type": "subscribe", "submissionId": 123 } +{ "type": "unsubscribe", "submissionId": 123 } ``` -## 아직 팀이랑 정해야 하는 것 (코드만으로는 답이 안 나오는 부분) - -1. **RabbitMQ 접속 정보** — Progress Server가 `iris.e.direct.judge` exchange에 자체 큐를 - bind하는 방식으로 가도 되는지, host/vhost/계정 정보를 받을 수 있는지. -2. **총 TC 개수 조회 방법** (`db.ts`) — codedang DB에 read-only로 직접 붙을지, 아니면 - NestJS가 내부 API를 하나 열어줄지. 전자가 더 간단하지만 DB 스키마/커넥션 정보 공유가 필요함. -3. **WebSocket 구독 프로토콜** — 지금은 `{ type: "subscribe", submissionId }`로 임의로 - 정했는데, 프론트엔드(정빈님/다른 팀원)와 실제 메시지 포맷을 맞춰야 함. -4. **Progress Server를 나중에 여러 대로 늘릴 때** — 지금 구현은 메모리(Map)에 상태를 - 들고 있어서 인스턴스가 1대일 때만 정확하게 동작함. 여러 대로 늘리면 "RabbitMQ 메시지는 - 인스턴스 A가 받고, 그 유저의 WebSocket은 인스턴스 B에 연결된" 상황이 생겨서 진행률이 - 전달되지 않을 수 있음. 지금 범위 밖이지만, 나중에 필요해지면 Redis pub/sub 같은 것으로 - 인스턴스 간에 진행상황을 공유하는 방식을 고려해야 함. +서버 → 클라이언트 + +```json +{ + "type": "progress", + "submissionId": 123, + "status": "judging", + "completed": 2, + "total": 5, + "percentage": 40, + "lastStatusCode": 0, + "updatedAt": 1791425556973 +} +``` + +- `status`: `judging` | `done` | `error` +- 구독 시점에 이미 진행 중이거나 막 끝난 채점이면 현재 상태를 즉시 한 번 보냅니다. + +## 실행 + +루트에서 RabbitMQ를 띄운 뒤 실행합니다. + +```bash +docker compose up -d rabbitmq +pnpm --filter @codedang/progress dev +``` + +환경변수는 백엔드와 같은 이름을 쓰며, 값이 없으면 루트 `docker-compose.yml`의 로컬 설정을 사용합니다. + +| 변수 | 기본값 | +| ------------------------ | ----------- | +| `WS_PORT` | `8080` | +| `RABBITMQ_HOST` | `localhost` | +| `RABBITMQ_PORT` | `5672` | +| `RABBITMQ_DEFAULT_USER` | `skku` | +| `RABBITMQ_DEFAULT_PASS` | `1234` | +| `RABBITMQ_DEFAULT_VHOST` | `vh` | +| `RABBITMQ_SSL` | `false` | +. \ No newline at end of file From 386606c83e84139e4d9a27983dacfea732cd62ff Mon Sep 17 00:00:00 2001 From: weno7 Date: Thu, 8 Oct 2026 20:48:16 +0900 Subject: [PATCH 3/3] chore(progress): remove executable permission --- apps/progress/README.md | 0 apps/progress/package.json | 0 apps/progress/src/progressStore.ts | 0 apps/progress/src/rabbitConsumer.ts | 0 apps/progress/src/server.ts | 0 apps/progress/src/types.ts | 0 apps/progress/src/wsServer.ts | 0 apps/progress/tsconfig.json | 0 8 files changed, 0 insertions(+), 0 deletions(-) mode change 100755 => 100644 apps/progress/README.md mode change 100755 => 100644 apps/progress/package.json mode change 100755 => 100644 apps/progress/src/progressStore.ts mode change 100755 => 100644 apps/progress/src/rabbitConsumer.ts mode change 100755 => 100644 apps/progress/src/server.ts mode change 100755 => 100644 apps/progress/src/types.ts mode change 100755 => 100644 apps/progress/src/wsServer.ts mode change 100755 => 100644 apps/progress/tsconfig.json diff --git a/apps/progress/README.md b/apps/progress/README.md old mode 100755 new mode 100644 diff --git a/apps/progress/package.json b/apps/progress/package.json old mode 100755 new mode 100644 diff --git a/apps/progress/src/progressStore.ts b/apps/progress/src/progressStore.ts old mode 100755 new mode 100644 diff --git a/apps/progress/src/rabbitConsumer.ts b/apps/progress/src/rabbitConsumer.ts old mode 100755 new mode 100644 diff --git a/apps/progress/src/server.ts b/apps/progress/src/server.ts old mode 100755 new mode 100644 diff --git a/apps/progress/src/types.ts b/apps/progress/src/types.ts old mode 100755 new mode 100644 diff --git a/apps/progress/src/wsServer.ts b/apps/progress/src/wsServer.ts old mode 100755 new mode 100644 diff --git a/apps/progress/tsconfig.json b/apps/progress/tsconfig.json old mode 100755 new mode 100644