mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 00:38:14 +08:00
385 lines
12 KiB
TypeScript
385 lines
12 KiB
TypeScript
import { v7 as uuidV7 } from 'uuid';
|
|
import {
|
|
assertApprovedActionLeaseDuration,
|
|
assertApprovedActionLeaseIdentity,
|
|
assertApprovedActionPageSize,
|
|
assertApprovedActionResultCode,
|
|
type ApprovedActionDispatchCursor,
|
|
type ApprovedActionDispatchExecutionRecord,
|
|
} from '../domain/approvedActionDispatchExecution';
|
|
import type { ApprovedActionHandler } from '../ports/approvedActionHandler';
|
|
import type { ApprovedActionDispatchRepository } from '../ports/approvedActionDispatchRepository';
|
|
|
|
const DEFAULT_LEASE_DURATION_MS = 30_000;
|
|
const DEFAULT_RETRY_BASE_MS = 1_000;
|
|
const DEFAULT_RETRY_MAX_MS = 60_000;
|
|
|
|
export interface ApprovedActionDispatcherOptions {
|
|
owner: string;
|
|
leaseDurationMs?: number;
|
|
retryBaseMs?: number;
|
|
retryMaxMs?: number;
|
|
clock?: () => number;
|
|
createId?: () => string;
|
|
}
|
|
|
|
export interface ApprovedActionDispatchBatchSummary {
|
|
scanned: number;
|
|
claimed: number;
|
|
started: number;
|
|
succeeded: number;
|
|
failed: number;
|
|
blocked: number;
|
|
retrying: number;
|
|
deferred: number;
|
|
recoveryRequired: number;
|
|
alreadyTerminal: number;
|
|
unavailable: number;
|
|
truncated: boolean;
|
|
nextCursor?: Readonly<ApprovedActionDispatchCursor>;
|
|
}
|
|
|
|
function assertPositiveInteger(name: string, value: number): void {
|
|
if (!Number.isSafeInteger(value) || value < 1) {
|
|
throw new RangeError(`${name} must be a positive safe integer`);
|
|
}
|
|
}
|
|
|
|
function exactKeys(value: object, expected: readonly string[]): boolean {
|
|
const keys = Object.keys(value).sort();
|
|
const canonical = [...expected].sort();
|
|
return (
|
|
keys.length === canonical.length &&
|
|
keys.every((key, index) => key === canonical[index])
|
|
);
|
|
}
|
|
|
|
export class ApprovedActionDispatcher {
|
|
private readonly handlers = new Map<string, ApprovedActionHandler>();
|
|
private readonly owner: string;
|
|
private readonly leaseDurationMs: number;
|
|
private readonly retryBaseMs: number;
|
|
private readonly retryMaxMs: number;
|
|
private readonly clock: () => number;
|
|
private readonly createId: () => string;
|
|
|
|
constructor(
|
|
private readonly repository: ApprovedActionDispatchRepository,
|
|
handlers: readonly ApprovedActionHandler[],
|
|
options: ApprovedActionDispatcherOptions,
|
|
) {
|
|
assertApprovedActionLeaseIdentity(options.owner);
|
|
this.owner = options.owner;
|
|
this.leaseDurationMs = options.leaseDurationMs ?? DEFAULT_LEASE_DURATION_MS;
|
|
this.retryBaseMs = options.retryBaseMs ?? DEFAULT_RETRY_BASE_MS;
|
|
this.retryMaxMs = options.retryMaxMs ?? DEFAULT_RETRY_MAX_MS;
|
|
this.clock = options.clock ?? Date.now;
|
|
this.createId = options.createId ?? uuidV7;
|
|
assertApprovedActionLeaseDuration(this.leaseDurationMs);
|
|
assertPositiveInteger('retryBaseMs', this.retryBaseMs);
|
|
assertPositiveInteger('retryMaxMs', this.retryMaxMs);
|
|
if (this.retryMaxMs < this.retryBaseMs) {
|
|
throw new RangeError(
|
|
'retryMaxMs must be greater than or equal to retryBaseMs',
|
|
);
|
|
}
|
|
for (const handler of handlers) {
|
|
if (
|
|
!handler ||
|
|
typeof handler !== 'object' ||
|
|
typeof handler.actionType !== 'string' ||
|
|
handler.actionType.length < 1 ||
|
|
handler.actionType.length > 64 ||
|
|
typeof handler.inspect !== 'function' ||
|
|
typeof handler.execute !== 'function'
|
|
) {
|
|
throw new TypeError('Approved action handler is invalid');
|
|
}
|
|
if (this.handlers.has(handler.actionType)) {
|
|
throw new TypeError(
|
|
`Duplicate approved action handler: ${handler.actionType}`,
|
|
);
|
|
}
|
|
this.handlers.set(handler.actionType, handler);
|
|
}
|
|
}
|
|
|
|
async dispatchBatch(
|
|
options: { cursor?: ApprovedActionDispatchCursor; limit?: number } = {},
|
|
): Promise<Readonly<ApprovedActionDispatchBatchSummary>> {
|
|
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
|
throw new TypeError('Approved action dispatch options must be an object');
|
|
}
|
|
if (
|
|
!exactKeys(
|
|
options,
|
|
options.cursor === undefined && options.limit === undefined
|
|
? []
|
|
: [
|
|
...(options.cursor === undefined ? [] : ['cursor']),
|
|
...(options.limit === undefined ? [] : ['limit']),
|
|
],
|
|
)
|
|
) {
|
|
throw new TypeError('Approved action dispatch options shape is invalid');
|
|
}
|
|
const limit = options.limit ?? 16;
|
|
assertApprovedActionPageSize(limit);
|
|
const observedAtMs = this.now();
|
|
const page = await this.repository.listDue({
|
|
nowMs: observedAtMs,
|
|
limit,
|
|
...(options.cursor ? { cursor: options.cursor } : {}),
|
|
});
|
|
const summary: ApprovedActionDispatchBatchSummary = {
|
|
scanned: page.dispatches.length,
|
|
claimed: 0,
|
|
started: 0,
|
|
succeeded: 0,
|
|
failed: 0,
|
|
blocked: 0,
|
|
retrying: 0,
|
|
deferred: 0,
|
|
recoveryRequired: 0,
|
|
alreadyTerminal: 0,
|
|
unavailable: 0,
|
|
truncated: page.truncated,
|
|
...(page.nextCursor ? { nextCursor: page.nextCursor } : {}),
|
|
};
|
|
for (const candidate of page.dispatches) {
|
|
await this.dispatchOne(candidate.dispatch.id, summary);
|
|
}
|
|
return Object.freeze(summary);
|
|
}
|
|
|
|
private async dispatchOne(
|
|
dispatchId: string,
|
|
summary: ApprovedActionDispatchBatchSummary,
|
|
): Promise<void> {
|
|
const claimedAtMs = this.now();
|
|
let claim;
|
|
try {
|
|
claim = await this.repository.claim({
|
|
dispatchId,
|
|
owner: this.owner,
|
|
leaseToken: this.createId(),
|
|
nowMs: claimedAtMs,
|
|
leaseDurationMs: this.leaseDurationMs,
|
|
});
|
|
} catch {
|
|
summary.unavailable += 1;
|
|
return;
|
|
}
|
|
if (claim.status === 'not_found') {
|
|
summary.unavailable += 1;
|
|
return;
|
|
}
|
|
if (claim.status !== 'claimed') {
|
|
if (claim.status === 'recovery_required') summary.recoveryRequired += 1;
|
|
else if (
|
|
claim.status === 'succeeded' ||
|
|
claim.status === 'failed' ||
|
|
claim.status === 'blocked'
|
|
) {
|
|
summary.alreadyTerminal += 1;
|
|
} else summary.deferred += 1;
|
|
return;
|
|
}
|
|
summary.claimed += 1;
|
|
const handler = this.handlers.get(
|
|
claim.snapshot.dispatch.action.actionType,
|
|
);
|
|
if (!handler) {
|
|
await this.releasePreflight(
|
|
claim.snapshot.execution,
|
|
'handler_unavailable',
|
|
true,
|
|
summary,
|
|
);
|
|
return;
|
|
}
|
|
let inspection;
|
|
try {
|
|
inspection = await handler.inspect(claim.snapshot.dispatch);
|
|
this.assertInspection(inspection);
|
|
} catch {
|
|
await this.releasePreflight(
|
|
claim.snapshot.execution,
|
|
'handler_inspection_failed',
|
|
true,
|
|
summary,
|
|
);
|
|
return;
|
|
}
|
|
if (inspection.status !== 'ready') {
|
|
await this.releasePreflight(
|
|
claim.snapshot.execution,
|
|
inspection.resultCode,
|
|
inspection.status === 'retry',
|
|
summary,
|
|
);
|
|
return;
|
|
}
|
|
if (
|
|
inspection.actionDigest !== claim.snapshot.dispatch.action.actionDigest
|
|
) {
|
|
await this.releasePreflight(
|
|
claim.snapshot.execution,
|
|
'action_digest_mismatch',
|
|
false,
|
|
summary,
|
|
);
|
|
return;
|
|
}
|
|
|
|
let started;
|
|
try {
|
|
const startedAtMs = this.now();
|
|
started = await this.repository.start({
|
|
dispatchId,
|
|
approvalRequestId: claim.snapshot.dispatch.approvalRequestId,
|
|
actionDigest: inspection.actionDigest,
|
|
owner: this.owner,
|
|
leaseToken: claim.snapshot.execution.leaseToken!,
|
|
expectedVersion: claim.snapshot.execution.version,
|
|
startedAtMs,
|
|
});
|
|
summary.started += 1;
|
|
} catch {
|
|
summary.unavailable += 1;
|
|
return;
|
|
}
|
|
|
|
let outcome: 'succeeded' | 'failed' | 'indeterminate';
|
|
let resultCode: string;
|
|
try {
|
|
const result = await handler.execute(
|
|
Object.freeze({
|
|
dispatch: started.dispatch,
|
|
execution: started.execution,
|
|
idempotencyKey: started.dispatch.id,
|
|
fence: Object.freeze({
|
|
owner: this.owner,
|
|
leaseToken: started.execution.leaseToken!,
|
|
version: started.execution.version,
|
|
}),
|
|
}),
|
|
);
|
|
this.assertExecutionResult(result);
|
|
outcome = result.outcome;
|
|
resultCode = result.resultCode;
|
|
} catch {
|
|
outcome = 'indeterminate';
|
|
resultCode = 'handler_failed_after_start';
|
|
}
|
|
try {
|
|
const completed = await this.repository.complete({
|
|
dispatchId,
|
|
owner: this.owner,
|
|
leaseToken: started.execution.leaseToken!,
|
|
expectedVersion: started.execution.version,
|
|
resultMutationId: this.createId(),
|
|
outcome,
|
|
resultCode,
|
|
completedAtMs: this.now(),
|
|
});
|
|
if (completed.execution.status === 'succeeded') summary.succeeded += 1;
|
|
else if (completed.execution.status === 'failed') summary.failed += 1;
|
|
else summary.blocked += 1;
|
|
} catch {
|
|
summary.unavailable += 1;
|
|
summary.recoveryRequired += 1;
|
|
}
|
|
}
|
|
|
|
private async releasePreflight(
|
|
execution: Readonly<ApprovedActionDispatchExecutionRecord>,
|
|
resultCode: string,
|
|
retry: boolean,
|
|
summary: ApprovedActionDispatchBatchSummary,
|
|
): Promise<void> {
|
|
try {
|
|
const atMs = this.now();
|
|
const released = await this.repository.releaseBeforeStart({
|
|
dispatchId: execution.dispatchId,
|
|
owner: this.owner,
|
|
leaseToken: execution.leaseToken!,
|
|
expectedVersion: execution.version,
|
|
resultMutationId: this.createId(),
|
|
resultCode,
|
|
atMs,
|
|
...(retry
|
|
? { retryAtMs: this.nextRetryAt(atMs, execution.attemptCount) }
|
|
: {}),
|
|
});
|
|
if (released.execution.status === 'retry_wait') summary.retrying += 1;
|
|
else summary.blocked += 1;
|
|
} catch {
|
|
summary.unavailable += 1;
|
|
}
|
|
}
|
|
|
|
private assertInspection(
|
|
value: unknown,
|
|
): asserts value is Awaited<ReturnType<ApprovedActionHandler['inspect']>> {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
throw new TypeError('Approved action inspection is invalid');
|
|
}
|
|
if (
|
|
'status' in value &&
|
|
value.status === 'ready' &&
|
|
exactKeys(value, ['status', 'actionDigest']) &&
|
|
'actionDigest' in value &&
|
|
typeof value.actionDigest === 'string' &&
|
|
/^[0-9a-f]{64}$/.test(value.actionDigest)
|
|
) {
|
|
return;
|
|
}
|
|
if (
|
|
'status' in value &&
|
|
(value.status === 'retry' || value.status === 'blocked') &&
|
|
exactKeys(value, ['status', 'resultCode']) &&
|
|
'resultCode' in value &&
|
|
typeof value.resultCode === 'string'
|
|
) {
|
|
assertApprovedActionResultCode(value.resultCode);
|
|
return;
|
|
}
|
|
throw new TypeError('Approved action inspection is invalid');
|
|
}
|
|
|
|
private assertExecutionResult(
|
|
value: unknown,
|
|
): asserts value is Awaited<ReturnType<ApprovedActionHandler['execute']>> {
|
|
if (
|
|
!value ||
|
|
typeof value !== 'object' ||
|
|
Array.isArray(value) ||
|
|
!exactKeys(value, ['outcome', 'resultCode']) ||
|
|
!('outcome' in value) ||
|
|
!['succeeded', 'failed', 'indeterminate'].includes(
|
|
value.outcome as string,
|
|
) ||
|
|
!('resultCode' in value) ||
|
|
typeof value.resultCode !== 'string'
|
|
) {
|
|
throw new TypeError('Approved action execution result is invalid');
|
|
}
|
|
assertApprovedActionResultCode(value.resultCode);
|
|
}
|
|
|
|
private nextRetryAt(atMs: number, attemptCount: number): number {
|
|
const exponent = Math.max(0, Math.min(attemptCount - 1, 30));
|
|
const delay = Math.min(this.retryMaxMs, this.retryBaseMs * 2 ** exponent);
|
|
return Math.min(Number.MAX_SAFE_INTEGER, atMs + delay);
|
|
}
|
|
|
|
private now(): number {
|
|
const nowMs = this.clock();
|
|
if (!Number.isSafeInteger(nowMs) || nowMs < 0) {
|
|
throw new RangeError('clock must return a non-negative safe integer');
|
|
}
|
|
return nowMs;
|
|
}
|
|
}
|