merge: 프로필 초기화와 Gateway 배포 직렬화를 통합

This commit is contained in:
2026-08-17 14:49:12 +00:00
4 changed files with 75 additions and 3 deletions
@@ -1,6 +1,10 @@
import type { GatewayPrisma, GatewayPrismaClient } from '@sammo-ts/infra';
import type { GatewayOperationStatus, GatewaySourceMode } from './profileRepository.js';
import {
CONTROL_PLANE_OPERATION_CLAIM_LOCK,
type GatewayOperationStatus,
type GatewaySourceMode,
} from './profileRepository.js';
export type GatewayReleaseOperationType = 'DEPLOY' | 'ROLLBACK';
@@ -230,8 +234,17 @@ export const createGatewayReleaseRepository = (prisma: GatewayPrismaClient): Gat
async claimNextOperation(now, lease) {
const row = await prisma.$transaction(async (tx) => {
await tx.$queryRaw<Array<{ lock_result: string }>>`
SELECT pg_advisory_xact_lock(hashtextextended('gateway_release_operation_claim', 0))::text AS lock_result
SELECT pg_advisory_xact_lock(
hashtextextended(${CONTROL_PLANE_OPERATION_CLAIM_LOCK}, 0)
)::text AS lock_result
`;
const runningProfileOperation = await tx.gatewayOperation.findFirst({
where: { status: 'RUNNING' },
select: { id: true },
});
if (runningProfileOperation) {
return null;
}
const staleBefore = new Date(now.getTime() - lease.durationMs);
const running = await tx.gatewayReleaseOperation.findFirst({
where: { status: 'RUNNING' },
@@ -1,6 +1,8 @@
import { GATEWAY_PROFILE_STATUSES, type GatewayProfileStatus } from '@sammo-ts/common';
import type { GatewayPrisma, GatewayPrismaClient } from '@sammo-ts/infra';
export const CONTROL_PLANE_OPERATION_CLAIM_LOCK = 'gateway_control_plane_operation_claim';
export { GATEWAY_PROFILE_STATUSES, type GatewayProfileStatus };
export const GATEWAY_BUILD_STATUSES = ['IDLE', 'QUEUED', 'RUNNING', 'FAILED', 'SUCCEEDED'] as const;
@@ -625,8 +627,17 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat
): Promise<GatewayOperationRecord | null> {
const row = await prisma.$transaction(async (tx) => {
await tx.$queryRaw<Array<{ lock_result: string }>>`
SELECT pg_advisory_xact_lock(hashtextextended('gateway_operation_claim', 0))::text AS lock_result
SELECT pg_advisory_xact_lock(
hashtextextended(${CONTROL_PLANE_OPERATION_CLAIM_LOCK}, 0)
)::text AS lock_result
`;
const runningRelease = await tx.gatewayReleaseOperation.findFirst({
where: { status: 'RUNNING' },
select: { id: true },
});
if (runningRelease) {
return null;
}
const staleBefore = lease ? new Date(now.getTime() - lease.durationMs) : now;
const running = await tx.gatewayOperation.findFirst({
where: { status: 'RUNNING' },
@@ -1,6 +1,7 @@
import { createGatewayPostgresConnector } from '@sammo-ts/infra';
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
import { createGatewayReleaseRepository } from '../src/orchestrator/gatewayReleaseRepository.js';
import { createGatewayProfileRepository } from '../src/orchestrator/profileRepository.js';
const databaseUrl = process.env.GATEWAY_OPERATION_DATABASE_URL;
@@ -11,6 +12,7 @@ const secondProfileName = 'lease-test-2:1010';
describeDatabase('gateway operation lease and profile serialization', () => {
const connector = createGatewayPostgresConnector({ url: databaseUrl ?? '' });
const repository = createGatewayProfileRepository(connector.prisma);
const releaseRepository = createGatewayReleaseRepository(connector.prisma);
beforeAll(async () => {
await connector.connect();
@@ -29,6 +31,8 @@ describeDatabase('gateway operation lease and profile serialization', () => {
});
afterEach(async () => {
await connector.prisma.gatewayReleaseOperation.deleteMany();
await connector.prisma.gatewayReleaseState.deleteMany();
await connector.prisma.gatewayOperation.deleteMany({
where: { profileName: { in: [profileName, secondProfileName] } },
});
@@ -115,6 +119,45 @@ describeDatabase('gateway operation lease and profile serialization', () => {
).resolves.toMatchObject({ id: second.id, leaseOwner: 'worker-b' });
});
it('serializes profile and Gateway release claims under one control-plane lock', async () => {
const profileOperation = await repository.createOperation({
profileName,
type: 'RESET',
sourceMode: 'COMMIT',
sourceRef: 'a'.repeat(40),
requestedBy: 'profile-admin',
});
const releaseOperation = await releaseRepository.createOperation({
type: 'DEPLOY',
sourceMode: 'COMMIT',
sourceRef: 'b'.repeat(40),
requestedBy: 'release-admin',
});
const now = new Date('2030-01-01T00:00:00.000Z');
const [profileClaim, releaseClaim] = await Promise.all([
repository.claimNextOperation(now, { ownerId: 'profile-worker', durationMs: 1_000 }),
releaseRepository.claimNextOperation(now, { ownerId: 'release-worker', durationMs: 1_000 }),
]);
expect([profileClaim, releaseClaim].filter(Boolean)).toHaveLength(1);
if (profileClaim) {
expect(profileClaim.id).toBe(profileOperation.id);
expect(releaseClaim).toBeNull();
await repository.completeOperation(profileOperation.id, 'SUCCEEDED', { error: null }, 'profile-worker');
await expect(
releaseRepository.claimNextOperation(now, { ownerId: 'release-worker', durationMs: 1_000 })
).resolves.toMatchObject({ id: releaseOperation.id });
return;
}
expect(releaseClaim?.id).toBe(releaseOperation.id);
await releaseRepository.completeOperation(releaseOperation.id, 'SUCCEEDED', { error: null }, 'release-worker');
await expect(
repository.claimNextOperation(now, { ownerId: 'profile-worker', durationMs: 1_000 })
).resolves.toMatchObject({ id: profileOperation.id });
});
it('does not let a future queued operation suppress runtime reconciliation early', async () => {
const now = new Date('2030-01-01T00:00:00.000Z');
await repository.createOperation({