mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
431 lines
12 KiB
TypeScript
431 lines
12 KiB
TypeScript
import {
|
|
assertApprovalMutationId,
|
|
assertApprovalTimestamp,
|
|
type ApprovedActionDispatchRecord,
|
|
} from './approvalRequest';
|
|
import { assertProjectPolicyProjectId } from './projectPolicy';
|
|
|
|
export const APPROVED_ACTION_EXECUTION_STATUSES = [
|
|
'pending',
|
|
'leased',
|
|
'executing',
|
|
'retry_wait',
|
|
'succeeded',
|
|
'failed',
|
|
'blocked',
|
|
] as const;
|
|
|
|
export type ApprovedActionExecutionStatus =
|
|
(typeof APPROVED_ACTION_EXECUTION_STATUSES)[number];
|
|
export type ApprovedActionExecutionEffectiveStatus =
|
|
| ApprovedActionExecutionStatus
|
|
| 'recovery_required';
|
|
|
|
export interface ApprovedActionDispatchExecutionRecord {
|
|
dispatchId: string;
|
|
projectId: string;
|
|
status: ApprovedActionExecutionStatus;
|
|
version: number;
|
|
attemptCount: number;
|
|
maxAttempts: number;
|
|
eligibleAtMs: number | null;
|
|
nextAttemptAtMs: number | null;
|
|
leaseOwner: string | null;
|
|
leaseToken: string | null;
|
|
leaseExpiresAtMs: number | null;
|
|
startedAtMs: number | null;
|
|
resultMutationId: string | null;
|
|
lastResultCode: string | null;
|
|
completedAtMs: number | null;
|
|
createdAtMs: number;
|
|
updatedAtMs: number;
|
|
}
|
|
|
|
export interface ApprovedActionDispatchExecutionSnapshot {
|
|
dispatch: Readonly<ApprovedActionDispatchRecord>;
|
|
execution: Readonly<ApprovedActionDispatchExecutionRecord>;
|
|
}
|
|
|
|
export interface ApprovedActionDispatchCursor {
|
|
eligibleAtMs: number;
|
|
dispatchId: string;
|
|
}
|
|
|
|
export const DEFAULT_APPROVED_ACTION_MAX_ATTEMPTS = 5;
|
|
export const MAX_APPROVED_ACTION_ATTEMPTS = 16;
|
|
export const MAX_APPROVED_ACTION_EXECUTION_VERSION = 2_147_483_647;
|
|
export const MAX_APPROVED_ACTION_LEASE_ID_LENGTH = 128;
|
|
export const MAX_APPROVED_ACTION_RESULT_CODE_LENGTH = 64;
|
|
export const MAX_APPROVED_ACTION_DISPATCH_PAGE_SIZE = 64;
|
|
export const MAX_APPROVED_ACTION_LEASE_DURATION_MS = 10 * 60 * 1000;
|
|
|
|
const EXECUTION_IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/;
|
|
|
|
export class InvalidApprovedActionExecutionError extends TypeError {
|
|
constructor(message: string) {
|
|
super(`Approved action execution is invalid: ${message}`);
|
|
this.name = 'InvalidApprovedActionExecutionError';
|
|
}
|
|
}
|
|
|
|
export class ApprovedActionDispatchNotFoundError extends Error {
|
|
readonly code = 'APPROVED_ACTION_DISPATCH_NOT_FOUND';
|
|
|
|
constructor() {
|
|
super('Approved action dispatch does not exist');
|
|
this.name = 'ApprovedActionDispatchNotFoundError';
|
|
}
|
|
}
|
|
|
|
export class ApprovedActionDispatchFenceRejectedError extends Error {
|
|
readonly code = 'APPROVED_ACTION_DISPATCH_FENCE_REJECTED';
|
|
|
|
constructor() {
|
|
super('Approved action dispatch execution fence was rejected');
|
|
this.name = 'ApprovedActionDispatchFenceRejectedError';
|
|
}
|
|
}
|
|
|
|
export class ApprovedActionDispatchStateConflictError extends Error {
|
|
readonly code = 'APPROVED_ACTION_DISPATCH_STATE_CONFLICT';
|
|
|
|
constructor() {
|
|
super('Approved action dispatch is not in the required execution state');
|
|
this.name = 'ApprovedActionDispatchStateConflictError';
|
|
}
|
|
}
|
|
|
|
export class ApprovedActionDispatchBindingConflictError extends Error {
|
|
readonly code = 'APPROVED_ACTION_DISPATCH_BINDING_CONFLICT';
|
|
|
|
constructor() {
|
|
super('Approved action dispatch identity does not match its execution');
|
|
this.name = 'ApprovedActionDispatchBindingConflictError';
|
|
}
|
|
}
|
|
|
|
export class ApprovedActionDispatchRepositoryError extends Error {
|
|
readonly code = 'APPROVED_ACTION_DISPATCH_REPOSITORY_ERROR';
|
|
|
|
constructor() {
|
|
super('Approved action dispatch repository is unavailable');
|
|
this.name = 'ApprovedActionDispatchRepositoryError';
|
|
}
|
|
}
|
|
|
|
function assertExactKeys(
|
|
name: string,
|
|
value: object,
|
|
expected: readonly string[],
|
|
): void {
|
|
const keys = Object.keys(value).sort();
|
|
const canonical = [...expected].sort();
|
|
if (
|
|
keys.length !== canonical.length ||
|
|
keys.some((key, index) => key !== canonical[index])
|
|
) {
|
|
throw new InvalidApprovedActionExecutionError(`${name} shape is invalid`);
|
|
}
|
|
}
|
|
|
|
function assertInteger(
|
|
name: string,
|
|
value: number,
|
|
minimum: number,
|
|
maximum: number,
|
|
): void {
|
|
if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
|
|
throw new InvalidApprovedActionExecutionError(`${name} is invalid`);
|
|
}
|
|
}
|
|
|
|
function assertNullableTimestamp(name: string, value: number | null): void {
|
|
if (value !== null) assertApprovalTimestamp(name, value);
|
|
}
|
|
|
|
function assertNullableIdentifier(
|
|
name: string,
|
|
value: string | null,
|
|
maximum: number,
|
|
): void {
|
|
if (
|
|
value !== null &&
|
|
(typeof value !== 'string' ||
|
|
value.length < 1 ||
|
|
value.length > maximum ||
|
|
!EXECUTION_IDENTIFIER_PATTERN.test(value))
|
|
) {
|
|
throw new InvalidApprovedActionExecutionError(`${name} is invalid`);
|
|
}
|
|
}
|
|
|
|
function allNull(values: readonly unknown[]): boolean {
|
|
return values.every((value) => value === null);
|
|
}
|
|
|
|
function allPresent(values: readonly unknown[]): boolean {
|
|
return values.every((value) => value !== null);
|
|
}
|
|
|
|
export function assertApprovedActionLeaseIdentity(value: string): void {
|
|
if (typeof value !== 'string') {
|
|
throw new InvalidApprovedActionExecutionError('lease identity is invalid');
|
|
}
|
|
assertNullableIdentifier(
|
|
'lease identity',
|
|
value,
|
|
MAX_APPROVED_ACTION_LEASE_ID_LENGTH,
|
|
);
|
|
}
|
|
|
|
export function assertApprovedActionResultCode(value: string): void {
|
|
if (typeof value !== 'string') {
|
|
throw new InvalidApprovedActionExecutionError('result code is invalid');
|
|
}
|
|
assertNullableIdentifier(
|
|
'result code',
|
|
value,
|
|
MAX_APPROVED_ACTION_RESULT_CODE_LENGTH,
|
|
);
|
|
}
|
|
|
|
export function assertApprovedActionLeaseDuration(value: number): void {
|
|
assertInteger(
|
|
'lease duration',
|
|
value,
|
|
1,
|
|
MAX_APPROVED_ACTION_LEASE_DURATION_MS,
|
|
);
|
|
}
|
|
|
|
export function assertApprovedActionPageSize(value: number): void {
|
|
assertInteger('page size', value, 1, MAX_APPROVED_ACTION_DISPATCH_PAGE_SIZE);
|
|
}
|
|
|
|
export function normalizeApprovedActionDispatchCursor(
|
|
value: ApprovedActionDispatchCursor,
|
|
): Readonly<ApprovedActionDispatchCursor> {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
throw new InvalidApprovedActionExecutionError('cursor must be an object');
|
|
}
|
|
assertExactKeys('cursor', value, ['eligibleAtMs', 'dispatchId']);
|
|
assertApprovalTimestamp('cursor eligibleAtMs', value.eligibleAtMs);
|
|
assertApprovalMutationId(value.dispatchId);
|
|
return Object.freeze({ ...value });
|
|
}
|
|
|
|
export function normalizeApprovedActionDispatchExecutionRecord(
|
|
value: ApprovedActionDispatchExecutionRecord,
|
|
): Readonly<ApprovedActionDispatchExecutionRecord> {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
throw new InvalidApprovedActionExecutionError('record must be an object');
|
|
}
|
|
assertExactKeys('record', value, [
|
|
'dispatchId',
|
|
'projectId',
|
|
'status',
|
|
'version',
|
|
'attemptCount',
|
|
'maxAttempts',
|
|
'eligibleAtMs',
|
|
'nextAttemptAtMs',
|
|
'leaseOwner',
|
|
'leaseToken',
|
|
'leaseExpiresAtMs',
|
|
'startedAtMs',
|
|
'resultMutationId',
|
|
'lastResultCode',
|
|
'completedAtMs',
|
|
'createdAtMs',
|
|
'updatedAtMs',
|
|
]);
|
|
assertApprovalMutationId(value.dispatchId);
|
|
assertProjectPolicyProjectId(value.projectId);
|
|
if (!APPROVED_ACTION_EXECUTION_STATUSES.includes(value.status)) {
|
|
throw new InvalidApprovedActionExecutionError('status is invalid');
|
|
}
|
|
assertInteger(
|
|
'version',
|
|
value.version,
|
|
0,
|
|
MAX_APPROVED_ACTION_EXECUTION_VERSION,
|
|
);
|
|
assertInteger(
|
|
'attempt count',
|
|
value.attemptCount,
|
|
0,
|
|
MAX_APPROVED_ACTION_ATTEMPTS,
|
|
);
|
|
assertInteger(
|
|
'max attempts',
|
|
value.maxAttempts,
|
|
1,
|
|
MAX_APPROVED_ACTION_ATTEMPTS,
|
|
);
|
|
if (value.attemptCount > value.maxAttempts) {
|
|
throw new InvalidApprovedActionExecutionError(
|
|
'attempt count exceeds its maximum',
|
|
);
|
|
}
|
|
for (const [name, timestamp] of [
|
|
['eligibleAtMs', value.eligibleAtMs],
|
|
['nextAttemptAtMs', value.nextAttemptAtMs],
|
|
['leaseExpiresAtMs', value.leaseExpiresAtMs],
|
|
['startedAtMs', value.startedAtMs],
|
|
['completedAtMs', value.completedAtMs],
|
|
] as const) {
|
|
assertNullableTimestamp(name, timestamp);
|
|
}
|
|
assertApprovalTimestamp('createdAtMs', value.createdAtMs);
|
|
assertApprovalTimestamp('updatedAtMs', value.updatedAtMs);
|
|
if (value.updatedAtMs < value.createdAtMs) {
|
|
throw new InvalidApprovedActionExecutionError('timestamps are invalid');
|
|
}
|
|
assertNullableIdentifier(
|
|
'lease owner',
|
|
value.leaseOwner,
|
|
MAX_APPROVED_ACTION_LEASE_ID_LENGTH,
|
|
);
|
|
assertNullableIdentifier(
|
|
'lease token',
|
|
value.leaseToken,
|
|
MAX_APPROVED_ACTION_LEASE_ID_LENGTH,
|
|
);
|
|
assertNullableIdentifier('result mutation id', value.resultMutationId, 64);
|
|
assertNullableIdentifier(
|
|
'last result code',
|
|
value.lastResultCode,
|
|
MAX_APPROVED_ACTION_RESULT_CODE_LENGTH,
|
|
);
|
|
|
|
const leaseTuple = [
|
|
value.leaseOwner,
|
|
value.leaseToken,
|
|
value.leaseExpiresAtMs,
|
|
];
|
|
if (!allNull(leaseTuple) && !allPresent(leaseTuple)) {
|
|
throw new InvalidApprovedActionExecutionError('lease tuple is incomplete');
|
|
}
|
|
const hasLease = allPresent(leaseTuple);
|
|
if (
|
|
hasLease &&
|
|
(value.leaseExpiresAtMs! <= value.updatedAtMs ||
|
|
value.leaseExpiresAtMs! - value.updatedAtMs >
|
|
MAX_APPROVED_ACTION_LEASE_DURATION_MS)
|
|
) {
|
|
throw new InvalidApprovedActionExecutionError('lease lifetime is invalid');
|
|
}
|
|
|
|
const terminal = ['succeeded', 'failed', 'blocked'].includes(value.status);
|
|
if (
|
|
value.status === 'pending' &&
|
|
(value.version !== 0 ||
|
|
value.attemptCount !== 0 ||
|
|
value.eligibleAtMs === null ||
|
|
value.nextAttemptAtMs !== null ||
|
|
hasLease ||
|
|
value.startedAtMs !== null ||
|
|
value.resultMutationId !== null ||
|
|
value.lastResultCode !== null ||
|
|
value.completedAtMs !== null)
|
|
) {
|
|
throw new InvalidApprovedActionExecutionError('pending tuple is invalid');
|
|
}
|
|
if (
|
|
value.status === 'leased' &&
|
|
(!hasLease ||
|
|
value.attemptCount < 1 ||
|
|
value.eligibleAtMs !== value.leaseExpiresAtMs ||
|
|
value.nextAttemptAtMs !== null ||
|
|
value.startedAtMs !== null ||
|
|
value.resultMutationId !== null ||
|
|
value.lastResultCode !== null ||
|
|
value.completedAtMs !== null)
|
|
) {
|
|
throw new InvalidApprovedActionExecutionError('leased tuple is invalid');
|
|
}
|
|
if (
|
|
value.status === 'executing' &&
|
|
(!hasLease ||
|
|
value.attemptCount < 1 ||
|
|
value.eligibleAtMs !== null ||
|
|
value.nextAttemptAtMs !== null ||
|
|
value.startedAtMs === null ||
|
|
value.resultMutationId !== null ||
|
|
value.lastResultCode !== null ||
|
|
value.completedAtMs !== null)
|
|
) {
|
|
throw new InvalidApprovedActionExecutionError('executing tuple is invalid');
|
|
}
|
|
if (
|
|
value.status === 'retry_wait' &&
|
|
(hasLease ||
|
|
value.attemptCount < 1 ||
|
|
value.attemptCount >= value.maxAttempts ||
|
|
value.eligibleAtMs === null ||
|
|
value.eligibleAtMs !== value.nextAttemptAtMs ||
|
|
value.startedAtMs !== null ||
|
|
value.resultMutationId === null ||
|
|
value.lastResultCode === null ||
|
|
value.completedAtMs !== null)
|
|
) {
|
|
throw new InvalidApprovedActionExecutionError(
|
|
'retry wait tuple is invalid',
|
|
);
|
|
}
|
|
if (
|
|
terminal &&
|
|
(hasLease ||
|
|
value.eligibleAtMs !== null ||
|
|
value.nextAttemptAtMs !== null ||
|
|
value.resultMutationId === null ||
|
|
value.lastResultCode === null ||
|
|
value.completedAtMs === null)
|
|
) {
|
|
throw new InvalidApprovedActionExecutionError('terminal tuple is invalid');
|
|
}
|
|
if (
|
|
(value.status === 'succeeded' || value.status === 'failed') &&
|
|
value.startedAtMs === null
|
|
) {
|
|
throw new InvalidApprovedActionExecutionError(
|
|
'completed execution has no start barrier',
|
|
);
|
|
}
|
|
if (
|
|
value.startedAtMs !== null &&
|
|
(value.startedAtMs < value.createdAtMs ||
|
|
value.startedAtMs > value.updatedAtMs)
|
|
) {
|
|
throw new InvalidApprovedActionExecutionError(
|
|
'execution start timestamp is invalid',
|
|
);
|
|
}
|
|
if (
|
|
value.completedAtMs !== null &&
|
|
(value.completedAtMs < (value.startedAtMs ?? value.createdAtMs) ||
|
|
value.completedAtMs !== value.updatedAtMs)
|
|
) {
|
|
throw new InvalidApprovedActionExecutionError(
|
|
'completion timestamp is invalid',
|
|
);
|
|
}
|
|
return Object.freeze({ ...value });
|
|
}
|
|
|
|
export function approvedActionExecutionEffectiveStatus(
|
|
record: Readonly<ApprovedActionDispatchExecutionRecord>,
|
|
nowMs: number,
|
|
): ApprovedActionExecutionEffectiveStatus {
|
|
const normalized = normalizeApprovedActionDispatchExecutionRecord(record);
|
|
assertApprovalTimestamp('nowMs', nowMs);
|
|
if (
|
|
normalized.status === 'executing' &&
|
|
normalized.leaseExpiresAtMs !== null &&
|
|
nowMs >= normalized.leaseExpiresAtMs
|
|
) {
|
|
return 'recovery_required';
|
|
}
|
|
return normalized.status;
|
|
}
|