diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index 62c740b9..adc06520 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -1079,6 +1079,7 @@ export const createDatabaseTurnHooks = async ( profileName?: string; reservedTurns?: InMemoryReservedTurnStore; turnDaemonLease?: DatabaseTurnDaemonLease; + transactionMaxWaitMs?: number; transactionTimeoutMs?: number; } ): Promise => { @@ -1089,7 +1090,13 @@ export const createDatabaseTurnHooks = async ( // Prisma's 5-second default matches the normal turn execution budget too // closely. A populated season can finish the turn but expire while flushing // it, which rolls the transaction back and marks the profile PAUSED. - const transactionOptions = { timeout: options?.transactionTimeoutMs ?? 30_000 }; + // 실행 timeout과 연결 획득 대기는 별도 제한이다. 기본 maxWait 2초는 + // 일시적인 DB 지연에도 callback 시작 전에 턴을 영구 PAUSED로 만들었다. + // 대기를 유한하게 늘리되 callback 재실행이나 lease/fencing 우회는 하지 않는다. + const transactionOptions = { + maxWait: options?.transactionMaxWaitMs ?? 10_000, + timeout: options?.transactionTimeoutMs ?? 30_000, + }; const readModelBaseline = createRealtimeReadModelBaseline(world); let worldReadModelBaseline = createWorldReadModelSignature(world); let persistedTickSeconds = world.getState().tickSeconds; diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts index d750adc0..f8324aa2 100644 --- a/app/game-engine/src/turn/turnDaemon.ts +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -139,6 +139,7 @@ export interface TurnDaemonRuntimeOptions { * loaded once and no concurrent API writer may touch this database. */ exclusiveFastForward?: boolean; + databaseTransactionMaxWaitMs?: number; databaseTransactionTimeoutMs?: number; onActionResolved?: NonNullable[0]['onActionResolved']>; } @@ -933,6 +934,7 @@ const createTurnDaemonRuntimeWithLease = async ( profileName: options.profileName ?? options.profile, reservedTurns: reservedTurnStoreHandle?.store, turnDaemonLease: turnDaemonLease ?? undefined, + transactionMaxWaitMs: options.databaseTransactionMaxWaitMs, transactionTimeoutMs: options.databaseTransactionTimeoutMs, }); try { diff --git a/app/game-engine/test/turnFailureLogPersistence.integration.test.ts b/app/game-engine/test/turnFailureLogPersistence.integration.test.ts index 6f3bada5..b0d2d614 100644 --- a/app/game-engine/test/turnFailureLogPersistence.integration.test.ts +++ b/app/game-engine/test/turnFailureLogPersistence.integration.test.ts @@ -135,3 +135,106 @@ integration('turn failure personal-record persistence', () => { ]); }); }); + +integration('turn persistence connection acquisition', () => { + let connector: ReturnType; + let hooks: DatabaseTurnHooks; + const fixtureWorldId = worldId + 10; + + beforeAll(async () => { + connector = createGamePostgresConnector({ url: databaseUrl! }); + await connector.connect(); + await connector.prisma.worldState.create({ + data: { + id: fixtureWorldId, + scenarioCode: 'transaction-acquisition', + currentYear: state.currentYear, + currentMonth: state.currentMonth, + tickSeconds: state.tickSeconds, + clockBaseTime: turnTime, + clockWallAnchor: turnTime, + clockMode: 'manual', + clockPhase: 'MANUAL', + clockTick: 0n, + lastTurnTick: 0n, + }, + }); + hooks = await createDatabaseTurnHooks( + databaseUrl!, + new InMemoryTurnWorld({ ...state, id: fixtureWorldId }, snapshot, { schedule }), + { transactionTimeoutMs: 1_000 } + ); + }); + + afterAll(async () => { + await hooks?.close(); + await connector?.prisma.worldState.deleteMany({ where: { id: fixtureWorldId } }); + await connector?.disconnect(); + }); + + const occupyPool = async () => { + let release!: () => void; + const held = new Promise((resolve) => { + release = resolve; + }); + const started: Promise[] = []; + const blockers = Array.from({ length: connector.getPoolStats().max }, () => { + let markStarted!: () => void; + started.push(new Promise((resolve) => (markStarted = resolve))); + return connector.prisma.$transaction( + async (tx) => { + await tx.$queryRaw`SELECT 1`; + markStarted(); + await held; + }, + { maxWait: 10_000, timeout: 20_000 } + ); + }); + // Attach handlers immediately so a setup failure cannot leak transactions. + const settled = Promise.allSettled(blockers); + try { + await Promise.all(started); + } catch (error) { + release(); + await settled; + throw error; + } + return { release, settled }; + }; + + it('waits beyond Prisma default without spending the callback execution budget', async () => { + const held = await occupyPool(); + const sync = hooks.synchronizeClockAuthority(); + // Observe the result immediately; the old 2-second default rejects here. + const outcome = sync.then( + (value) => ({ ok: true, value }), + (error: unknown) => ({ ok: false, error }) + ); + try { + const deadline = Date.now() + 1_000; + while (!connector.getPoolStats().waiting && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + expect(connector.getPoolStats().waiting).toBeGreaterThan(0); + await new Promise((resolve) => setTimeout(resolve, 2_500)); + held.release(); + expect(await outcome).toMatchObject({ ok: true }); + } finally { + held.release(); + await held.settled; + await outcome; + } + }, 15_000); + + it('still rejects sustained connection exhaustion at the finite acquisition limit', async () => { + const held = await occupyPool(); + try { + await expect(hooks.synchronizeClockAuthority()).rejects.toMatchObject({ code: 'P2028' }); + } finally { + held.release(); + await held.settled; + } + // Timed-out acquisition must not prevent the next normal operation. + await expect(hooks.synchronizeClockAuthority()).resolves.toBeTypeOf('boolean'); + }, 15_000); +}); diff --git a/docs/release-operations.md b/docs/release-operations.md index fc646075..f5678dcb 100644 --- a/docs/release-operations.md +++ b/docs/release-operations.md @@ -363,7 +363,13 @@ Hyper-V의 VM 정지/재개, 체크포인트 또는 host 시간 보정은 게스 NTP 로그와 Windows Hyper-V/백업 작업 이력을 대조합니다. lease 기간을 늘리거나 시간 동기화를 임의로 끄는 것으로 해결하지 않습니다. -Turn daemon의 DB persistence interactive transaction은 기본 30초입니다. 정상 turn +Turn daemon의 DB persistence interactive transaction은 연결 획득 대기(`maxWait`) +10초와 callback 실행(`timeout`) 30초를 각각 제한합니다. Prisma 기본 연결 대기 +2초는 일시적인 DB 지연에도 callback 시작 전 `P2028`로 서버를 PAUSED로 만들 수 +있습니다. 대기 상한을 넘거나 실행·lease 검증이 실패하면 기존 오류 처리를 유지하며, +callback을 자동 재실행하지 않습니다. 내부 runtime 설정의 +`databaseTransactionMaxWaitMs`와 `databaseTransactionTimeoutMs`로 각각 조정합니다. +정상 turn budget과 같은 Prisma 기본 5초를 그대로 쓰면 populated season의 flush가 경계에서 rollback되고 profile이 `PAUSED`로 전환될 수 있습니다. timeout을 늘려도 한 transaction의 계산·RNG·write 순서는 바뀌지 않지만 lock 보유 상한도 함께 늘어나므로 @@ -751,7 +757,6 @@ NDJSON frame 512 KiB/stream 64 MiB, 느린 소비자의 write buffer 1 MiB를 release를 보존합니다. 외부 공개 stack과 임시 localhost stack의 token 파일은 공유하지 않습니다. 임시 host의 절차는 상위 local-runtime README를 따릅니다. - ### 이미지 업로드와 WebPush 전송 경계 게시판 이미지의 기존 1 MiB 입력·2048px 변환 정책, AVIF 원본 보존과 WebP 변환,