merge: 로비 일시정지 상태와 턴 저장 시간 보완

This commit is contained in:
2026-08-15 16:38:03 +00:00
6 changed files with 96 additions and 36 deletions
+17 -19
View File
@@ -897,6 +897,10 @@ export const createDatabaseTurnHooks = async (
const connector = createGamePostgresConnector({ url: databaseUrl }); const connector = createGamePostgresConnector({ url: databaseUrl });
await connector.connect(); await connector.connect();
const prisma = connector.prisma; const prisma = connector.prisma;
// 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 };
let committedReadModelChanges: RealtimeReadModelChanges | null = null; let committedReadModelChanges: RealtimeReadModelChanges | null = null;
const readModelBaseline = createRealtimeReadModelBaseline(world); const readModelBaseline = createRealtimeReadModelBaseline(world);
@@ -1391,10 +1395,7 @@ export const createDatabaseTurnHooks = async (
if (transaction) { if (transaction) {
await persist(transaction); await persist(transaction);
} else { } else {
await prisma.$transaction( await prisma.$transaction(persist, transactionOptions);
persist,
options?.transactionTimeoutMs ? { timeout: options.transactionTimeoutMs } : undefined
);
} }
const readModelChanges = mergePersistedVisibleLogChanges( const readModelChanges = mergePersistedVisibleLogChanges(
@@ -1425,21 +1426,18 @@ export const createDatabaseTurnHooks = async (
committedReadModelChanges = committed.readModelChanges; committedReadModelChanges = committed.readModelChanges;
}, },
executeCommand: async (requestId, execute) => { executeCommand: async (requestId, execute) => {
const committed = await prisma.$transaction( const committed = await prisma.$transaction(async (transaction) => {
async (transaction) => { const directLogFloor =
const directLogFloor = (
( await transaction.logEntry.findFirst({
await transaction.logEntry.findFirst({ orderBy: { id: 'desc' },
orderBy: { id: 'desc' }, select: { id: true },
select: { id: true }, })
}) )?.id ?? 0;
)?.id ?? 0; const result = await execute({ db: transaction });
const result = await execute({ db: transaction }); const persisted = await persistChanges(transaction, { requestId, result }, directLogFloor);
const persisted = await persistChanges(transaction, { requestId, result }, directLogFloor); return { result, persisted };
return { result, persisted }; }, transactionOptions);
},
options?.transactionTimeoutMs ? { timeout: options.transactionTimeoutMs } : undefined
);
committed.persisted.acknowledge(); committed.persisted.acknowledge();
committedReadModelChanges = committed.persisted.readModelChanges; committedReadModelChanges = committed.persisted.readModelChanges;
return committed.result; return committed.result;
@@ -53,6 +53,7 @@ type LobbyFixtureOptions = {
opentime?: string; opentime?: string;
turntime?: string; turntime?: string;
lobbyBundleFailures?: number; lobbyBundleFailures?: number;
profileStatus?: 'RUNNING' | 'PREOPEN' | 'PAUSED' | 'COMPLETED';
}; };
const installFixture = async (page: Page, options: LobbyFixtureOptions = {}) => { const installFixture = async (page: Page, options: LobbyFixtureOptions = {}) => {
@@ -78,6 +79,7 @@ const installFixture = async (page: Page, options: LobbyFixtureOptions = {}) =>
opentime = '2026-07-30 00:00:00', opentime = '2026-07-30 00:00:00',
turntime = '2026-07-30 00:05:00', turntime = '2026-07-30 00:05:00',
lobbyBundleFailures = 0, lobbyBundleFailures = 0,
profileStatus = 'RUNNING',
} = options; } = options;
let remainingLobbyBundleFailures = lobbyBundleFailures; let remainingLobbyBundleFailures = lobbyBundleFailures;
const gameOperations: Array<{ operation: string; authorization: string | undefined }> = []; const gameOperations: Array<{ operation: string; authorization: string | undefined }> = [];
@@ -111,7 +113,7 @@ const installFixture = async (page: Page, options: LobbyFixtureOptions = {}) =>
profileName: 'hwe:903', profileName: 'hwe:903',
profile: 'hwe', profile: 'hwe',
scenario: '903', scenario: '903',
status: 'RUNNING', status: profileStatus,
apiPort: 15015, apiPort: 15015,
runtime: { runtime: {
apiRunning: true, apiRunning: true,
@@ -230,6 +232,23 @@ test('exchanges the gateway token before loading authenticated lobby general dat
}); });
}); });
test('loads and labels a PAUSED profile whose runtime remains available', async ({ page }, testInfo) => {
const gameOperations = await installFixture(page, { profileStatus: 'PAUSED' });
await page.setViewportSize({ width: 1365, height: 900 });
await page.goto('lobby');
const row = page.locator('tbody tr').filter({ hasText: 'hwe섭' });
const pausedStatus = row.getByTestId('profile-paused-status');
await expect(pausedStatus).toHaveText('턴 진행 일시정지');
await expect(pausedStatus).toHaveCSS('color', 'oklch(0.879 0.169 91.605)');
await expect(row).toContainText('선택장수');
await expect(row).not.toContainText('정보를 불러오는 중');
await expect(row.getByRole('button', { name: '입장' })).toBeVisible();
expect(gameOperations.some(({ operation }) => operation === 'lobby.info')).toBe(true);
await expect(page.getByRole('tab', { name: 'hwe섭' })).toBeVisible();
await page.screenshot({ path: testInfo.outputPath('gateway-paused-profile-lobby.png'), fullPage: true });
});
test('automatically recovers profile details after a transient update outage', async ({ page }) => { test('automatically recovers profile details after a transient update outage', async ({ page }) => {
const gameOperations = await installFixture(page, { lobbyBundleFailures: 1 }); const gameOperations = await installFixture(page, { lobbyBundleFailures: 1 });
@@ -11,7 +11,7 @@ type ProfileFixture = {
profile: string; profile: string;
korName: string; korName: string;
color: string; color: string;
status: 'RUNNING' | 'PREOPEN' | 'STOPPED'; status: 'RUNNING' | 'PREOPEN' | 'PAUSED' | 'COMPLETED' | 'STOPPED';
apiPort: number; apiPort: number;
}; };
@@ -29,7 +29,7 @@ const profiles: ProfileFixture[] = [
profile: 'hwe', profile: 'hwe',
korName: '훼', korName: '훼',
color: '#80c0ff', color: '#80c0ff',
status: 'PREOPEN', status: 'PAUSED',
apiPort: 15015, apiPort: 15015,
}, },
{ {
@@ -371,6 +371,17 @@ test('treats an all-closed profile list as a normal empty login status', async (
await page.screenshot({ path: testInfo.outputPath('login-no-public-server.png'), fullPage: true }); await page.screenshot({ path: testInfo.outputPath('login-no-public-server.png'), fullPage: true });
}); });
test('shows a PAUSED profile with a live runtime on the public login status', async ({ page }) => {
await installGatewayFixture(page, [profiles[1]!], false);
await installGameFixture(page, profiles[1]!, 22);
await page.goto('/gateway/');
const status = page.locator('#map-subframe');
await expect(status).toContainText('훼 현황');
await expect(status).toContainText('유저 22명');
await expect(status).not.toContainText('현재 공개 중인 서버가 없습니다.');
});
test('renders the Gateway profile order returned by the API', async ({ page }, testInfo) => { test('renders the Gateway profile order returned by the API', async ({ page }, testInfo) => {
await installGatewayFixture(page, orderedProfiles, true); await installGatewayFixture(page, orderedProfiles, true);
+4 -3
View File
@@ -19,6 +19,7 @@ type LobbyProfile = GatewayOutput['lobby']['profiles'][number];
type LobbyInfo = GameOutput['lobby']['info']; type LobbyInfo = GameOutput['lobby']['info'];
type PublicMap = GameOutput['public']['getCachedMap']; type PublicMap = GameOutput['public']['getCachedMap'];
type PublicMapLayout = GameOutput['public']['getMapLayout']; type PublicMapLayout = GameOutput['public']['getMapLayout'];
const PROFILE_PUBLIC_STATUS_ORDER: LobbyProfile['status'][] = ['RUNNING', 'PREOPEN', 'PAUSED', 'COMPLETED'];
const router = useRouter(); const router = useRouter();
const username = ref(''); const username = ref('');
@@ -52,9 +53,9 @@ const loadPublicStatus = async (): Promise<void> => {
try { try {
const profiles = await trpc.lobby.profiles.query(); const profiles = await trpc.lobby.profiles.query();
profile.value = profile.value =
profiles.find((entry) => entry.status === 'RUNNING') ?? PROFILE_PUBLIC_STATUS_ORDER.map((status) => profiles.find((entry) => entry.status === status)).find(
profiles.find((entry) => entry.status === 'PREOPEN') ?? (entry) => entry !== undefined
null; ) ?? null;
if (!profile.value) { if (!profile.value) {
statusError.value = '현재 공개 중인 서버가 없습니다.'; statusError.value = '현재 공개 중인 서버가 없습니다.';
return; return;
+21 -5
View File
@@ -33,6 +33,7 @@ type ProfileLoadState = {
const PROFILE_REQUEST_TIMEOUT_MS = 10_000; const PROFILE_REQUEST_TIMEOUT_MS = 10_000;
const PROFILE_RETRY_DELAYS_MS = [1_000, 2_000, 3_000, 5_000, 8_000, 15_000] as const; const PROFILE_RETRY_DELAYS_MS = [1_000, 2_000, 3_000, 5_000, 8_000, 15_000] as const;
const PROFILE_RUNTIME_STATUSES = new Set<LobbyProfile['status']>(['RUNNING', 'PREOPEN', 'PAUSED', 'COMPLETED']);
const router = useRouter(); const router = useRouter();
const me = ref<MeOutput>(null); const me = ref<MeOutput>(null);
@@ -67,7 +68,7 @@ const needsKakaoVerification = computed(
const userIconBaseUrl = configuredUserIconPublicUrl(); const userIconBaseUrl = configuredUserIconPublicUrl();
const sharedIconBaseUrl = configuredSharedIconPublicUrl(); const sharedIconBaseUrl = configuredSharedIconPublicUrl();
const publicMapProfiles = computed(() => const publicMapProfiles = computed(() =>
profiles.value.filter((profile) => profile.status === 'RUNNING' || profile.status === 'PREOPEN') profiles.value.filter((profile) => PROFILE_RUNTIME_STATUSES.has(profile.status))
); );
const selectedMapProfile = computed( const selectedMapProfile = computed(
() => publicMapProfiles.value.find((profile) => profile.profileName === selectedMapProfileName.value) ?? null () => publicMapProfiles.value.find((profile) => profile.profileName === selectedMapProfileName.value) ?? null
@@ -109,6 +110,12 @@ const handleMapTabKeydown = (event: KeyboardEvent, profileName: string): void =>
const formatGraceEndsAt = (value: string | null | undefined): string => formatServerDateTime(value); const formatGraceEndsAt = (value: string | null | undefined): string => formatServerDateTime(value);
const serverSeasonStatus = (info: LobbyInfo) => resolveServerSeasonStatus(info); const serverSeasonStatus = (info: LobbyInfo) => resolveServerSeasonStatus(info);
const isProfileRuntimeAvailable = (profile: LobbyProfile): boolean => PROFILE_RUNTIME_STATUSES.has(profile.status);
const unavailableProfileText = (profile: LobbyProfile): string => {
if (profile.status === 'RESERVED') return '- 준 비 중 -';
if (profile.status === 'DISABLED') return '- 비 활 성 -';
return '- 폐 쇄 중 -';
};
const profileLoadState = (profileName: string): ProfileLoadState | undefined => profileLoadStates.value[profileName]; const profileLoadState = (profileName: string): ProfileLoadState | undefined => profileLoadStates.value[profileName];
const setProfileLoadState = (profileName: string, state: ProfileLoadState): void => { const setProfileLoadState = (profileName: string, state: ProfileLoadState): void => {
profileLoadStates.value = { profileLoadStates.value = {
@@ -151,7 +158,7 @@ const handleGeneralPictureError = (event: Event): void => {
}; };
const loadProfileDetails = async (profile: LobbyProfile, sessionToken: string | null): Promise<void> => { const loadProfileDetails = async (profile: LobbyProfile, sessionToken: string | null): Promise<void> => {
if (!lobbyMounted || (profile.status !== 'RUNNING' && profile.status !== 'PREOPEN')) { if (!lobbyMounted || !isProfileRuntimeAvailable(profile)) {
return; return;
} }
clearProfileRetry(profile.profileName); clearProfileRetry(profile.profileName);
@@ -443,6 +450,13 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
> >
{{ serverSeasonStatus(profileDetails[profile.profileName]!).label }} {{ serverSeasonStatus(profileDetails[profile.profileName]!).label }}
</div> </div>
<div
v-if="profile.status === 'PAUSED'"
class="mt-1 whitespace-nowrap text-xs text-amber-300"
data-testid="profile-paused-status"
>
진행 일시정지
</div>
<div <div
v-if="profile.localAccountPolicy?.specialAccess" v-if="profile.localAccountPolicy?.specialAccess"
class="mt-2 text-xs text-emerald-300" class="mt-2 text-xs text-emerald-300"
@@ -494,8 +508,10 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
</div> </div>
</div> </div>
</template> </template>
<template v-else-if="profile.status === 'STOPPED'"> <template v-else-if="!isProfileRuntimeAvailable(profile)">
<div class="text-center text-zinc-600 py-2">- 폐 쇄 중 -</div> <div class="text-center text-zinc-600 py-2">
{{ unavailableProfileText(profile) }}
</div>
</template> </template>
<template v-else-if="profileLoadState(profile.profileName)?.status === 'retrying'"> <template v-else-if="profileLoadState(profile.profileName)?.status === 'retrying'">
<div <div
@@ -598,7 +614,7 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
</template> </template>
</div> </div>
</template> </template>
<template v-else-if="profile.status === 'STOPPED'"> <template v-else-if="!isProfileRuntimeAvailable(profile)">
<span class="text-zinc-700">-</span> <span class="text-zinc-700">-</span>
</template> </template>
</td> </td>
+21 -6
View File
@@ -124,21 +124,36 @@ process 복구를 시도합니다. 관리자 화면의 오류와 PM2 process 상
뒤 원인을 해결하고 실패한 작업을 재시도해 주세요. 재시도는 처음 고정된 commit을 뒤 원인을 해결하고 실패한 작업을 재시도해 주세요. 재시도는 처음 고정된 commit을
사용합니다. 사용합니다.
Turn daemon의 DB persistence interactive transaction은 기본 30초입니다. 정상 turn
budget과 같은 Prisma 기본 5초를 그대로 쓰면 populated season의 flush가 경계에서
rollback되고 profile이 `PAUSED`로 전환될 수 있습니다. timeout을 늘려도 한
transaction의 계산·RNG·write 순서는 바뀌지 않지만 lock 보유 상한도 함께 늘어나므로
운영에서는 실제 flush 시간과 DB 경합을 함께 관찰합니다.
로비 한 행만 위 안내에 머물 때는 먼저 새 배포를 요청하지 말고 다음 순서로 로비 한 행만 위 안내에 머물 때는 먼저 새 배포를 요청하지 말고 다음 순서로
구분합니다. 구분합니다.
1. 로비의 `지금 다시 확인` 또는 browser 새로고침으로 같은 profile을 다시 1. 로비의 `지금 다시 확인` 또는 browser 새로고침으로 같은 profile을 다시
조회합니다. 조회합니다.
2. 공개 `/<profile>/api/trpc/lobby.info`가 이미 200이면 runtime은 복구된 것이므로 2. `lobby.profiles`의 상태와 runtime role을 함께 봅니다. `RUNNING`, `PREOPEN`,
`PAUSED`, `COMPLETED`는 API process가 유지되는 상태이므로 로비 상세와 공개 지도를
조회합니다. `PAUSED`에는 `턴 진행 일시정지`를 함께 표시하며, 상세 조회 실패나
폐쇄 상태로 바꾸어 표시하지 않습니다.
3. 상태가 `RUNNING`/`PREOPEN`/`COMPLETED`이고 공개
`/<profile>/api/trpc/lobby.info`가 이미 200이면 runtime은 복구된 것이므로
`DB 유지 배포`, `중지`, `재개`를 누르지 않습니다. 기존 로비의 전환 중 1회 `DB 유지 배포`, `중지`, `재개`를 누르지 않습니다. 기존 로비의 전환 중 1회
실패였을 가능성이 큽니다. 실패였을 가능성이 큽니다.
3. 응답이 계속 502/connection refused이면 관리자 작업 이력에서 활성 DEPLOY/RESET과 4. 상태가 `PAUSED`이면 turn daemon이 오류 또는 관리자 요청 때문에 턴 진행 gate를
닫은 것입니다. 활성 DEPLOY/RESET이 없고 `lastError`의 원인이 일시적이거나 이미
해소됐음을 확인한 뒤 `재개`를 한 번 사용합니다. runtime role이 모두 RUNNING이어도
profile status가 자동으로 `RUNNING`으로 돌아가지는 않습니다.
5. 응답이 계속 502/connection refused이면 관리자 작업 이력에서 활성 DEPLOY/RESET과
terminal 오류, 해당 profile의 API/daemon/worker runtime 상태를 확인합니다. 활성 terminal 오류, 해당 profile의 API/daemon/worker runtime 상태를 확인합니다. 활성
작업이 있으면 중복 작업을 만들지 말고 readiness 또는 rollback 종료를 기다립니다. 작업이 있으면 중복 작업을 만들지 말고 readiness 또는 rollback 종료를 기다립니다.
4. profile이 실제 `STOPPED`/`PAUSED`이고 활성 작업이 없을 때만 `재개`를 사용합니다. 6. profile이 `STOPPED`이고 활성 작업이 없을 때만 `재개`를 사용합니다. metadata는
metadata는 RUNNING인데 process가 계속 없으면 자동 reconcile과 operation 오류를 RUNNING인데 process가 계속 없으면 자동 reconcile과 operation 오류를 먼저 확인하고,
먼저 확인하고, 원인이 없는 상태에서만 마지막 복구 수단으로 `중지``재개` 원인이 없는 상태에서만 마지막 복구 수단으로 `중지``재개` 사용합니다. DB
사용합니다. DB 보존 배포는 health restart 버튼이 아닙니다. 보존 배포는 health restart 버튼이 아닙니다.
## Gateway 전체 배포 ## Gateway 전체 배포