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 });
await connector.connect();
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;
const readModelBaseline = createRealtimeReadModelBaseline(world);
@@ -1391,10 +1395,7 @@ export const createDatabaseTurnHooks = async (
if (transaction) {
await persist(transaction);
} else {
await prisma.$transaction(
persist,
options?.transactionTimeoutMs ? { timeout: options.transactionTimeoutMs } : undefined
);
await prisma.$transaction(persist, transactionOptions);
}
const readModelChanges = mergePersistedVisibleLogChanges(
@@ -1425,21 +1426,18 @@ export const createDatabaseTurnHooks = async (
committedReadModelChanges = committed.readModelChanges;
},
executeCommand: async (requestId, execute) => {
const committed = await prisma.$transaction(
async (transaction) => {
const directLogFloor =
(
await transaction.logEntry.findFirst({
orderBy: { id: 'desc' },
select: { id: true },
})
)?.id ?? 0;
const result = await execute({ db: transaction });
const persisted = await persistChanges(transaction, { requestId, result }, directLogFloor);
return { result, persisted };
},
options?.transactionTimeoutMs ? { timeout: options.transactionTimeoutMs } : undefined
);
const committed = await prisma.$transaction(async (transaction) => {
const directLogFloor =
(
await transaction.logEntry.findFirst({
orderBy: { id: 'desc' },
select: { id: true },
})
)?.id ?? 0;
const result = await execute({ db: transaction });
const persisted = await persistChanges(transaction, { requestId, result }, directLogFloor);
return { result, persisted };
}, transactionOptions);
committed.persisted.acknowledge();
committedReadModelChanges = committed.persisted.readModelChanges;
return committed.result;
@@ -53,6 +53,7 @@ type LobbyFixtureOptions = {
opentime?: string;
turntime?: string;
lobbyBundleFailures?: number;
profileStatus?: 'RUNNING' | 'PREOPEN' | 'PAUSED' | 'COMPLETED';
};
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',
turntime = '2026-07-30 00:05:00',
lobbyBundleFailures = 0,
profileStatus = 'RUNNING',
} = options;
let remainingLobbyBundleFailures = lobbyBundleFailures;
const gameOperations: Array<{ operation: string; authorization: string | undefined }> = [];
@@ -111,7 +113,7 @@ const installFixture = async (page: Page, options: LobbyFixtureOptions = {}) =>
profileName: 'hwe:903',
profile: 'hwe',
scenario: '903',
status: 'RUNNING',
status: profileStatus,
apiPort: 15015,
runtime: {
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 }) => {
const gameOperations = await installFixture(page, { lobbyBundleFailures: 1 });
@@ -11,7 +11,7 @@ type ProfileFixture = {
profile: string;
korName: string;
color: string;
status: 'RUNNING' | 'PREOPEN' | 'STOPPED';
status: 'RUNNING' | 'PREOPEN' | 'PAUSED' | 'COMPLETED' | 'STOPPED';
apiPort: number;
};
@@ -29,7 +29,7 @@ const profiles: ProfileFixture[] = [
profile: 'hwe',
korName: '훼',
color: '#80c0ff',
status: 'PREOPEN',
status: 'PAUSED',
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 });
});
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) => {
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 PublicMap = GameOutput['public']['getCachedMap'];
type PublicMapLayout = GameOutput['public']['getMapLayout'];
const PROFILE_PUBLIC_STATUS_ORDER: LobbyProfile['status'][] = ['RUNNING', 'PREOPEN', 'PAUSED', 'COMPLETED'];
const router = useRouter();
const username = ref('');
@@ -52,9 +53,9 @@ const loadPublicStatus = async (): Promise<void> => {
try {
const profiles = await trpc.lobby.profiles.query();
profile.value =
profiles.find((entry) => entry.status === 'RUNNING') ??
profiles.find((entry) => entry.status === 'PREOPEN') ??
null;
PROFILE_PUBLIC_STATUS_ORDER.map((status) => profiles.find((entry) => entry.status === status)).find(
(entry) => entry !== undefined
) ?? null;
if (!profile.value) {
statusError.value = '현재 공개 중인 서버가 없습니다.';
return;
+21 -5
View File
@@ -33,6 +33,7 @@ type ProfileLoadState = {
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_RUNTIME_STATUSES = new Set<LobbyProfile['status']>(['RUNNING', 'PREOPEN', 'PAUSED', 'COMPLETED']);
const router = useRouter();
const me = ref<MeOutput>(null);
@@ -67,7 +68,7 @@ const needsKakaoVerification = computed(
const userIconBaseUrl = configuredUserIconPublicUrl();
const sharedIconBaseUrl = configuredSharedIconPublicUrl();
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(
() => 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 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 setProfileLoadState = (profileName: string, state: ProfileLoadState): void => {
profileLoadStates.value = {
@@ -151,7 +158,7 @@ const handleGeneralPictureError = (event: Event): void => {
};
const loadProfileDetails = async (profile: LobbyProfile, sessionToken: string | null): Promise<void> => {
if (!lobbyMounted || (profile.status !== 'RUNNING' && profile.status !== 'PREOPEN')) {
if (!lobbyMounted || !isProfileRuntimeAvailable(profile)) {
return;
}
clearProfileRetry(profile.profileName);
@@ -443,6 +450,13 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
>
{{ serverSeasonStatus(profileDetails[profile.profileName]!).label }}
</div>
<div
v-if="profile.status === 'PAUSED'"
class="mt-1 whitespace-nowrap text-xs text-amber-300"
data-testid="profile-paused-status"
>
진행 일시정지
</div>
<div
v-if="profile.localAccountPolicy?.specialAccess"
class="mt-2 text-xs text-emerald-300"
@@ -494,8 +508,10 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
</div>
</div>
</template>
<template v-else-if="profile.status === 'STOPPED'">
<div class="text-center text-zinc-600 py-2">- 폐 쇄 중 -</div>
<template v-else-if="!isProfileRuntimeAvailable(profile)">
<div class="text-center text-zinc-600 py-2">
{{ unavailableProfileText(profile) }}
</div>
</template>
<template v-else-if="profileLoadState(profile.profileName)?.status === 'retrying'">
<div
@@ -598,7 +614,7 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
</template>
</div>
</template>
<template v-else-if="profile.status === 'STOPPED'">
<template v-else-if="!isProfileRuntimeAvailable(profile)">
<span class="text-zinc-700">-</span>
</template>
</td>
+21 -6
View File
@@ -124,21 +124,36 @@ process 복구를 시도합니다. 관리자 화면의 오류와 PM2 process 상
뒤 원인을 해결하고 실패한 작업을 재시도해 주세요. 재시도는 처음 고정된 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을 다시
조회합니다.
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회
실패였을 가능성이 큽니다.
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 상태를 확인합니다. 활성
작업이 있으면 중복 작업을 만들지 말고 readiness 또는 rollback 종료를 기다립니다.
4. profile이 실제 `STOPPED`/`PAUSED`이고 활성 작업이 없을 때만 `재개`를 사용합니다.
metadata는 RUNNING인데 process가 계속 없으면 자동 reconcile과 operation 오류를
먼저 확인하고, 원인이 없는 상태에서만 마지막 복구 수단으로 `중지``재개`
사용합니다. DB 보존 배포는 health restart 버튼이 아닙니다.
6. profile이 `STOPPED`이고 활성 작업이 없을 때만 `재개`를 사용합니다. metadata는
RUNNING인데 process가 계속 없으면 자동 reconcile과 operation 오류를 먼저 확인하고,
원인이 없는 상태에서만 마지막 복구 수단으로 `중지``재개` 사용합니다. DB
보존 배포는 health restart 버튼이 아닙니다.
## Gateway 전체 배포