From 34a6be0c44715248fd92330fd52205f87493c5ef Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 16 Aug 2026 06:48:35 +0000 Subject: [PATCH] =?UTF-8?q?fix(game-engine):=20=EB=B0=80=EB=A6=B0=20?= =?UTF-8?q?=ED=84=B4=EC=9D=84=20=EC=9B=94=20=EA=B2=BD=EA=B3=84=EB=B3=84?= =?UTF-8?q?=EB=A1=9C=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 실시간 게임 시계가 여러 달 앞선 경우 장수 턴을 현재 월 경계까지만 실행해 월 표시와 상태 전이 순서를 보존한다. 턴 데몬 전용 NODE_OPTIONS도 지원해 다른 런타임 프로세스와 heap 상한을 분리한다. --- .../src/lifecycle/turnDaemonLifecycle.ts | 14 +++++++++----- .../test/turnDaemonLifecycle.test.ts | 8 ++++---- .../src/orchestrator/gatewayOrchestrator.ts | 2 ++ app/gateway-api/test/orchestratorPlan.test.ts | 17 +++++++++++++++++ docs/release-operations.md | 4 ++++ 5 files changed, 36 insertions(+), 9 deletions(-) diff --git a/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts b/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts index a7e4d5a5..8a9e183a 100644 --- a/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts +++ b/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts @@ -183,11 +183,15 @@ export class TurnDaemonLifecycle { const gameNowMs = gameClock?.now.getTime() ?? nowMs; const nextTurnMs = nextRunTime.getTime(); if (gameNowMs >= nextTurnMs) { - // Ref checkDelay() executes every turn due at the observed - // wall-clock time in one snapshot. Using only the oldest due - // timestamp lets generals created by that batch run before a - // monthly boundary, although Ref defers them to the next pass. - await this.runOnce({ reason: 'schedule', targetTime: new Date(gameNowMs) }); + // Preserve Ref's chronological boundary even when realtime has + // fallen multiple months behind. Draining every general through + // gameNow before advancing the first pending month labels and + // resolves all of those future commands in the stale month and + // can build an unbounded same-month catch-up backlog. Within the current + // month we still observe every due general through gameNow; once + // a month boundary is overdue, process only through that boundary. + const nextMonthMs = this.getNextTickTime(new Date(this.status.lastTurnTime!)).getTime(); + await this.runOnce({ reason: 'schedule', targetTime: new Date(Math.min(gameNowMs, nextMonthMs)) }); continue; } diff --git a/app/game-engine/test/turnDaemonLifecycle.test.ts b/app/game-engine/test/turnDaemonLifecycle.test.ts index afce9318..37151f0f 100644 --- a/app/game-engine/test/turnDaemonLifecycle.test.ts +++ b/app/game-engine/test/turnDaemonLifecycle.test.ts @@ -447,11 +447,11 @@ describe('TurnDaemonLifecycle', () => { await loop; }); - it('catches up through the observed clock with queue and checkpoint context', async () => { + it('limits realtime backlog to the next month with queue and checkpoint context', async () => { const turnTermMinutes = 10; const lastTurnTime = new Date(2026, 0, 2, 2, 0, 0, 0); const generalTurnQueue = [addMinutes(lastTurnTime, 5), addMinutes(lastTurnTime, 20)]; - const expectedRunTimeMs = addMinutes(lastTurnTime, 30).getTime(); + const expectedRunTimeMs = addMinutes(lastTurnTime, turnTermMinutes).getTime(); const checkpoint = { turnTime: lastTurnTime.toISOString(), generalId: 101, @@ -524,11 +524,11 @@ describe('TurnDaemonLifecycle', () => { await loop; }); - it('catches up through the observed clock when tick boundary precedes the queue front', async () => { + it('limits realtime backlog to the next month when the boundary precedes the queue front', async () => { const turnTermMinutes = 10; const lastTurnTime = new Date(2026, 0, 2, 2, 0, 0, 0); const generalTurnQueue = [addMinutes(lastTurnTime, 15), addMinutes(lastTurnTime, 30)]; - const expectedRunTimeMs = addMinutes(lastTurnTime, 30).getTime(); + const expectedRunTimeMs = addMinutes(lastTurnTime, turnTermMinutes).getTime(); const checkpoint = { turnTime: lastTurnTime.toISOString(), generalId: 102, diff --git a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts index 93714c0c..08117632 100644 --- a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts +++ b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts @@ -393,6 +393,7 @@ export const buildProcessDefinitions = ( const daemonCwd = path.join(runtimeWorkspace, 'app', 'game-engine'); const apiScript = path.join(apiCwd, 'dist', 'index.js'); const daemonScript = path.join(daemonCwd, 'dist', 'index.js'); + const turnDaemonNodeOptions = baseEnv.TURN_DAEMON_NODE_OPTIONS?.trim(); const apiEnv = { ...baseEnv, GAME_API_ROLE: 'server', @@ -409,6 +410,7 @@ export const buildProcessDefinitions = ( }; const daemonEnv = { ...baseEnv, + ...(turnDaemonNodeOptions ? { NODE_OPTIONS: turnDaemonNodeOptions } : {}), GAME_ENGINE_ROLE: 'turn-daemon', TURN_PROFILE: profile.profile, PROFILE: profile.profile, diff --git a/app/gateway-api/test/orchestratorPlan.test.ts b/app/gateway-api/test/orchestratorPlan.test.ts index 7685460f..31a934d8 100644 --- a/app/gateway-api/test/orchestratorPlan.test.ts +++ b/app/gateway-api/test/orchestratorPlan.test.ts @@ -233,6 +233,23 @@ describe('buildProcessDefinitions', () => { } expect(definitions.frontend.env.VITE_APP_BASE_PATH).toBe('/che'); }); + + it('applies a dedicated Node heap option only to the turn daemon', () => { + const definitions = buildProcessDefinitions(buildProfile(), { + ...processConfig, + baseEnv: { + NODE_OPTIONS: '--max-old-space-size=1536', + TURN_DAEMON_NODE_OPTIONS: '--max-old-space-size=3072', + }, + }); + + expect(definitions.daemon.env.NODE_OPTIONS).toBe('--max-old-space-size=3072'); + expect(definitions.frontend.env.NODE_OPTIONS).toBe('--max-old-space-size=1536'); + expect(definitions.api.env.NODE_OPTIONS).toBe('--max-old-space-size=1536'); + expect(definitions.auction.env.NODE_OPTIONS).toBe('--max-old-space-size=1536'); + expect(definitions.battleSim.env.NODE_OPTIONS).toBe('--max-old-space-size=1536'); + expect(definitions.tournament.env.NODE_OPTIONS).toBe('--max-old-space-size=1536'); + }); }); describe('sanitizeManagedProcessEnv', () => { diff --git a/docs/release-operations.md b/docs/release-operations.md index d71c09a2..467a54bf 100644 --- a/docs/release-operations.md +++ b/docs/release-operations.md @@ -55,6 +55,10 @@ profile 범위 권한과 별개인 전역 `admin.releases.manage` 권한이 필 API URL이 다른 frontend artifact를 cache hit로 잘못 복원하지 않습니다. `NODE_OPTIONS`와 `RAYON_NUM_THREADS`는 출력에는 영향을 주지 않는 resource 제한으로 build child에 전달됩니다. +- `TURN_DAEMON_NODE_OPTIONS`가 설정되어 있으면 Gateway orchestrator는 그 값을 + turn-daemon PM2 process의 `NODE_OPTIONS`로만 덮어씁니다. API·frontend·worker와 + build는 공용 `NODE_OPTIONS`를 계속 사용합니다. 전용 heap을 늘릴 때는 runtime + container hard limit과 전체 process RSS를 먼저 확인합니다. - migration 이후 이전 애플리케이션으로 돌아갈 때 schema 하위 호환성이 유지됩니다.