refactor: 턴 경계와 12턴 묶음을 보존하는 2배속 복구 구현

This commit is contained in:
2026-09-06 10:04:33 +00:00
parent 63da0921cb
commit d3443ec7f5
53 changed files with 1467 additions and 160 deletions
+28
View File
@@ -2285,6 +2285,22 @@ export const adminRouter = router({
message: 'preopenAt and openAt are required for RESERVED status.',
});
}
const current = await ctx.profiles.getProfile(input.profileName);
if (!current) throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found.' });
if (current.currentScenario !== null && current.status !== input.status) {
const clockAction =
input.status === 'RUNNING'
? 'RESUME'
: current.status === 'RUNNING' && (input.status === 'PAUSED' || input.status === 'STOPPED')
? 'SUSPEND'
: null;
if (clockAction)
await ctx.orchestrator.transitionProfileClock(
input.profileName,
clockAction,
'operator setStatus'
);
}
const result = await ctx.profiles.updateStatus(input.profileName, input.status, {
preopenAt: input.preopenAt,
openAt: input.openAt,
@@ -2696,6 +2712,18 @@ export const adminRouter = router({
});
}
if (input.action === 'ACCELERATE' || input.action === 'DELAY') {
const [settings] = (await ctx.orchestrator.listRuntimeSettings?.([profile.profileName])) ?? [];
if (!settings || input.durationMinutes! % settings.turnTermMinutes !== 0) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: settings
? `일정 이동은 현재 턴 길이(${settings.turnTermMinutes}분)의 정수 배로 입력해 주세요.`
: '현재 턴 길이를 확인할 수 없습니다.',
});
}
}
if (
input.action === 'ACCELERATE' ||
input.action === 'DELAY' ||
@@ -1,6 +1,7 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { createHash, randomBytes, randomUUID } from 'node:crypto';
import { stripVTControlCharacters } from 'node:util';
@@ -1237,7 +1238,8 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
if (!profile || profile.currentScenario === null) {
throw new Error(`Profile clock is unavailable: ${profileName}`);
}
const postgres = createGamePostgresConnector({ url: this.resolveProfileDatabaseUrl(profile) });
const { connectorFactory, supportsTurnRecovery } = await this.resolveProfileClockAdapter(profile);
const postgres = connectorFactory({ url: this.resolveProfileDatabaseUrl(profile) });
const redis = createRedisConnector(resolveRedisConfigFromEnv(this.processConfig.baseEnv ?? process.env));
await postgres.connect();
await redis.connect();
@@ -1265,6 +1267,9 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
select: { clockPhase: true, clockRevision: true, deadlineGeneration: true },
});
if (!world) throw new Error(`Profile has no world_state: ${profileName}`);
if (['PREOPEN', 'MANUAL', 'COMPLETED'].includes(world.clockPhase)) {
return { phase: world.clockPhase, revision: clockRevisionAsNumber(world.clockRevision) };
}
if (action === 'SUSPEND') {
let suspension = await postgres.prisma.clockSuspension.findFirst({
@@ -1281,8 +1286,8 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
suspensionId: `gateway-maintenance-${suffix}`,
source: 'MAINTENANCE',
// 운영 중단은 생성 때 구매한 턴 구간과 장수 간 실행 순서를 보존한다.
// 관측 시계만 재개하고 정상 엔진이 미처리 턴을 따라잡게 한다.
policy: 'PRESERVE_SCHEDULE',
// 완전한 12턴은 정수 이동하고 잔여 지연은 복구 구간에서 두 배속으로 실행한다.
policy: supportsTurnRecovery ? 'RECOVER_TURNS' : 'PRESERVE_SCHEDULE',
authority,
});
suspension = await postgres.prisma.clockSuspension.findUniqueOrThrow({
@@ -1326,7 +1331,12 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
return { phase: 'SUSPENDED', revision: clockRevisionAsNumber(world.clockRevision) };
}
if (world.clockPhase === 'SUSPENDED') {
await reconcileClockSuspension({ db: postgres.prisma, suspensionId: suspension.id, authority });
await reconcileClockSuspension({
db: postgres.prisma,
suspensionId: suspension.id,
authority,
upgradeMaintenancePolicy: supportsTurnRecovery,
});
} else if (world.clockPhase !== 'RECONCILING') {
throw new Error(`Cannot resume profile clock from ${world.clockPhase}.`);
}
@@ -1353,7 +1363,8 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
await this.promoteProfileOpeningOverride(profile);
return;
}
const postgres = createGamePostgresConnector({ url: this.resolveProfileDatabaseUrl(profile) });
const { connectorFactory } = await this.resolveProfileClockAdapter(profile);
const postgres = connectorFactory({ url: this.resolveProfileDatabaseUrl(profile) });
const redis = createRedisConnector(resolveRedisConfigFromEnv(this.processConfig.baseEnv ?? process.env));
await postgres.connect();
await redis.connect();
@@ -1695,6 +1706,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
if (!gatewayProfileCapabilities(profile.status).operatorResumable) {
throw new Error(`Profile status ${profile.status} cannot be started by an operator.`);
}
await this.transitionProfileClock(profile.profileName, 'RESUME', operation.reason ?? 'operator START');
await this.appendOperationLog(operation.id, 'runtime', '프로필 process를 시작합니다.');
const updated = await updateOperationProfile(
{
@@ -1735,6 +1747,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
if (!gatewayProfileCapabilities(profile.status).runtimeExpected && profile.status !== 'STOPPED') {
throw new Error(`Profile status ${profile.status} cannot be stopped by an operator.`);
}
await this.transitionProfileClock(profile.profileName, 'SUSPEND', operation.reason ?? 'operator STOP');
await this.appendOperationLog(operation.id, 'runtime', '프로필 process를 정지합니다.');
await updateOperationProfile({ status: 'STOPPED' }, () =>
this.repository.updateStatus(profile.profileName, 'STOPPED')
@@ -1979,14 +1992,19 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
'settlement',
'기수·장수 기록과 유산 포인트를 원자적으로 정산합니다.'
);
const result = await this.cancelGame({
cancellationId: operation.id,
databaseUrl,
cancelledBy: operation.requestedBy,
reason: operation.reason ?? '',
...options,
cancelledAt: this.now(),
});
const result = await this.cancelGame(
{
cancellationId: operation.id,
databaseUrl,
cancelledBy: operation.requestedBy,
reason: operation.reason ?? '',
...options,
cancelledAt: this.now(),
},
this.cancelGame === defaultCancelGame
? (await this.resolveProfileClockAdapter(profile)).connectorFactory
: undefined
);
cancellationCommitted = true;
await assertLease();
await updateClaimedProfile({
@@ -2493,11 +2511,24 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
throw new Error(`Selected profile seed failed: ${seedResult.output.slice(-4000)}`);
}
await appendLog('seed', '시나리오 초기 데이터 생성을 완료했습니다.');
// 실제 seed가 정한 경계를 공개 시간표와 scheduler에도 사용한다.
const openingConnector = createGamePostgresConnector({ url: seedInfo.databaseUrl });
let effectiveOpenAt = openAt;
try {
await openingConnector.connect();
const clock = await openingConnector.prisma.worldState.findFirstOrThrow({
select: { clockMode: true, clockWallAnchor: true },
});
if (clock.clockMode === 'realtime' && clock.clockWallAnchor) effectiveOpenAt = clock.clockWallAnchor;
} finally {
await openingConnector.disconnect();
}
await this.clearTournamentRuntimeState(profile.profileName);
await assertLease?.();
const completedAt = this.now().toISOString();
const now = this.now();
const desiredStatus = resolveResetLifecycleStatus(now, preopenAt, openAt);
const desiredStatus = resolveResetLifecycleStatus(now, preopenAt, effectiveOpenAt);
const publishedProfile = await updateClaimedProfile(
{
currentScenario: String(scenarioId),
@@ -2508,8 +2539,12 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
buildLastUsedAt: completedAt,
buildCompletedAt: completedAt,
buildError: null,
preopenAt: preopenAt ? preopenAt.toISOString() : openAt ? openAt.toISOString() : null,
openAt: openAt ? openAt.toISOString() : null,
preopenAt: preopenAt
? preopenAt.toISOString()
: effectiveOpenAt
? effectiveOpenAt.toISOString()
: null,
openAt: effectiveOpenAt ? effectiveOpenAt.toISOString() : null,
scheduledStartAt: action.scheduledAt ?? null,
...(releaseSource ? { meta: writeProfileReleaseSource(profile.meta, releaseSource) } : {}),
},
@@ -2523,8 +2558,12 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
await this.repository.updateCurrentScenario(profile.profileName, String(scenarioId));
}
return this.repository.updateStatus(profile.profileName, desiredStatus, {
preopenAt: preopenAt ? preopenAt.toISOString() : openAt ? openAt.toISOString() : null,
openAt: openAt ? openAt.toISOString() : null,
preopenAt: preopenAt
? preopenAt.toISOString()
: effectiveOpenAt
? effectiveOpenAt.toISOString()
: null,
openAt: effectiveOpenAt ? effectiveOpenAt.toISOString() : null,
scheduledStartAt: action.scheduledAt ?? null,
});
}
@@ -2802,6 +2841,25 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
}
}
private async resolveProfileClockAdapter(profile: GatewayProfileRecord): Promise<{
connectorFactory: typeof createGamePostgresConnector;
supportsTurnRecovery: boolean;
}> {
// Gateway와 profile은 독립 배포된다. 이전 profile에는 당시 Prisma 모델과
// 기존 즉시 따라잡기 정책을 사용하고, 새 profile만 복구 창을 저장한다.
const profileWorkspace = profile.buildWorkspace ?? this.processConfig.workspaceRoot;
const manifest = await readReleaseManifest(profileWorkspace);
const supportsTurnRecovery = manifest.gameSchemaHead >= '20260906090000_add_turn_recovery_window';
const connectorFactory = supportsTurnRecovery
? createGamePostgresConnector
: (
(await import(pathToFileURL(path.join(profileWorkspace, 'packages/infra/dist/index.js')).href)) as {
createGamePostgresConnector: typeof createGamePostgresConnector;
}
).createGamePostgresConnector;
return { connectorFactory, supportsTurnRecovery };
}
private resolveProfileDatabaseUrl(profile: GatewayProfileRecord): string {
return resolveGatewayPostgresConfigFromEnv(this.processConfig.baseEnv ?? process.env, profile.profile).url;
}
@@ -65,6 +65,7 @@ export const runProfileSeedCli = async (env: NodeJS.ProcessEnv = process.env): P
tickSeconds: request.tickSeconds,
gameClockMode: process.env.GAME_CLOCK_MODE === 'manual' ? 'manual' : 'realtime',
now: new Date(request.now),
wallNow: new Date(),
installOptions: request.installOptions
? {
...request.installOptions,
@@ -22,6 +22,7 @@ export interface SeedProfileDatabaseOptions {
tickSeconds?: number;
gameClockMode?: GameClockMode;
now?: Date;
wallNow?: Date;
installOptions?: ScenarioInstallOptions;
scenarioOptions?: Parameters<typeof seedScenarioToDatabase>[0]['scenarioOptions'];
mapOptions?: Parameters<typeof seedScenarioToDatabase>[0]['mapOptions'];
@@ -115,9 +116,7 @@ const ensureAdminGeneral = async (prisma: GamePrisma.TransactionClient, adminUse
const rawTurnTime = typeof meta.turntime === 'string' ? new Date(meta.turntime) : null;
const fallbackTurnTime = rawTurnTime && !Number.isNaN(rawTurnTime.getTime()) ? rawTurnTime : new Date();
const mode = worldState.clockMode === 'manual' ? 'manual' : 'realtime';
const phase = worldState.clockPhase
? parseGameClockPhase(worldState.clockPhase)
: inferClockPhase(mode);
const phase = worldState.clockPhase ? parseGameClockPhase(worldState.clockPhase) : inferClockPhase(mode);
const gameClock = new GameClock({
baseTime: worldState.clockBaseTime ?? fallbackTurnTime,
tick: Number(worldState.clockTick ?? 0n),
@@ -164,6 +163,7 @@ export const seedProfileDatabase = async (options: SeedProfileDatabaseOptions) =
tickSeconds: options.tickSeconds,
gameClockMode: options.gameClockMode,
now: options.now,
wallNow: options.wallNow,
installOptions: options.installOptions,
scenarioOptions: options.scenarioOptions,
mapOptions: options.mapOptions,
+23 -4
View File
@@ -1566,13 +1566,32 @@ describe('admin runtime clock action API', () => {
expect(harness.updatedStatuses).toEqual([]);
});
it('routes direct profile status changes through clock suspension', async () => {
const harness = await buildCaller(unusedCreateOperation, { initialProfileStatus: 'RUNNING' });
await harness.caller.admin.profiles.setStatus({ profileName: 'che:2', status: 'PAUSED' });
expect(harness.lifecycle[0]).toBe('clock:SUSPEND');
expect(harness.updatedStatuses).toEqual(['PAUSED']);
});
it('rejects schedule movement that changes within-turn phases', async () => {
const harness = await buildCaller(unusedCreateOperation);
await expect(
harness.caller.admin.profiles.requestAction({
profileName: 'che:2',
action: 'ACCELERATE',
durationMinutes: 15,
})
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
expect(harness.createdRuntimeActions).toEqual([]);
});
it('creates a first-class clock action owned by the authenticated administrator', async () => {
const harness = await buildCaller(unusedCreateOperation);
const result = await harness.caller.admin.profiles.requestAction({
profileName: 'che:2',
action: 'ACCELERATE',
durationMinutes: 15,
durationMinutes: 20,
reason: '운영 일정 조정',
});
@@ -1580,7 +1599,7 @@ describe('admin runtime clock action API', () => {
ok: true,
action: {
action: 'ACCELERATE',
durationMinutes: 15,
durationMinutes: 20,
status: 'REQUESTED',
},
});
@@ -1589,7 +1608,7 @@ describe('admin runtime clock action API', () => {
profileName: 'che:2',
action: 'ACCELERATE',
payload: {},
durationMinutes: 15,
durationMinutes: 20,
reason: '운영 일정 조정',
requestedBy: harness.admin.id,
},
@@ -1605,7 +1624,7 @@ describe('admin runtime clock action API', () => {
harness.caller.admin.profiles.requestAction({
profileName: 'che:2',
action: 'DELAY',
durationMinutes: 5,
durationMinutes: 20,
})
).rejects.toMatchObject({
code: 'CONFLICT',
@@ -193,6 +193,10 @@ const createHarness = (
scheduleIntervalMs: 60_000,
buildIntervalMs: 60_000,
adminActionIntervalMs: 60_000,
transitionProfileClock: async (_profileName, action) => {
lifecycle.push(`clock:${action}`);
return { phase: action === 'SUSPEND' ? 'SUSPENDED' : 'RUNNING', revision: 2 };
},
now: options.now,
cancelGame: options.cancelGame,
promoteProfileOpening: options.promoteProfileOpening
@@ -218,6 +222,13 @@ const createHarness = (
};
describe('GatewayOrchestrator first-class operations', () => {
it.each(['START', 'STOP'] as const)('routes %s through durable clock transition', async (action) => {
const harness = createHarness(buildOperation(action));
await harness.orchestrator.runOperationsNow();
expect(harness.lifecycle[0]).toBe(`clock:${action === 'START' ? 'RESUME' : 'SUSPEND'}`);
expect(harness.completions).toEqual(['SUCCEEDED']);
});
it('stops runtime, settles once, and seals a cancelled profile', async () => {
const operation: GatewayOperationRecord = {
id: '88888888-8888-4888-8888-888888888888',
@@ -48,14 +48,14 @@ describeDatabase('selected workspace profile seed CLI', () => {
JSON.stringify({
scenarioId: 1010,
tickSeconds: 60,
now: '2036-03-03T00:00:00.000Z',
now: '2036-03-03T02:10:30.000Z',
installOptions: {
serverId: 'selected-cli-seed',
firstGameIdx: 0,
installOperationId: 'selected-cli-operation',
installCommitSha: 'selected-cli-commit',
preopenAt: '2036-03-03T01:00:00.000Z',
openAt: '2036-03-03T02:00:00.000Z',
openAt: '2036-03-03T02:10:30.000Z',
},
adminUser: {
id: 'selected-cli-admin',
@@ -71,7 +71,8 @@ describeDatabase('selected workspace profile seed CLI', () => {
const world = await connector.prisma.worldState.findFirstOrThrow();
expect(world).toMatchObject({
scenarioCode: '1010',
clockWallAnchor: new Date('2036-03-03T02:00:00.000Z'),
clockWallAnchor: new Date('2036-03-03T02:11:00.000Z'),
clockPhase: 'PREOPEN',
meta: {
firstGameIdx: 0,
gameIdx: completedGameCount,
@@ -83,6 +84,8 @@ describeDatabase('selected workspace profile seed CLI', () => {
where: { userId: 'selected-cli-admin' },
});
expect(adminGeneral).toMatchObject({ meta: { createdBy: 'admin-seed' } });
expect(adminGeneral.turnTick).toBeGreaterThanOrEqual(0n);
expect(adminGeneral.turnTick).toBeLessThan(36_000_000n);
const history = await connector.prisma.gameHistory.findUniqueOrThrow({
where: { serverId: 'selected-cli-seed' },
});
+1 -1
View File
@@ -39,7 +39,7 @@ describe('readReleaseManifest', () => {
await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({
controllerProtocol: RELEASE_CONTROLLER_PROTOCOL,
gatewaySchemaHead: '20260825000000_add_bulk_release_batches',
gameSchemaHead: '20260903201500_complete_invader_game_clock',
gameSchemaHead: '20260906090000_add_turn_recovery_window',
});
});