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;
reservedTurns?: InMemoryReservedTurnStore;
turnDaemonLease?: DatabaseTurnDaemonLease;
transactionMaxWaitMs?: number;
transactionTimeoutMs?: number;
}
): Promise<DatabaseTurnHooks> => {
@@ -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;
+2
View File
@@ -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<Parameters<typeof createReservedTurnHandler>[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 {
@@ -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);
});