fix: 턴 저장 트랜잭션의 연결 획득 대기를 보완한다

This commit is contained in:
Hide_D committed 2026-10-26 17:57:58 +09:00
1 parent 8c877b4a6f
commit 6a23e4ca08
4 files changed
+120 -3

No files matched your search

+8 -1
View File
@@ -1079,6 +1079,7 @@ export const createDatabaseTurnHooks = async (
profileName?: string; profileName?: string;
reservedTurns?: InMemoryReservedTurnStore; reservedTurns?: InMemoryReservedTurnStore;
turnDaemonLease?: DatabaseTurnDaemonLease; turnDaemonLease?: DatabaseTurnDaemonLease;
transactionMaxWaitMs?: number;
transactionTimeoutMs?: number; transactionTimeoutMs?: number;
} }
): Promise<DatabaseTurnHooks> => { ): Promise<DatabaseTurnHooks> => {
@@ -1089,7 +1090,13 @@ export const createDatabaseTurnHooks = async (
// Prisma's 5-second default matches the normal turn execution budget too // Prisma's 5-second default matches the normal turn execution budget too
// closely. A populated season can finish the turn but expire while flushing // closely. A populated season can finish the turn but expire while flushing
// it, which rolls the transaction back and marks the profile PAUSED. // 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); const readModelBaseline = createRealtimeReadModelBaseline(world);
let worldReadModelBaseline = createWorldReadModelSignature(world); let worldReadModelBaseline = createWorldReadModelSignature(world);
let persistedTickSeconds = world.getState().tickSeconds; let persistedTickSeconds = world.getState().tickSeconds;
+2
View File
@@ -139,6 +139,7 @@ export interface TurnDaemonRuntimeOptions {
* loaded once and no concurrent API writer may touch this database. * loaded once and no concurrent API writer may touch this database.
*/ */
exclusiveFastForward?: boolean; exclusiveFastForward?: boolean;
databaseTransactionMaxWaitMs?: number;
databaseTransactionTimeoutMs?: number; databaseTransactionTimeoutMs?: number;
onActionResolved?: NonNullable<Parameters<typeof createReservedTurnHandler>[0]['onActionResolved']>; onActionResolved?: NonNullable<Parameters<typeof createReservedTurnHandler>[0]['onActionResolved']>;
} }
@@ -933,6 +934,7 @@ const createTurnDaemonRuntimeWithLease = async (
profileName: options.profileName ?? options.profile, profileName: options.profileName ?? options.profile,
reservedTurns: reservedTurnStoreHandle?.store, reservedTurns: reservedTurnStoreHandle?.store,
turnDaemonLease: turnDaemonLease ?? undefined, turnDaemonLease: turnDaemonLease ?? undefined,
transactionMaxWaitMs: options.databaseTransactionMaxWaitMs,
transactionTimeoutMs: options.databaseTransactionTimeoutMs, transactionTimeoutMs: options.databaseTransactionTimeoutMs,
}); });
try { try {
@@ -135,3 +135,106 @@ integration('turn failure personal-record persistence', () => {
]); ]);
}); });
}); });
integration('turn persistence connection acquisition', () => {
let connector: ReturnType<typeof createGamePostgresConnector>;
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<void>((resolve) => {
release = resolve;
});
const started: Promise<void>[] = [];
const blockers = Array.from({ length: connector.getPoolStats().max }, () => {
let markStarted!: () => void;
started.push(new Promise<void>((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);
});
+7 -2
View File
@@ -363,7 +363,13 @@ Hyper-V의 VM 정지/재개, 체크포인트 또는 host 시간 보정은 게스
NTP 로그와 Windows Hyper-V/백업 작업 이력을 대조합니다. lease 기간을 늘리거나 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가 경계에서 budget과 같은 Prisma 기본 5초를 그대로 쓰면 populated season의 flush가 경계에서
rollback되고 profile이 `PAUSED`로 전환될 수 있습니다. timeout을 늘려도 한 rollback되고 profile이 `PAUSED`로 전환될 수 있습니다. timeout을 늘려도 한
transaction의 계산·RNG·write 순서는 바뀌지 않지만 lock 보유 상한도 함께 늘어나므로 transaction의 계산·RNG·write 순서는 바뀌지 않지만 lock 보유 상한도 함께 늘어나므로
@@ -751,7 +757,6 @@ NDJSON frame 512 KiB/stream 64 MiB, 느린 소비자의 write buffer 1 MiB를
release를 보존합니다. 외부 공개 stack과 임시 localhost stack의 token 파일은 release를 보존합니다. 외부 공개 stack과 임시 localhost stack의 token 파일은
공유하지 않습니다. 임시 host의 절차는 상위 local-runtime README를 따릅니다. 공유하지 않습니다. 임시 host의 절차는 상위 local-runtime README를 따릅니다.
### 이미지 업로드와 WebPush 전송 경계 ### 이미지 업로드와 WebPush 전송 경계
게시판 이미지의 기존 1 MiB 입력·2048px 변환 정책, AVIF 원본 보존과 WebP 변환, 게시판 이미지의 기존 1 MiB 입력·2048px 변환 정책, AVIF 원본 보존과 WebP 변환,