fix: 턴 저장 트랜잭션의 연결 획득 대기를 보완한다
This commit is contained in:
1 parent
8c877b4a6f
commit
6a23e4ca08
4 files changed
+120
-3
No files matched your search
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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 변환,
|
||||||
|
|||||||
Reference in new issue
Block a user