feat: 일괄 릴리스 실행 계획을 추가한다

Gateway와 권한 있는 프로필 작업을 하나의 고정 커밋과 순서로 영속화한다. 앞 대상 성공 뒤 다음 작업을 claim하고 실패 대상은 같은 작업 ID로 재시도한다.
This commit is contained in:
2026-08-25 09:19:12 +00:00
parent ac48f2ae5b
commit 9613b482c1
11 changed files with 609 additions and 14 deletions
@@ -36,6 +36,7 @@ describeDatabase('gateway operation lease and profile serialization', () => {
await connector.prisma.gatewayOperation.deleteMany({
where: { profileName: { in: [profileName, secondProfileName] } },
});
await connector.prisma.gatewayBulkRelease.deleteMany();
await connector.prisma.gatewayProfile.updateMany({
where: { profileName: { in: [profileName, secondProfileName] } },
data: { buildStatus: 'IDLE', buildError: null },
@@ -46,6 +47,7 @@ describeDatabase('gateway operation lease and profile serialization', () => {
await connector.prisma.gatewayOperation.deleteMany({
where: { profileName: { in: [profileName, secondProfileName] } },
});
await connector.prisma.gatewayBulkRelease.deleteMany();
await connector.prisma.gatewayProfile.deleteMany({
where: { profileName: { in: [profileName, secondProfileName] } },
});
@@ -270,6 +272,97 @@ describeDatabase('gateway operation lease and profile serialization', () => {
).resolves.toMatchObject({ id: profileOperation.id });
});
it('runs a bulk release in Gateway-first order and pauses later profiles until a failed target retries', async () => {
const fixedCommit = 'd'.repeat(40);
const created = await connector.prisma.$transaction(async (tx) => {
const batch = await tx.gatewayBulkRelease.create({
data: {
sourceMode: 'BRANCH',
sourceRef: 'main',
resolvedCommitSha: fixedCommit,
requestedBy: 'bulk-admin',
},
});
const gateway = await tx.gatewayReleaseOperation.create({
data: {
type: 'DEPLOY',
sourceMode: 'COMMIT',
sourceRef: fixedCommit,
requestedBy: 'bulk-admin',
bulkReleaseId: batch.id,
bulkOrder: 0,
},
});
const firstProfile = await tx.gatewayOperation.create({
data: {
profileName,
type: 'DEPLOY',
sourceMode: 'COMMIT',
sourceRef: fixedCommit,
requestedBy: 'bulk-admin',
bulkReleaseId: batch.id,
bulkOrder: 1,
},
});
const secondProfile = await tx.gatewayOperation.create({
data: {
profileName: secondProfileName,
type: 'DEPLOY',
sourceMode: 'COMMIT',
sourceRef: fixedCommit,
requestedBy: 'bulk-admin',
bulkReleaseId: batch.id,
bulkOrder: 2,
},
});
return { gateway, firstProfile, secondProfile };
});
const now = new Date('2030-01-01T00:00:00.000Z');
await expect(
repository.claimNextOperation(now, { ownerId: 'profile-worker', durationMs: 10_000 })
).resolves.toBeNull();
await expect(
releaseRepository.claimNextOperation(now, { ownerId: 'release-worker', durationMs: 10_000 })
).resolves.toMatchObject({ id: created.gateway.id, sourceRef: fixedCommit });
await releaseRepository.completeOperation(
created.gateway.id,
'SUCCEEDED',
{ resolvedCommitSha: fixedCommit, error: null },
'release-worker'
);
await expect(
repository.claimNextOperation(now, { ownerId: 'profile-worker', durationMs: 10_000 })
).resolves.toMatchObject({ id: created.firstProfile.id, sourceRef: fixedCommit });
await repository.completeOperation(
created.firstProfile.id,
'FAILED',
{ resolvedCommitSha: fixedCommit, error: 'fixture failure' },
'profile-worker'
);
await expect(
repository.claimNextOperation(now, { ownerId: 'profile-worker', durationMs: 10_000 })
).resolves.toBeNull();
await expect(repository.retryOperation(created.firstProfile.id, 'retry-admin')).resolves.toMatchObject({
id: created.firstProfile.id,
status: 'QUEUED',
sourceMode: 'COMMIT',
sourceRef: fixedCommit,
});
await repository.claimNextOperation(now, { ownerId: 'profile-worker', durationMs: 10_000 });
await repository.completeOperation(
created.firstProfile.id,
'SUCCEEDED',
{ resolvedCommitSha: fixedCommit, error: null },
'profile-worker'
);
await expect(
repository.claimNextOperation(now, { ownerId: 'profile-worker', durationMs: 10_000 })
).resolves.toMatchObject({ id: created.secondProfile.id, sourceRef: fixedCommit });
});
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({