mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 00:38:14 +08:00
278 lines
7.7 KiB
TypeScript
278 lines
7.7 KiB
TypeScript
import { v7 as uuidV7 } from 'uuid';
|
|
import type {
|
|
RunAttemptRecord,
|
|
RunCancellationReason,
|
|
RunEventActorType,
|
|
RunEventRecord,
|
|
RunRecord,
|
|
} from '../domain/run';
|
|
import {
|
|
isTerminalRunAttemptStatus,
|
|
requestRunCancellation,
|
|
transitionRun,
|
|
transitionRunAttempt,
|
|
type RunAttemptTransitionCommand,
|
|
type RunTransitionCommand,
|
|
} from '../domain/runStateMachine';
|
|
import { RunVersionConflictError } from '../domain/stateMachineErrors';
|
|
import type { RunRepository } from '../ports/runRepository';
|
|
import {
|
|
RunAttemptConcurrentWriteError,
|
|
RunAttemptNotFoundError,
|
|
RunNotFoundError,
|
|
} from './commandErrors';
|
|
|
|
export interface RunCommandActor {
|
|
type: RunEventActorType;
|
|
id?: string;
|
|
}
|
|
|
|
export interface TransitionRunCommand extends RunTransitionCommand {
|
|
runId: string;
|
|
actor: RunCommandActor;
|
|
dedupeKey?: string;
|
|
}
|
|
|
|
export interface TransitionRunAttemptCommand
|
|
extends RunAttemptTransitionCommand {
|
|
runId: string;
|
|
attemptId: string;
|
|
actor: RunCommandActor;
|
|
dedupeKey?: string;
|
|
}
|
|
|
|
export interface RunCommandResult {
|
|
run: RunRecord;
|
|
event: RunEventRecord;
|
|
}
|
|
|
|
export interface RunAttemptCommandResult extends RunCommandResult {
|
|
attempt: RunAttemptRecord;
|
|
}
|
|
|
|
export interface RequestRunCancellationCommand {
|
|
runId: string;
|
|
attemptId: string;
|
|
atMs: number;
|
|
reason: RunCancellationReason;
|
|
actor: RunCommandActor;
|
|
dedupeKey?: string;
|
|
}
|
|
|
|
export type RequestRunCancellationResult =
|
|
| {
|
|
status: 'accepted';
|
|
run: RunRecord;
|
|
attempt: RunAttemptRecord;
|
|
event: RunEventRecord;
|
|
}
|
|
| {
|
|
status: 'already_requested' | 'already_terminal';
|
|
run: RunRecord;
|
|
attempt: RunAttemptRecord;
|
|
};
|
|
|
|
export type RunEventIdFactory = () => string;
|
|
|
|
export class RunCommandService {
|
|
constructor(
|
|
private readonly repository: RunRepository,
|
|
private readonly createEventId: RunEventIdFactory = uuidV7,
|
|
) {}
|
|
|
|
async transitionRun(
|
|
command: TransitionRunCommand,
|
|
): Promise<RunCommandResult> {
|
|
return this.repository.transaction(async (transaction) => {
|
|
const current = await transaction.findRunById(command.runId);
|
|
if (!current) throw new RunNotFoundError(command.runId);
|
|
|
|
const decision = transitionRun(current, command);
|
|
const updated = await transaction.compareAndSetRun(
|
|
decision.run,
|
|
command.expectedVersion,
|
|
);
|
|
if (!updated) {
|
|
const latest = await transaction.findRunById(command.runId);
|
|
if (!latest) throw new RunNotFoundError(command.runId);
|
|
throw new RunVersionConflictError(
|
|
command.runId,
|
|
command.expectedVersion,
|
|
latest.version,
|
|
);
|
|
}
|
|
|
|
const event = this.createRunEvent({
|
|
runId: command.runId,
|
|
decision: decision.event,
|
|
actor: command.actor,
|
|
dedupeKey:
|
|
command.dedupeKey ??
|
|
`run-transition:${command.expectedVersion}:${command.to}`,
|
|
createdAtMs: command.atMs,
|
|
});
|
|
await transaction.appendEvent(event);
|
|
return { run: decision.run, event };
|
|
});
|
|
}
|
|
|
|
async transitionRunAttempt(
|
|
command: TransitionRunAttemptCommand,
|
|
): Promise<RunAttemptCommandResult> {
|
|
return this.repository.transaction(async (transaction) => {
|
|
const currentRun = await transaction.findRunById(command.runId);
|
|
if (!currentRun) throw new RunNotFoundError(command.runId);
|
|
|
|
const currentAttempt = await transaction.findAttemptById(
|
|
command.attemptId,
|
|
);
|
|
if (!currentAttempt) {
|
|
throw new RunAttemptNotFoundError(command.attemptId);
|
|
}
|
|
|
|
const decision = transitionRunAttempt(
|
|
currentRun,
|
|
currentAttempt,
|
|
command,
|
|
);
|
|
const runUpdated = await transaction.compareAndSetRun(
|
|
decision.run,
|
|
command.expectedRunVersion,
|
|
);
|
|
if (!runUpdated) {
|
|
const latest = await transaction.findRunById(command.runId);
|
|
if (!latest) throw new RunNotFoundError(command.runId);
|
|
throw new RunVersionConflictError(
|
|
command.runId,
|
|
command.expectedRunVersion,
|
|
latest.version,
|
|
);
|
|
}
|
|
|
|
const attemptUpdated = await transaction.compareAndSetAttempt(
|
|
decision.attempt,
|
|
{
|
|
status: currentAttempt.status,
|
|
callbackSequence: currentAttempt.callbackSequence,
|
|
},
|
|
);
|
|
if (!attemptUpdated) {
|
|
throw new RunAttemptConcurrentWriteError(
|
|
currentAttempt.id,
|
|
currentAttempt.status,
|
|
currentAttempt.callbackSequence,
|
|
);
|
|
}
|
|
|
|
const event = this.createRunEvent({
|
|
runId: command.runId,
|
|
attemptId: command.attemptId,
|
|
decision: decision.event,
|
|
actor: command.actor,
|
|
dedupeKey:
|
|
command.dedupeKey ??
|
|
`attempt-transition:${command.attemptId}:${command.expectedRunVersion}:${command.to}`,
|
|
createdAtMs: command.atMs,
|
|
});
|
|
await transaction.appendEvent(event);
|
|
return { run: decision.run, attempt: decision.attempt, event };
|
|
});
|
|
}
|
|
|
|
async requestCancellation(
|
|
command: RequestRunCancellationCommand,
|
|
): Promise<RequestRunCancellationResult> {
|
|
return this.repository.transaction(async (transaction) => {
|
|
const currentRun = await transaction.findRunById(command.runId);
|
|
if (!currentRun) throw new RunNotFoundError(command.runId);
|
|
|
|
const currentAttempt = await transaction.findAttemptById(
|
|
command.attemptId,
|
|
);
|
|
if (!currentAttempt) {
|
|
throw new RunAttemptNotFoundError(command.attemptId);
|
|
}
|
|
if (currentAttempt.runId !== currentRun.id) {
|
|
throw new RunAttemptNotFoundError(command.attemptId);
|
|
}
|
|
if (isTerminalRunAttemptStatus(currentAttempt.status)) {
|
|
return {
|
|
status: 'already_terminal',
|
|
run: currentRun,
|
|
attempt: currentAttempt,
|
|
};
|
|
}
|
|
|
|
const decision = requestRunCancellation(currentRun, {
|
|
expectedVersion: currentRun.version,
|
|
atMs: command.atMs,
|
|
reason: command.reason,
|
|
});
|
|
if (decision.status !== 'accepted') {
|
|
return {
|
|
status: decision.status,
|
|
run: decision.run,
|
|
attempt: currentAttempt,
|
|
};
|
|
}
|
|
|
|
const updated = await transaction.compareAndSetRun(
|
|
decision.run,
|
|
currentRun.version,
|
|
);
|
|
if (!updated) {
|
|
const latest = await transaction.findRunById(command.runId);
|
|
if (!latest) throw new RunNotFoundError(command.runId);
|
|
throw new RunVersionConflictError(
|
|
command.runId,
|
|
currentRun.version,
|
|
latest.version,
|
|
);
|
|
}
|
|
|
|
const event = this.createRunEvent({
|
|
runId: command.runId,
|
|
attemptId: command.attemptId,
|
|
decision: decision.event,
|
|
actor: command.actor,
|
|
dedupeKey:
|
|
command.dedupeKey ?? `run-cancel-request:${command.attemptId}`,
|
|
createdAtMs: command.atMs,
|
|
});
|
|
await transaction.appendEvent(event);
|
|
return {
|
|
status: 'accepted',
|
|
run: decision.run,
|
|
attempt: currentAttempt,
|
|
event,
|
|
};
|
|
});
|
|
}
|
|
|
|
private createRunEvent(input: {
|
|
runId: string;
|
|
attemptId?: string;
|
|
decision: {
|
|
sequence: number;
|
|
type: string;
|
|
payload: Readonly<Record<string, unknown>>;
|
|
};
|
|
actor: RunCommandActor;
|
|
dedupeKey: string;
|
|
createdAtMs: number;
|
|
}): RunEventRecord {
|
|
return {
|
|
id: this.createEventId(),
|
|
runId: input.runId,
|
|
sequence: input.decision.sequence,
|
|
type: input.decision.type,
|
|
dedupeKey: input.dedupeKey,
|
|
actorType: input.actor.type,
|
|
...(input.actor.id === undefined ? {} : { actorId: input.actor.id }),
|
|
...(input.attemptId === undefined ? {} : { attemptId: input.attemptId }),
|
|
payload: input.decision.payload,
|
|
createdAtMs: input.createdAtMs,
|
|
};
|
|
}
|
|
}
|