From ce0c9e257de4138a7b1090b5c939c1eea7c8e6ae Mon Sep 17 00:00:00 2001 From: hided62 Date: Mon, 24 Aug 2026 13:22:19 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=98=88=EC=95=BD=20=EC=B4=88=EA=B8=B0?= =?UTF-8?q?=ED=99=94=20=EC=A4=91=EA=B0=84=20=EB=B2=84=EC=A0=84=20=EC=97=85?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=8A=B8=EB=A5=BC=20=ED=97=88=EC=9A=A9?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 미래 예약 RESET을 보존하면서 DB 유지 DEPLOY 한 건을 별도 queue lane에 등록한다. 예약 시각까지 시작되지 않은 중간 배포는 RESET claim 전에 자동 취소하고 관리자 화면에 이 경계를 안내한다. --- app/gateway-api/src/adminRouter.ts | 23 ++-- .../src/orchestrator/profileRepository.ts | 92 ++++++++++++++ .../profileOperationLease.integration.test.ts | 114 +++++++++++++++++- app/gateway-api/test/releaseManifest.test.ts | 2 +- .../e2e/server-operations.spec.ts | 66 +++++++++- .../src/views/ServerOperationsView.vue | 49 ++++++-- .../migration.sql | 19 +++ packages/infra/prisma/gateway.prisma | 5 +- release-manifest.json | 2 +- 9 files changed, 350 insertions(+), 22 deletions(-) create mode 100644 packages/infra/prisma/gateway-migrations/20260824090000_allow_interim_profile_deploy/migration.sql diff --git a/app/gateway-api/src/adminRouter.ts b/app/gateway-api/src/adminRouter.ts index ff99494a..98138d1d 100644 --- a/app/gateway-api/src/adminRouter.ts +++ b/app/gateway-api/src/adminRouter.ts @@ -25,7 +25,11 @@ import { } from './adminCapabilities.js'; import type { GatewayApiContext } from './context.js'; import { resolveLocalAccountProfilePolicy } from './auth/localAccountPolicy.js'; -import { GATEWAY_BUILD_STATUSES, GATEWAY_PROFILE_STATUSES } from './orchestrator/profileRepository.js'; +import { + GATEWAY_BUILD_STATUSES, + GATEWAY_PROFILE_STATUSES, + GatewayProfileOperationConflictError, +} from './orchestrator/profileRepository.js'; import { readProfileReleaseSource } from './orchestrator/profileReleaseSource.js'; import { orderGatewayProfiles, @@ -469,6 +473,9 @@ const zRuntimeSettings = z const isUniqueConstraintError = (error: unknown): boolean => Boolean(error && typeof error === 'object' && 'code' in error && error.code === 'P2002'); +const isProfileOperationConflictError = (error: unknown): boolean => + error instanceof GatewayProfileOperationConflictError || isUniqueConstraintError(error); + const zInstallOptions = z.object({ scenarioId: z.number().int().min(0), turnTermMinutes: z @@ -1343,7 +1350,7 @@ export const adminRouter = router({ }); return operation; } catch (error) { - if (!isUniqueConstraintError(error)) { + if (!isProfileOperationConflictError(error)) { throw error; } throw new TRPCError({ @@ -1399,7 +1406,7 @@ export const adminRouter = router({ requestedBy: adminAuth.user.id, }); } catch (error) { - if (!isUniqueConstraintError(error)) { + if (!isProfileOperationConflictError(error)) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'The active Gateway release commit cannot be resolved for game cancellation.', @@ -1460,7 +1467,7 @@ export const adminRouter = router({ requestedBy: adminAuth.user.id, }); } catch (error) { - if (!isUniqueConstraintError(error)) { + if (!isProfileOperationConflictError(error)) { throw error; } throw new TRPCError({ @@ -1505,7 +1512,7 @@ export const adminRouter = router({ }); return operation; } catch (error) { - if (!isUniqueConstraintError(error)) { + if (!isProfileOperationConflictError(error)) { throw error; } throw new TRPCError({ @@ -1575,7 +1582,7 @@ export const adminRouter = router({ if (error instanceof TRPCError) { throw error; } - if (!isUniqueConstraintError(error)) { + if (!isProfileOperationConflictError(error)) { throw error; } throw new TRPCError({ @@ -2114,7 +2121,7 @@ export const adminRouter = router({ }); return { ok: true, operationId: operation.id, action: actionRecord }; } catch (error) { - if (!isUniqueConstraintError(error)) { + if (!isProfileOperationConflictError(error)) { throw error; } throw new TRPCError({ @@ -2197,7 +2204,7 @@ export const adminRouter = router({ message: 'Profile install operation did not complete in time.', }); } catch (error) { - if (!isUniqueConstraintError(error)) { + if (!isProfileOperationConflictError(error)) { throw error; } throw new TRPCError({ diff --git a/app/gateway-api/src/orchestrator/profileRepository.ts b/app/gateway-api/src/orchestrator/profileRepository.ts index 081a1811..9fc71f8a 100644 --- a/app/gateway-api/src/orchestrator/profileRepository.ts +++ b/app/gateway-api/src/orchestrator/profileRepository.ts @@ -3,6 +3,13 @@ import type { GatewayPrisma, GatewayPrismaClient } from '@sammo-ts/infra'; export const CONTROL_PLANE_OPERATION_CLAIM_LOCK = 'gateway_control_plane_operation_claim'; +export class GatewayProfileOperationConflictError extends Error { + constructor() { + super('This profile already has an incompatible queued or running operation.'); + this.name = 'GatewayProfileOperationConflictError'; + } +} + export { GATEWAY_PROFILE_STATUSES, type GatewayProfileStatus }; export const GATEWAY_BUILD_STATUSES = ['IDLE', 'QUEUED', 'RUNNING', 'FAILED', 'SUCCEEDED'] as const; @@ -352,6 +359,24 @@ const mapOperationLog = (row: { createdAt: row.createdAt.toISOString(), }); +const canQueueAlongsideActiveOperations = ( + input: Pick, + activeOperations: GatewayOperationRow[], + now: Date +): boolean => { + if (activeOperations.length === 0) return true; + if (input.type !== 'DEPLOY' || input.scheduledAt || activeOperations.length !== 1) return false; + + const [reservedReset] = activeOperations; + return Boolean( + reservedReset && + reservedReset.type === 'RESET' && + reservedReset.status === 'QUEUED' && + reservedReset.scheduledAt && + reservedReset.scheduledAt.getTime() > now.getTime() + ); +}; + export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): GatewayProfileRepository => ({ async listProfiles(): Promise { const rows = await prisma.gatewayProfile.findMany({ @@ -620,6 +645,21 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat }, async createOperation(input: GatewayOperationCreateInput): Promise { const row = await prisma.$transaction(async (tx) => { + await tx.$queryRaw>` + SELECT pg_advisory_xact_lock( + hashtextextended(${CONTROL_PLANE_OPERATION_CLAIM_LOCK}, 0) + )::text AS lock_result + `; + const activeOperations = await tx.gatewayOperation.findMany({ + where: { + profileName: input.profileName, + status: { in: ['QUEUED', 'RUNNING'] }, + }, + orderBy: [{ createdAt: 'asc' }, { id: 'asc' }], + }); + if (!canQueueAlongsideActiveOperations(input, activeOperations, new Date())) { + throw new GatewayProfileOperationConflictError(); + } const operation = await tx.gatewayOperation.create({ data: { profileName: input.profileName, @@ -675,6 +715,37 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat if (running && !runningIsStale) { return null; } + const expiredInterimDeploys = await tx.$queryRaw>` + UPDATE "gateway_operation" AS deploy + SET "status" = 'CANCELLED', + "completed_at" = ${now}, + "lease_owner" = NULL, + "lease_until" = NULL, + "heartbeat_at" = NULL, + "updated_at" = ${now} + WHERE deploy."status" = 'QUEUED' + AND deploy."type" = 'DEPLOY' + AND EXISTS ( + SELECT 1 + FROM "gateway_operation" AS reset + WHERE reset."profile_name" = deploy."profile_name" + AND reset."status" = 'QUEUED' + AND reset."type" = 'RESET' + AND reset."scheduled_at" IS NOT NULL + AND reset."scheduled_at" <= ${now} + ) + RETURNING deploy."id" + `; + if (expiredInterimDeploys.length) { + await tx.gatewayOperationLog.createMany({ + data: expiredInterimDeploys.map(({ id }) => ({ + operationId: id, + level: 'INFO', + phase: 'cancel', + message: '예약 시나리오 초기화 시각이 되어 실행 전 중간 버전 업데이트를 자동 취소했습니다.', + })), + }); + } const candidate = running ?? (await tx.gatewayOperation.findFirst({ @@ -887,10 +958,31 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat }, async retryOperation(id: string, requestedBy: string): Promise { const row = await prisma.$transaction(async (tx) => { + await tx.$queryRaw>` + SELECT pg_advisory_xact_lock( + hashtextextended(${CONTROL_PLANE_OPERATION_CLAIM_LOCK}, 0) + )::text AS lock_result + `; const previous = await tx.gatewayOperation.findUnique({ where: { id } }); if (!previous || (previous.status !== 'FAILED' && previous.status !== 'CANCELLED')) { return null; } + const activeOperations = await tx.gatewayOperation.findMany({ + where: { + profileName: previous.profileName, + status: { in: ['QUEUED', 'RUNNING'] }, + }, + orderBy: [{ createdAt: 'asc' }, { id: 'asc' }], + }); + if ( + !canQueueAlongsideActiveOperations( + { type: previous.type, scheduledAt: undefined }, + activeOperations, + new Date() + ) + ) { + throw new GatewayProfileOperationConflictError(); + } const previousPayload = previous.payload as GatewayPrisma.JsonObject; const retrySource = buildRetryOperationSource(previous); const operation = await tx.gatewayOperation.create({ diff --git a/app/gateway-api/test/profileOperationLease.integration.test.ts b/app/gateway-api/test/profileOperationLease.integration.test.ts index 8b5311d7..c016e0be 100644 --- a/app/gateway-api/test/profileOperationLease.integration.test.ts +++ b/app/gateway-api/test/profileOperationLease.integration.test.ts @@ -52,7 +52,7 @@ describeDatabase('gateway operation lease and profile serialization', () => { await connector.disconnect(); }); - it('allows only one queued or running operation for a profile', async () => { + it('allows only one incompatible queued or running operation for a profile', async () => { const results = await Promise.allSettled([ repository.createOperation({ profileName, @@ -73,6 +73,118 @@ describeDatabase('gateway operation lease and profile serialization', () => { await expect(repository.listOperations({ profileName })).resolves.toHaveLength(1); }); + it('keeps a future scheduled RESET while one interim DEPLOY runs and completes first', async () => { + const scheduledAt = new Date('2099-01-01T00:00:00.000Z'); + const scheduledReset = await repository.createOperation({ + profileName, + type: 'RESET', + sourceMode: 'BRANCH', + sourceRef: 'main', + scheduledAt: scheduledAt.toISOString(), + requestedBy: 'reset-admin', + }); + const interimDeploy = await repository.createOperation({ + profileName, + type: 'DEPLOY', + sourceMode: 'BRANCH', + sourceRef: 'main', + requestedBy: 'deploy-admin', + }); + + await expect( + repository.createOperation({ + profileName, + type: 'STOP', + requestedBy: 'runtime-admin', + }) + ).rejects.toThrow('incompatible queued or running operation'); + await expect(repository.listOperations({ profileName })).resolves.toHaveLength(2); + + const beforeReset = new Date('2098-12-31T23:00:00.000Z'); + await expect( + repository.claimNextOperation(beforeReset, { ownerId: 'worker-a', durationMs: 1_000 }) + ).resolves.toMatchObject({ id: interimDeploy.id, type: 'DEPLOY', status: 'RUNNING' }); + await repository.completeOperation(interimDeploy.id, 'SUCCEEDED', { error: null }, 'worker-a'); + await expect(repository.getOperation(scheduledReset.id)).resolves.toMatchObject({ + status: 'QUEUED', + scheduledAt: scheduledAt.toISOString(), + }); + await expect( + repository.claimNextOperation(beforeReset, { ownerId: 'worker-b', durationMs: 1_000 }) + ).resolves.toBeNull(); + await expect( + repository.claimNextOperation(scheduledAt, { ownerId: 'worker-b', durationMs: 1_000 }) + ).resolves.toMatchObject({ id: scheduledReset.id, type: 'RESET', status: 'RUNNING' }); + }); + + it('accepts only one of two concurrent interim DEPLOY requests beside a scheduled RESET', async () => { + await repository.createOperation({ + profileName, + type: 'RESET', + sourceMode: 'BRANCH', + sourceRef: 'main', + scheduledAt: new Date('2099-01-01T00:00:00.000Z').toISOString(), + requestedBy: 'reset-admin', + }); + + const results = await Promise.allSettled([ + repository.createOperation({ + profileName, + type: 'DEPLOY', + sourceMode: 'BRANCH', + sourceRef: 'main', + requestedBy: 'deploy-admin-a', + }), + repository.createOperation({ + profileName, + type: 'DEPLOY', + sourceMode: 'BRANCH', + sourceRef: 'main', + requestedBy: 'deploy-admin-b', + }), + ]); + + expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1); + expect(results.filter((result) => result.status === 'rejected')).toHaveLength(1); + await expect(repository.listOperations({ profileName })).resolves.toHaveLength(2); + }); + + it('auto-cancels an interim DEPLOY that is still queued when the scheduled RESET becomes due', async () => { + const scheduledAt = new Date('2099-01-01T00:00:00.000Z'); + const scheduledReset = await repository.createOperation({ + profileName, + type: 'RESET', + sourceMode: 'COMMIT', + sourceRef: 'a'.repeat(40), + scheduledAt: scheduledAt.toISOString(), + requestedBy: 'reset-admin', + }); + const interimDeploy = await repository.createOperation({ + profileName, + type: 'DEPLOY', + sourceMode: 'COMMIT', + sourceRef: 'b'.repeat(40), + requestedBy: 'deploy-admin', + }); + + await expect( + repository.claimNextOperation(scheduledAt, { ownerId: 'worker-a', durationMs: 1_000 }) + ).resolves.toMatchObject({ id: scheduledReset.id, type: 'RESET', status: 'RUNNING' }); + await expect(repository.getOperation(interimDeploy.id)).resolves.toMatchObject({ + status: 'CANCELLED', + completedAt: scheduledAt.toISOString(), + }); + await expect(repository.listOperationLogs(interimDeploy.id)).resolves.toEqual([ + expect.objectContaining({ + phase: 'queue', + }), + expect.objectContaining({ + phase: 'cancel', + message: expect.stringContaining('중간 버전 업데이트를 자동 취소'), + }), + ]); + }); + it('stores durable cursor logs for profile operations', async () => { const operation = await repository.createOperation({ profileName, diff --git a/app/gateway-api/test/releaseManifest.test.ts b/app/gateway-api/test/releaseManifest.test.ts index 327a3702..97f2ff3c 100644 --- a/app/gateway-api/test/releaseManifest.test.ts +++ b/app/gateway-api/test/releaseManifest.test.ts @@ -38,7 +38,7 @@ describe('readReleaseManifest', () => { await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({ controllerProtocol: RELEASE_CONTROLLER_PROTOCOL, - gatewaySchemaHead: '20260823010000_add_web_push_notifications', + gatewaySchemaHead: '20260824090000_allow_interim_profile_deploy', gameSchemaHead: '20260824080000_vote_utc_wall_timestamps', }); }); diff --git a/app/gateway-frontend/e2e/server-operations.spec.ts b/app/gateway-frontend/e2e/server-operations.spec.ts index cdbdf58d..b57ed750 100644 --- a/app/gateway-frontend/e2e/server-operations.spec.ts +++ b/app/gateway-frontend/e2e/server-operations.spec.ts @@ -10,6 +10,7 @@ type Operation = { sourceMode?: 'BRANCH' | 'COMMIT'; sourceRef?: string; resolvedCommitSha?: string; + scheduledAt?: string; completedAt?: string; error?: string; payload: Record; @@ -382,7 +383,7 @@ const installFixture = async (page: Page, state: FixtureState) => { createdAt: '2026-07-25T02:00:00.000Z', updatedAt: '2026-07-25T02:00:00.000Z', }; - state.operations = [operation]; + state.operations = [operation, ...state.operations]; return response(operation); } if (name === 'admin.operations.requestDeploy') { @@ -398,7 +399,7 @@ const installFixture = async (page: Page, state: FixtureState) => { createdAt: '2026-08-01T01:00:00.000Z', updatedAt: '2026-08-01T01:00:00.000Z', }; - state.operations = [operation]; + state.operations = [operation, ...state.operations]; return response(operation); } if (name === 'admin.operations.requestGameCancellation') { @@ -817,6 +818,67 @@ test('separates DB-preserving profile deployment from DB reset', async ({ page } expect(state.requestBodies.some((entry) => entry.operation === 'admin.operations.requestReset')).toBe(false); }); +test('keeps a future scenario reset reserved while submitting one interim DB-preserving deploy', async ({ + page, +}, testInfo) => { + const scheduledReset: Operation = { + id: '99999999-9999-4999-8999-999999999999', + profileName: 'che:default', + type: 'RESET', + status: 'QUEUED', + sourceMode: 'BRANCH', + sourceRef: 'main', + scheduledAt: '2099-08-27T05:00:00.000Z', + payload: {}, + requestedBy: 'admin', + createdAt: '2026-08-24T00:00:00.000Z', + updatedAt: '2026-08-24T00:00:00.000Z', + }; + const state: FixtureState = { + operations: [scheduledReset], + gatewayOperations: [], + runtimeRunning: true, + requestBodies: [], + }; + await installFixture(page, state); + const confirmations: string[] = []; + page.on('dialog', async (dialog) => { + confirmations.push(dialog.message()); + await dialog.accept(); + }); + + await page.goto('admin/servers/che%3Adefault/version'); + const notice = page.getByTestId('interim-deploy-notice'); + await expect(notice).toContainText('예약 초기화 유지'); + await expect(notice).toContainText('시나리오 초기화 예약은 취소되지 않습니다.'); + await expect(page.getByTestId('request-deploy')).toBeEnabled(); + await page.getByTestId('request-deploy').click(); + + await expect(page.getByText('DB 보존 배포 작업을 등록했습니다.').first()).toBeVisible(); + expect(confirmations).toHaveLength(1); + expect(confirmations[0]).toContain('예약된 시나리오 초기화'); + expect(confirmations[0]).toContain('자동 취소됩니다.'); + expect(state.operations).toHaveLength(2); + expect(state.operations).toContainEqual(scheduledReset); + expect(state.requestBodies.filter((entry) => entry.operation === 'admin.operations.requestDeploy')).toHaveLength(1); + expect(state.requestBodies.some((entry) => entry.operation === 'admin.operations.requestReset')).toBe(false); + + await page.setViewportSize({ width: 390, height: 844 }); + const mobileGeometry = await notice.evaluate((element) => { + const rect = element.getBoundingClientRect(); + return { + left: rect.left, + right: rect.right, + viewportWidth: document.documentElement.clientWidth, + documentScrollWidth: document.documentElement.scrollWidth, + }; + }); + expect(mobileGeometry.left).toBeGreaterThanOrEqual(0); + expect(mobileGeometry.right).toBeLessThanOrEqual(mobileGeometry.viewportWidth); + expect(mobileGeometry.documentScrollWidth).toBeLessThanOrEqual(mobileGeometry.viewportWidth); + await page.screenshot({ path: testInfo.outputPath('interim-deploy-mobile.png'), fullPage: true }); +}); + test('submits a separately authorized destructive game cancellation on desktop and mobile', async ({ page, }, testInfo) => { diff --git a/app/gateway-frontend/src/views/ServerOperationsView.vue b/app/gateway-frontend/src/views/ServerOperationsView.vue index bccd8ed6..40fd7a17 100644 --- a/app/gateway-frontend/src/views/ServerOperationsView.vue +++ b/app/gateway-frontend/src/views/ServerOperationsView.vue @@ -268,15 +268,35 @@ const pageDescription = computed(() => { return '현재 게임 DB를 유지한 채 코드와 forward migration을 배포합니다.'; }); -const activeOperation = computed( +const activeOperations = computed(() => + operations.value.filter( + (operation) => + operation.profileName === selectedProfileName.value && + (operation.status === 'QUEUED' || operation.status === 'RUNNING') + ) +); + +const activeOperation = computed(() => activeOperations.value[0] ?? null); + +const queuedFutureScheduledReset = computed( () => - operations.value.find( + activeOperations.value.find( (operation) => - operation.profileName === selectedProfileName.value && - (operation.status === 'QUEUED' || operation.status === 'RUNNING') + operation.type === 'RESET' && + operation.status === 'QUEUED' && + Boolean(operation.scheduledAt) && + new Date(operation.scheduledAt ?? '').getTime() > Date.now() ) ?? null ); +const deployBlockingOperation = computed(() => { + if (activeOperations.value.length === 1 && queuedFutureScheduledReset.value) return null; + return ( + activeOperations.value.find((operation) => operation.id !== queuedFutureScheduledReset.value?.id) ?? + activeOperation.value + ); +}); + const sourceHelp = computed(() => form.sourceMode === 'CURRENT' ? '서버가 브랜치를 추적하면 작업 시작 시 최신 커밋을 사용하고, 커밋 고정 상태면 그 버전을 유지합니다.' @@ -564,15 +584,18 @@ const requestDeploy = async () => { if ( !selectedProfileName.value || !selectedProfileIdentityReady.value || - activeOperation.value || + deployBlockingOperation.value || !form.sourceRef.trim() || form.sourceMode === 'CURRENT' ) { return; } + const reservedResetReminder = queuedFutureScheduledReset.value?.scheduledAt + ? `\n예약된 시나리오 초기화(${formatTime(queuedFutureScheduledReset.value.scheduledAt)})는 유지됩니다. 이 배포가 그 시각까지 시작되지 못하면 자동 취소됩니다.` + : ''; if ( !window.confirm( - `${selectedProfileDisplayName.value}의 인게임 DB를 유지하고 ${form.sourceRef.trim()} 버전으로 배포하시겠습니까?` + `${selectedProfileDisplayName.value}의 인게임 DB를 유지하고 ${form.sourceRef.trim()} 버전으로 배포하시겠습니까?${reservedResetReminder}` ) ) { return; @@ -1054,6 +1077,18 @@ onBeforeUnmount(() => { +
+ 예약 초기화 유지 + + {{ formatTime(queuedFutureScheduledReset.scheduledAt) }} 시나리오 초기화 예약은 취소되지 + 않습니다. 지금 DB 유지 배포가 그 시각까지 시작되지 못하면 자동 취소됩니다. + +
+
소스 종류
@@ -1493,7 +1528,7 @@ onBeforeUnmount(() => { :disabled=" submitting || !selectedProfileIdentityReady || - Boolean(activeOperation) || + Boolean(deployBlockingOperation) || !form.sourceRef.trim() " data-testid="request-deploy" diff --git a/packages/infra/prisma/gateway-migrations/20260824090000_allow_interim_profile_deploy/migration.sql b/packages/infra/prisma/gateway-migrations/20260824090000_allow_interim_profile_deploy/migration.sql new file mode 100644 index 00000000..a5a49e89 --- /dev/null +++ b/packages/infra/prisma/gateway-migrations/20260824090000_allow_interim_profile_deploy/migration.sql @@ -0,0 +1,19 @@ +-- Keep the opening RESET reservation while allowing exactly one immediate +-- operation (the application permits only DEPLOY) beside it. A single RUNNING +-- lane preserves the existing profile mutation serialization contract. +DROP INDEX "gateway_operation_one_active_per_profile_idx"; + +CREATE UNIQUE INDEX "gateway_operation_one_running_per_profile_idx" + ON "gateway_operation" ("profile_name") + WHERE "status" = 'RUNNING'; + +CREATE UNIQUE INDEX "gateway_operation_one_queued_scheduled_reset_per_profile_idx" + ON "gateway_operation" ("profile_name") + WHERE "status" = 'QUEUED' + AND "type" = 'RESET' + AND "scheduled_at" IS NOT NULL; + +CREATE UNIQUE INDEX "gateway_operation_one_queued_immediate_per_profile_idx" + ON "gateway_operation" ("profile_name") + WHERE "status" = 'QUEUED' + AND NOT ("type" = 'RESET' AND "scheduled_at" IS NOT NULL); diff --git a/packages/infra/prisma/gateway.prisma b/packages/infra/prisma/gateway.prisma index f7df2914..6d74ec8d 100644 --- a/packages/infra/prisma/gateway.prisma +++ b/packages/infra/prisma/gateway.prisma @@ -362,8 +362,9 @@ model GatewayRuntimeAction { } model GatewayOperation { - /// A partial unique index in the gateway migration chain permits only one - /// QUEUED or RUNNING operation per profile. + /// Partial unique indexes in the gateway migration chain permit one future + /// scheduled RESET beside one immediate queued operation. RUNNING work and + /// each queue lane remain unique per profile. id String @id @default(uuid()) profileName String @map("profile_name") profile GatewayProfile @relation(fields: [profileName], references: [profileName], onDelete: Cascade) diff --git a/release-manifest.json b/release-manifest.json index 773e420f..f9f162f4 100644 --- a/release-manifest.json +++ b/release-manifest.json @@ -1,7 +1,7 @@ { "formatVersion": 1, "controllerProtocol": 2, - "gatewaySchemaHead": "20260823010000_add_web_push_notifications", + "gatewaySchemaHead": "20260824090000_allow_interim_profile_deploy", "gameSchemaHead": "20260824080000_vote_utc_wall_timestamps", "components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"] }