feat: 增加发布单版本冲突保护

- deploy-run: 将 version 纳入发布单摘要并在 run/step 更新时递增
- repository: 使用 expectedVersion 做乐观锁更新,冲突返回 CONFLICT
- execution: Jenkins 同步、通知、取消路径按最新版本串行推进
This commit is contained in:
湛兮
2026-06-12 05:10:59 +08:00
parent f92ee66298
commit f883568250
5 changed files with 276 additions and 95 deletions
+99 -44
View File
@@ -38,7 +38,11 @@ export class DeployExecutionService {
let currentRun = this.requireRun( let currentRun = this.requireRun(
run.id, run.id,
await this.deployRunRepository.updateRun(run.id, { status: 'running' }), await this.deployRunRepository.updateRun(
run.id,
{ status: 'running' },
run.version,
),
); );
let currentStep: string | undefined; let currentStep: string | undefined;
@@ -58,7 +62,7 @@ export class DeployExecutionService {
try { try {
currentStep = 'gitea-check'; currentStep = 'gitea-check';
currentRun = await this.completeStep( currentRun = await this.completeStep(
currentRun.id, currentRun,
currentStep, currentStep,
'success', 'success',
); );
@@ -74,7 +78,7 @@ export class DeployExecutionService {
currentStep = 'health-check'; currentStep = 'health-check';
currentRun = await this.completeStep( currentRun = await this.completeStep(
currentRun.id, currentRun,
currentStep, currentStep,
'success', 'success',
); );
@@ -84,15 +88,19 @@ export class DeployExecutionService {
currentStep = 'finish-release'; currentStep = 'finish-release';
currentRun = await this.completeStep( currentRun = await this.completeStep(
currentRun.id, currentRun,
currentStep, currentStep,
'success', 'success',
); );
currentRun = this.requireRun( currentRun = this.requireRun(
currentRun.id, currentRun.id,
await this.deployRunRepository.updateRun(currentRun.id, { await this.deployRunRepository.updateRun(
currentRun.id,
{
status: 'success', status: 'success',
}), },
currentRun.version,
),
); );
await this.auditService.record({ await this.auditService.record({
@@ -121,14 +129,19 @@ export class DeployExecutionService {
message: undefined, message: undefined,
errorSummary: failureSummary, errorSummary: failureSummary,
}, },
currentRun.version,
)) ?? currentRun; )) ?? currentRun;
} }
currentRun = currentRun =
(await this.deployRunRepository.updateRun(currentRun.id, { (await this.deployRunRepository.updateRun(
currentRun.id,
{
status: 'failed', status: 'failed',
failureSummary, failureSummary,
})) ?? currentRun; },
currentRun.version,
)) ?? currentRun;
currentRun = await this.advanceNotification(currentRun, 'failed'); currentRun = await this.advanceNotification(currentRun, 'failed');
@@ -172,26 +185,34 @@ export class DeployExecutionService {
if (!queueItem.buildNumber) { if (!queueItem.buildNumber) {
return this.requireRun( return this.requireRun(
currentRun.id, currentRun.id,
await this.deployRunRepository.updateRun(currentRun.id, { await this.deployRunRepository.updateRun(
currentRun.id,
{
status: 'queued', status: 'queued',
jenkinsQueueId: queueItem.queueId, jenkinsQueueId: queueItem.queueId,
jenkinsBuildUrl: queueItem.executableUrl, jenkinsBuildUrl: queueItem.executableUrl,
}), },
currentRun.version,
),
); );
} }
buildNumber = queueItem.buildNumber; buildNumber = queueItem.buildNumber;
currentRun = this.requireRun( currentRun = this.requireRun(
currentRun.id, currentRun.id,
await this.deployRunRepository.updateRun(currentRun.id, { await this.deployRunRepository.updateRun(
currentRun.id,
{
status: 'running', status: 'running',
jenkinsQueueId: queueItem.queueId, jenkinsQueueId: queueItem.queueId,
jenkinsBuildNumber: queueItem.buildNumber, jenkinsBuildNumber: queueItem.buildNumber,
jenkinsBuildUrl: queueItem.executableUrl, jenkinsBuildUrl: queueItem.executableUrl,
}), },
currentRun.version,
),
); );
currentRun = await this.markStep( currentRun = await this.markStep(
currentRun.id, currentRun,
'jenkins-build', 'jenkins-build',
'running', 'running',
`Jenkins 构建 #${queueItem.buildNumber} 正在运行。`, `Jenkins 构建 #${queueItem.buildNumber} 正在运行。`,
@@ -221,19 +242,23 @@ export class DeployExecutionService {
currentRun = this.requireRun( currentRun = this.requireRun(
currentRun.id, currentRun.id,
await this.deployRunRepository.updateRun(currentRun.id, { await this.deployRunRepository.updateRun(
currentRun.id,
{
status: status:
buildStatus.building || !buildStatus.result buildStatus.building || !buildStatus.result
? 'running' ? 'running'
: currentRun.status, : currentRun.status,
jenkinsBuildNumber: buildStatus.buildNumber, jenkinsBuildNumber: buildStatus.buildNumber,
jenkinsBuildUrl: buildStatus.url, jenkinsBuildUrl: buildStatus.url,
}), },
currentRun.version,
),
); );
if (buildStatus.building || !buildStatus.result) { if (buildStatus.building || !buildStatus.result) {
return this.markStep( return this.markStep(
currentRun.id, currentRun,
'jenkins-build', 'jenkins-build',
'running', 'running',
`Jenkins 构建 #${buildStatus.buildNumber} 正在运行。`, `Jenkins 构建 #${buildStatus.buildNumber} 正在运行。`,
@@ -263,7 +288,7 @@ export class DeployExecutionService {
health.message ?? health.message ??
'Jenkins 尚未配置,已完成本地模拟构建。'; 'Jenkins 尚未配置,已完成本地模拟构建。';
const currentRun = await this.completeStep( const currentRun = await this.completeStep(
run.id, run,
'jenkins-build', 'jenkins-build',
'success', 'success',
); );
@@ -292,23 +317,27 @@ export class DeployExecutionService {
); );
} }
await this.markStep(run.id, 'jenkins-build', 'running'); let currentRun = await this.markStep(run, 'jenkins-build', 'running');
const queueItem = await this.jenkinsClient.buildWithParameters( const queueItem = await this.jenkinsClient.buildWithParameters(
environment.jenkinsJobPath, environment.jenkinsJobPath,
this.buildParameters(run), this.buildParameters(run),
); );
const nextStatus = queueItem.buildNumber ? 'running' : 'queued'; const nextStatus = queueItem.buildNumber ? 'running' : 'queued';
let currentRun = this.requireRun( currentRun = this.requireRun(
run.id, currentRun.id,
await this.deployRunRepository.updateRun(run.id, { await this.deployRunRepository.updateRun(
currentRun.id,
{
status: nextStatus, status: nextStatus,
jenkinsQueueId: queueItem.queueId, jenkinsQueueId: queueItem.queueId,
jenkinsBuildNumber: queueItem.buildNumber, jenkinsBuildNumber: queueItem.buildNumber,
jenkinsBuildUrl: queueItem.executableUrl, jenkinsBuildUrl: queueItem.executableUrl,
}), },
currentRun.version,
),
); );
currentRun = await this.markStep( currentRun = await this.markStep(
run.id, currentRun,
'jenkins-build', 'jenkins-build',
nextStatus, nextStatus,
queueItem.buildNumber queueItem.buildNumber
@@ -336,17 +365,20 @@ export class DeployExecutionService {
run: DeployRunSummary, run: DeployRunSummary,
event: DeployNotificationEvent, event: DeployNotificationEvent,
): Promise<DeployRunSummary> { ): Promise<DeployRunSummary> {
await this.markStep(run.id, 'notify-wecom', 'running'); let currentRun = await this.markStep(run, 'notify-wecom', 'running');
const notification = const notification =
await this.deployNotificationService.sendDeployRunEvent(run, event); await this.deployNotificationService.sendDeployRunEvent(
currentRun,
event,
);
const stepStatus = const stepStatus =
notification.status === 'sent' notification.status === 'sent'
? 'success' ? 'success'
: notification.status === 'failed' : notification.status === 'failed'
? 'failed' ? 'failed'
: 'skipped'; : 'skipped';
const currentRun = await this.completeStep( currentRun = await this.completeStep(
run.id, currentRun,
'notify-wecom', 'notify-wecom',
stepStatus, stepStatus,
notification.status === 'sent' ? undefined : notification.reason, notification.status === 'sent' ? undefined : notification.reason,
@@ -372,26 +404,30 @@ export class DeployExecutionService {
run: DeployRunSummary, run: DeployRunSummary,
): Promise<DeployRunSummary> { ): Promise<DeployRunSummary> {
let currentRun = await this.completeStep( let currentRun = await this.completeStep(
run.id, run,
'jenkins-build', 'jenkins-build',
'success', 'success',
); );
currentRun = await this.completeStep( currentRun = await this.completeStep(
currentRun.id, currentRun,
'health-check', 'health-check',
'success', 'success',
); );
currentRun = await this.advanceNotification(currentRun, 'succeeded'); currentRun = await this.advanceNotification(currentRun, 'succeeded');
currentRun = await this.completeStep( currentRun = await this.completeStep(
currentRun.id, currentRun,
'finish-release', 'finish-release',
'success', 'success',
); );
currentRun = this.requireRun( currentRun = this.requireRun(
currentRun.id, currentRun.id,
await this.deployRunRepository.updateRun(currentRun.id, { await this.deployRunRepository.updateRun(
currentRun.id,
{
status: 'success', status: 'success',
}), },
currentRun.version,
),
); );
await this.auditService.record({ await this.auditService.record({
@@ -417,17 +453,26 @@ export class DeployExecutionService {
const logExcerpt = await this.readFailureLogExcerpt(run, jobPath); const logExcerpt = await this.readFailureLogExcerpt(run, jobPath);
const failureSummary = `Jenkins 构建 #${run.jenkinsBuildNumber ?? '-'} ${result}`; const failureSummary = `Jenkins 构建 #${run.jenkinsBuildNumber ?? '-'} ${result}`;
let currentRun = let currentRun =
(await this.deployRunRepository.updateStep(run.id, 'jenkins-build', { (await this.deployRunRepository.updateStep(
run.id,
'jenkins-build',
{
status: 'failed', status: 'failed',
message: undefined, message: undefined,
errorSummary: failureSummary, errorSummary: failureSummary,
logExcerpt, logExcerpt,
})) ?? run; },
run.version,
)) ?? run;
currentRun = currentRun =
(await this.deployRunRepository.updateRun(currentRun.id, { (await this.deployRunRepository.updateRun(
currentRun.id,
{
status: 'failed', status: 'failed',
failureSummary, failureSummary,
})) ?? currentRun; },
currentRun.version,
)) ?? currentRun;
currentRun = await this.advanceNotification(currentRun, 'failed'); currentRun = await this.advanceNotification(currentRun, 'failed');
await this.auditService.record({ await this.auditService.record({
@@ -502,33 +547,43 @@ export class DeployExecutionService {
} }
private async completeStep( private async completeStep(
runId: string, run: DeployRunSummary,
bpmnNodeId: string, bpmnNodeId: string,
status: Extract<DeployRunStepStatus, 'success' | 'skipped' | 'failed'>, status: Extract<DeployRunStepStatus, 'success' | 'skipped' | 'failed'>,
errorSummary?: string, errorSummary?: string,
): Promise<DeployRunSummary> { ): Promise<DeployRunSummary> {
return this.requireRun( return this.requireRun(
runId, run.id,
await this.deployRunRepository.updateStep(runId, bpmnNodeId, { await this.deployRunRepository.updateStep(
run.id,
bpmnNodeId,
{
status, status,
message: undefined, message: undefined,
errorSummary, errorSummary,
}), },
run.version,
),
); );
} }
private async markStep( private async markStep(
runId: string, run: DeployRunSummary,
bpmnNodeId: string, bpmnNodeId: string,
status: Extract<DeployRunStepStatus, 'queued' | 'running'>, status: Extract<DeployRunStepStatus, 'queued' | 'running'>,
message?: string, message?: string,
): Promise<DeployRunSummary> { ): Promise<DeployRunSummary> {
return this.requireRun( return this.requireRun(
runId, run.id,
await this.deployRunRepository.updateStep(runId, bpmnNodeId, { await this.deployRunRepository.updateStep(
run.id,
bpmnNodeId,
{
status, status,
message, message,
}), },
run.version,
),
); );
} }
+120 -5
View File
@@ -11,6 +11,7 @@ import {
Prisma, Prisma,
RunStepStatus as PrismaRunStepStatus, RunStepStatus as PrismaRunStepStatus,
} from '@prisma/client'; } from '@prisma/client';
import { AppError } from '../common/errors/app-error';
import { EnvConfig } from '../config/env.schema'; import { EnvConfig } from '../config/env.schema';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { seedDeployRuns } from './deploy-run.seed'; import { seedDeployRuns } from './deploy-run.seed';
@@ -217,9 +218,10 @@ export class DeployRunRepository {
async updateRun( async updateRun(
id: string, id: string,
patch: DeployRunMemoryPatch, patch: DeployRunMemoryPatch,
expectedVersion?: number,
): Promise<DeployRunSummary | null> { ): Promise<DeployRunSummary | null> {
if (!this.useDatabase()) { if (!this.useDatabase()) {
return this.updateMemoryRun(id, patch); return this.updateMemoryRun(id, patch, expectedVersion);
} }
const current = await this.prisma.deployRun.findUnique({ const current = await this.prisma.deployRun.findUnique({
@@ -279,11 +281,16 @@ export class DeployRunRepository {
data.finishedAt = this.toDate(patch.finishedAt); data.finishedAt = this.toDate(patch.finishedAt);
} }
await this.prisma.deployRun.update({ const version = expectedVersion ?? current.version;
where: { id }, const updated = await this.prisma.deployRun.updateMany({
where: { id, version },
data, data,
}); });
if (updated.count === 0) {
await this.raiseRunVersionConflict(id, version);
}
return this.findById(id); return this.findById(id);
} }
@@ -291,9 +298,15 @@ export class DeployRunRepository {
runId: string, runId: string,
bpmnNodeId: string, bpmnNodeId: string,
patch: DeployRunStepMemoryPatch, patch: DeployRunStepMemoryPatch,
expectedRunVersion?: number,
): Promise<DeployRunSummary | null> { ): Promise<DeployRunSummary | null> {
if (!this.useDatabase()) { if (!this.useDatabase()) {
return this.updateMemoryStep(runId, bpmnNodeId, patch); return this.updateMemoryStep(
runId,
bpmnNodeId,
patch,
expectedRunVersion,
);
} }
const current = await this.prisma.runStep.findUnique({ const current = await this.prisma.runStep.findUnique({
@@ -303,6 +316,9 @@ export class DeployRunRepository {
bpmnNodeId, bpmnNodeId,
}, },
}, },
include: {
deployRun: true,
},
}); });
if (!current) { if (!current) {
@@ -355,7 +371,30 @@ export class DeployRunRepository {
data.metadata = metadata; data.metadata = metadata;
} }
await this.prisma.runStep.update({ const runVersion = expectedRunVersion ?? current.deployRun.version;
await this.prisma.$transaction(async (tx) => {
const updatedRun = await tx.deployRun.updateMany({
where: {
id: runId,
version: runVersion,
},
data: {
version: {
increment: 1,
},
},
});
if (updatedRun.count === 0) {
throw this.createRunVersionConflict(runId, runVersion, {
version: current.deployRun.version,
status: current.deployRun.status,
updatedAt: current.deployRun.updatedAt,
});
}
await tx.runStep.update({
where: { where: {
deployRunId_bpmnNodeId: { deployRunId_bpmnNodeId: {
deployRunId: runId, deployRunId: runId,
@@ -364,10 +403,57 @@ export class DeployRunRepository {
}, },
data, data,
}); });
});
return this.findById(runId); return this.findById(runId);
} }
private async raiseRunVersionConflict(
id: string,
expectedVersion: number,
): Promise<never> {
throw await this.buildRunVersionConflict(id, expectedVersion);
}
private async buildRunVersionConflict(
id: string,
expectedVersion: number,
): Promise<AppError> {
const latest = await this.prisma.deployRun.findUnique({
where: { id },
select: {
status: true,
version: true,
updatedAt: true,
},
});
return this.createRunVersionConflict(id, expectedVersion, latest);
}
private createRunVersionConflict(
id: string,
expectedVersion: number,
latest?: {
status?: PrismaDeployRunStatus;
version?: number;
updatedAt?: Date;
} | null,
): AppError {
return new AppError(
'CONFLICT',
`发布记录 ${id} 已被其他流程更新,请刷新后重试`,
409,
{
id,
expectedVersion,
latestVersion: latest?.version,
latestStatus: latest?.status?.toLowerCase(),
latestUpdatedAt: latest?.updatedAt?.toISOString(),
},
);
}
private async findRecordById(id: string): Promise<DeployRunRecord | null> { private async findRecordById(id: string): Promise<DeployRunRecord | null> {
return this.prisma.deployRun.findUnique({ return this.prisma.deployRun.findUnique({
where: { id }, where: { id },
@@ -440,6 +526,7 @@ export class DeployRunRepository {
return { return {
id: idempotencyKey, id: idempotencyKey,
version: 1,
projectKey: input.projectKey, projectKey: input.projectKey,
environment: input.environment, environment: input.environment,
ref: input.ref, ref: input.ref,
@@ -497,6 +584,7 @@ export class DeployRunRepository {
private updateMemoryRun( private updateMemoryRun(
id: string, id: string,
patch: DeployRunMemoryPatch, patch: DeployRunMemoryPatch,
expectedVersion?: number,
): DeployRunSummary | null { ): DeployRunSummary | null {
const run = this.memoryRuns.find((item) => item.id === id); const run = this.memoryRuns.find((item) => item.id === id);
@@ -504,6 +592,7 @@ export class DeployRunRepository {
return null; return null;
} }
this.assertMemoryVersion(run, expectedVersion);
const now = new Date().toISOString(); const now = new Date().toISOString();
if (patch.status) { if (patch.status) {
@@ -542,6 +631,7 @@ export class DeployRunRepository {
run.finishedAt = patch.finishedAt; run.finishedAt = patch.finishedAt;
} }
run.version += 1;
return run; return run;
} }
@@ -549,6 +639,7 @@ export class DeployRunRepository {
runId: string, runId: string,
bpmnNodeId: string, bpmnNodeId: string,
patch: DeployRunStepMemoryPatch, patch: DeployRunStepMemoryPatch,
expectedRunVersion?: number,
): DeployRunSummary | null { ): DeployRunSummary | null {
const run = this.memoryRuns.find((item) => item.id === runId); const run = this.memoryRuns.find((item) => item.id === runId);
const step = run?.steps.find((item) => item.bpmnNodeId === bpmnNodeId); const step = run?.steps.find((item) => item.bpmnNodeId === bpmnNodeId);
@@ -557,6 +648,7 @@ export class DeployRunRepository {
return null; return null;
} }
this.assertMemoryVersion(run, expectedRunVersion);
const now = new Date().toISOString(); const now = new Date().toISOString();
if (patch.status) { if (patch.status) {
@@ -591,15 +683,38 @@ export class DeployRunRepository {
step.logExcerpt = patch.logExcerpt; step.logExcerpt = patch.logExcerpt;
} }
run.version += 1;
return run; return run;
} }
private assertMemoryVersion(
run: DeployRunSummary,
expectedVersion?: number,
): void {
if (expectedVersion === undefined || run.version === expectedVersion) {
return;
}
throw new AppError(
'CONFLICT',
`发布记录 ${run.id} 已被其他流程更新,请刷新后重试`,
409,
{
id: run.id,
expectedVersion,
latestVersion: run.version,
latestStatus: run.status,
},
);
}
private toSummary(run: DeployRunRecord): DeployRunSummary { private toSummary(run: DeployRunRecord): DeployRunSummary {
const metadata = this.objectMetadata<DeployRunMetadata>(run.metadata); const metadata = this.objectMetadata<DeployRunMetadata>(run.metadata);
const sortedSteps = [...run.steps].sort((left, right) => left.order - right.order); const sortedSteps = [...run.steps].sort((left, right) => left.order - right.order);
return { return {
id: run.id, id: run.id,
version: run.version,
projectKey: run.project.key, projectKey: run.project.key,
environment: run.environment.name === 'TEST' ? 'test' : 'production', environment: run.environment.name === 'TEST' ? 'test' : 'production',
ref: run.ref, ref: run.ref,
+1
View File
@@ -5,6 +5,7 @@ const now = new Date().toISOString();
export const seedDeployRuns: DeployRunSummary[] = [ export const seedDeployRuns: DeployRunSummary[] = [
{ {
id: 'run_seed_access_manage_test_001', id: 'run_seed_access_manage_test_001',
version: 1,
projectKey: 'access-manage', projectKey: 'access-manage',
environment: 'test', environment: 'test',
ref: 'develop', ref: 'develop',
+1
View File
@@ -23,6 +23,7 @@ export type DeployRunStepSummary = {
export type DeployRunSummary = { export type DeployRunSummary = {
id: string; id: string;
version: number;
projectKey: string; projectKey: string;
environment: DeployEnvironment; environment: DeployEnvironment;
ref: string; ref: string;
+13 -4
View File
@@ -165,17 +165,26 @@ export class DeployRunsService {
const jenkinsCancel = await this.cancelJenkinsExecution(source, project); const jenkinsCancel = await this.cancelJenkinsExecution(source, project);
let run = this.requireRun( let run = this.requireRun(
id, id,
await this.deployRunRepository.updateRun(id, { await this.deployRunRepository.updateRun(
id,
{
status: 'canceled', status: 'canceled',
}), },
source.version,
),
); );
run = this.requireRun( run = this.requireRun(
id, id,
await this.deployRunRepository.updateStep(id, 'jenkins-build', { await this.deployRunRepository.updateStep(
id,
'jenkins-build',
{
status: 'canceled', status: 'canceled',
message: undefined, message: undefined,
errorSummary: this.cancelSummary(jenkinsCancel), errorSummary: this.cancelSummary(jenkinsCancel),
}), },
run.version,
),
); );
await this.auditService.record({ await this.auditService.record({