diff --git a/apps/backend/.env.example b/apps/backend/.env.example index d5b25d8373..fc3b584f17 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -14,6 +14,16 @@ REDIS_PASSWORD="skku1234" AWS_ACCESS_KEY_ID="skku" AWS_SECRET_ACCESS_KEY="skku1234" +### RabbitMQ judge routing ### +# TEST_KEY and REJUDGE_KEY are intentionally unset by default. They fall back +# to JUDGE_SUBMISSION_ROUTING_KEY, preserving the former single judge request +# queue behavior until the split queue topology and workers are provisioned. +SUBMISSION_KEY="judge.submission" +# TEST_KEY="judge.test" +# REJUDGE_KEY="judge.rejudge" +# Legacy fallback used by deployments that have not configured the keys above. +JUDGE_SUBMISSION_ROUTING_KEY="judge.submission" + ################################################################################ # NOTE: The following variables are sensitive. # Please copy the values from Notion 'Secrets' page, or fill in your own values. diff --git a/apps/backend/apps/client/src/submission/submission-pub.service.ts b/apps/backend/apps/client/src/submission/submission-pub.service.ts index a371191e18..9d474e7b17 100644 --- a/apps/backend/apps/client/src/submission/submission-pub.service.ts +++ b/apps/backend/apps/client/src/submission/submission-pub.service.ts @@ -22,7 +22,7 @@ export class SubmissionPublicationService { * 2. `isUserTest` 플래그에 따라 다음 중 하나의 채점 요청 객체를 생성 * - 사용자 테스트인 경우: `UserTestcaseJudgeRequest` 객체를 생성하며, 사용자 정의 테스트케이스를 포함 * - 아닌 경우: 일반 채점 요청인 `JudgeRequest` 객체를 생성 - * 3. AMQP 프로토콜을 사용하여 지정된 EXCHANGE와 라우팅 키(SUBMISSION_KEY)를 통해 채점 요청 메시지를 발행 + * 3. AMQP 프로토콜을 사용하여 workload에 맞는 routing key로 채점 요청 메시지를 발행 * * @param {Object} params - 채점 요청 파라미터 * @param {Snippet[]} params.code - 제출한 코드 스니펫 배열 diff --git a/apps/backend/libs/amqp/src/amqp.service.spec.ts b/apps/backend/libs/amqp/src/amqp.service.spec.ts new file mode 100644 index 0000000000..40a2cf4103 --- /dev/null +++ b/apps/backend/libs/amqp/src/amqp.service.spec.ts @@ -0,0 +1,106 @@ +import type { ConfigService } from '@nestjs/config' +import type { AmqpConnection } from '@golevelup/nestjs-rabbitmq' +import { expect } from 'chai' +import type { TraceService } from 'nestjs-otel' +import * as sinon from 'sinon' +import { DEFAULT_SUBMISSION_KEY, EXCHANGE } from '@libs/constants' +import { JudgeAMQPService } from './amqp.service' + +type RoutingKeyConfig = Partial< + Record< + | 'SUBMISSION_KEY' + | 'TEST_KEY' + | 'REJUDGE_KEY' + | 'JUDGE_SUBMISSION_ROUTING_KEY', + string + > +> + +describe('JudgeAMQPService', () => { + const sandbox = sinon.createSandbox() + const publish = sandbox.stub().resolves() + const traceService = { + startSpan: sandbox.stub().returns({ + setAttributes: sandbox.stub(), + end: sandbox.stub() + }) + } as unknown as TraceService + + afterEach(() => { + sandbox.resetHistory() + }) + + const createService = function (config: RoutingKeyConfig) { + const configService = { + get: (key: keyof RoutingKeyConfig) => config[key] + } as ConfigService + + return new JudgeAMQPService( + { publish } as unknown as AmqpConnection, + traceService, + configService + ) + } + + const expectRoutingKey = async function ( + service: JudgeAMQPService, + routingKey: string, + isTest = false, + isUserTest = false, + isRejudge = false + ) { + await service.publishJudgeRequestMessage( + { request: routingKey }, + 42, + isTest, + isUserTest, + isRejudge + ) + + expect(publish.calledWith(EXCHANGE, routingKey)).to.be.true + sandbox.resetHistory() + } + + const createRoutingKeyConfig = function ( + entries: [keyof RoutingKeyConfig, string][] + ): RoutingKeyConfig { + return Object.fromEntries(entries) + } + + it('uses workload-specific routing keys when configured', async () => { + const service = createService( + createRoutingKeyConfig([ + ['SUBMISSION_KEY', 'submission.key'], + ['TEST_KEY', 'test.key'], + ['REJUDGE_KEY', 'rejudge.key'] + ]) + ) + + await expectRoutingKey(service, 'submission.key') + await expectRoutingKey(service, 'test.key', true) + await expectRoutingKey(service, 'test.key', false, true) + await expectRoutingKey(service, 'rejudge.key', false, false, true) + }) + + it('falls back to the legacy submission routing key for every workload', async () => { + const service = createService( + createRoutingKeyConfig([ + ['JUDGE_SUBMISSION_ROUTING_KEY', 'legacy.submission.key'] + ]) + ) + + await expectRoutingKey(service, 'legacy.submission.key') + await expectRoutingKey(service, 'legacy.submission.key', true) + await expectRoutingKey(service, 'legacy.submission.key', false, true) + await expectRoutingKey(service, 'legacy.submission.key', false, false, true) + }) + + it('falls back to the built-in submission key when no routing key is configured', async () => { + const service = createService({}) + + await expectRoutingKey(service, DEFAULT_SUBMISSION_KEY) + await expectRoutingKey(service, DEFAULT_SUBMISSION_KEY, true) + await expectRoutingKey(service, DEFAULT_SUBMISSION_KEY, false, true) + await expectRoutingKey(service, DEFAULT_SUBMISSION_KEY, false, false, true) + }) +}) diff --git a/apps/backend/libs/amqp/src/amqp.service.ts b/apps/backend/libs/amqp/src/amqp.service.ts index a557150321..a6528b48b3 100644 --- a/apps/backend/libs/amqp/src/amqp.service.ts +++ b/apps/backend/libs/amqp/src/amqp.service.ts @@ -1,4 +1,5 @@ import { Injectable, Logger } from '@nestjs/common' +import { ConfigService } from '@nestjs/config' import { AmqpConnection, Nack, @@ -22,9 +23,9 @@ import { MESSAGE_PRIORITY_HIGH, MESSAGE_PRIORITY_MIDDLE, MESSAGE_PRIORITY_LOW, - SUBMISSION_KEY, SUBMISSION_MESSAGE_TYPE, - RUN_SUBMISSION_MESSAGE_TYPE + RUN_SUBMISSION_MESSAGE_TYPE, + DEFAULT_SUBMISSION_KEY } from '@libs/constants' @Injectable() @@ -33,7 +34,8 @@ export class JudgeAMQPService { constructor( private readonly amqpConnection: AmqpConnection, - private readonly traceService: TraceService + private readonly traceService: TraceService, + private readonly configService: ConfigService ) {} startSubscription() { @@ -95,12 +97,17 @@ export class JudgeAMQPService { ) span.setAttributes({ submissionId }) - await this.amqpConnection.publish(EXCHANGE, SUBMISSION_KEY, judgeRequest, { - messageId: String(submissionId), - persistent: true, - type: this.calculateMessageType(isTest, isUserTest), - priority: this.calculateMessagePriority(isTest, isUserTest, isRejudge) - }) + await this.amqpConnection.publish( + EXCHANGE, + this.calculateRoutingKey(isTest, isUserTest, isRejudge), + judgeRequest, + { + messageId: String(submissionId), + persistent: true, + type: this.calculateMessageType(isTest, isUserTest), + priority: this.calculateMessagePriority(isTest, isUserTest, isRejudge) + } + ) span.end() } @@ -113,6 +120,29 @@ export class JudgeAMQPService { return JUDGE_MESSAGE_TYPE } + /** + * 채점 workload에 맞는 request queue routing key를 선택합니다. + * 새 workload key가 없는 기존 배포에서는 모든 요청을 submission routing + * key로 보내 단일 queue 동작을 유지합니다. + */ + private calculateRoutingKey( + isTest: boolean, + isUserTest: boolean, + isRejudge: boolean + ) { + if (isRejudge) return this.getRoutingKey('REJUDGE_KEY') + if (isTest || isUserTest) return this.getRoutingKey('TEST_KEY') + return this.getRoutingKey('SUBMISSION_KEY') + } + + private getRoutingKey(key: 'SUBMISSION_KEY' | 'TEST_KEY' | 'REJUDGE_KEY') { + return ( + this.configService.get(key) ?? + this.configService.get('JUDGE_SUBMISSION_ROUTING_KEY') ?? + DEFAULT_SUBMISSION_KEY + ) + } + /** * 메시지 우선순위를 계산하여 반환 */ diff --git a/apps/backend/libs/constants/src/rabbitmq.constants.ts b/apps/backend/libs/constants/src/rabbitmq.constants.ts index 9810b0283a..5ffb50579a 100644 --- a/apps/backend/libs/constants/src/rabbitmq.constants.ts +++ b/apps/backend/libs/constants/src/rabbitmq.constants.ts @@ -3,7 +3,7 @@ export const CONSUME_CHANNEL = 'result-consume-channel' export const EXCHANGE = 'iris.e.direct.judge' -export const SUBMISSION_KEY = 'judge.submission' +export const DEFAULT_SUBMISSION_KEY = 'judge.submission' export const RESULT_KEY = 'judge.result' export const RESULT_QUEUE = 'iris.q.judge.result'