feat(ql3): add postgres cancellation dispatch

This commit is contained in:
whyour
2026-08-19 06:24:11 +08:00
parent 36035ac43e
commit 1809fbb8d3
29 changed files with 2265 additions and 133 deletions
+8
View File
@@ -41,6 +41,9 @@
"run-cancellation": [
"dist/run/clusterRunCancellation.d.ts"
],
"cancellation-dispatch": [
"dist/run/cancellation-dispatch/cancellationDispatch.d.ts"
],
"task-start": [
"dist/task-start/taskStart.d.ts"
],
@@ -776,6 +779,11 @@
"require": "./dist/run/clusterRunCancellation.js",
"default": "./dist/run/clusterRunCancellation.js"
},
"./cancellation-dispatch": {
"types": "./dist/run/cancellation-dispatch/cancellationDispatch.d.ts",
"require": "./dist/run/cancellation-dispatch/cancellationDispatch.js",
"default": "./dist/run/cancellation-dispatch/cancellationDispatch.js"
},
"./task-start": {
"types": "./dist/task-start/taskStart.d.ts",
"require": "./dist/task-start/taskStart.js",
@@ -0,0 +1,496 @@
import { createHash } from 'node:crypto';
import type { RunEventRecord } from '../run';
export const CANCELLATION_DISPATCH_STATUSES = Object.freeze([
'pending',
'leased',
'retry_wait',
'dispatched',
'blocked',
] as const);
export type CancellationDispatchStatus =
(typeof CANCELLATION_DISPATCH_STATUSES)[number];
export const CANCELLATION_DISPATCH_RESULTS = Object.freeze([
'termination_requested',
'already_exited',
'identity_mismatch',
'pid_mismatch',
'unsupported',
'invalid',
'controller_missing',
'handle_missing',
'dispatch_error',
] as const);
export type CancellationDispatchResult =
(typeof CANCELLATION_DISPATCH_RESULTS)[number];
export const CANCELLATION_DISPATCH_RETRYABLE_RESULTS = Object.freeze([
'controller_missing',
'handle_missing',
'dispatch_error',
] as const satisfies readonly CancellationDispatchResult[]);
export const CANCELLATION_DISPATCH_BLOCKING_RESULTS = Object.freeze([
'identity_mismatch',
'pid_mismatch',
'unsupported',
'invalid',
] as const satisfies readonly CancellationDispatchResult[]);
export const MAX_CANCELLATION_DISPATCH_LEASE_MS = 5 * 60_000;
export const MAX_CANCELLATION_DISPATCH_RETRY_DELAY_MS = 24 * 60 * 60_000;
export interface CancellationDispatchRecord {
readonly runId: string;
readonly attemptId: string;
readonly status: CancellationDispatchStatus;
readonly version: number;
readonly dispatchCount: number;
readonly nextAttemptAtMs?: number;
readonly leaseOwner?: string;
readonly leaseTokenDigest?: string;
readonly leaseExpiresAtMs?: number;
readonly lastResult?: CancellationDispatchResult;
readonly lastDispatchedAtMs?: number;
readonly createdAtMs: number;
readonly updatedAtMs: number;
}
export interface ClaimCancellationDispatchCommand {
readonly runId: string;
readonly attemptId: string;
readonly requestedAtMs: number;
readonly owner: string;
readonly leaseToken: string;
readonly leaseDurationMs: number;
}
export type ClaimCancellationDispatchResult =
| Readonly<{
status: 'claimed';
dispatch: Readonly<CancellationDispatchRecord>;
leaseToken: string;
}>
| Readonly<{ status: 'not_eligible' }>
| Readonly<{
status: 'not_due' | 'leased' | 'dispatched' | 'blocked';
dispatch: Readonly<CancellationDispatchRecord>;
}>;
export interface RecordCancellationDispatchResultCommand {
readonly runId: string;
readonly attemptId: string;
readonly owner: string;
readonly leaseToken: string;
readonly expectedVersion: number;
readonly result: CancellationDispatchResult;
readonly retryDelayMs?: number;
readonly eventId: string;
}
export interface RecordCancellationDispatchResult {
readonly dispatch: Readonly<CancellationDispatchRecord>;
readonly event: Readonly<RunEventRecord>;
}
export interface CancellationDispatchRepository {
findByRunId(
runId: string,
): Promise<Readonly<CancellationDispatchRecord> | null>;
claim(
command: Readonly<ClaimCancellationDispatchCommand>,
): Promise<ClaimCancellationDispatchResult>;
recordResult(
command: Readonly<RecordCancellationDispatchResultCommand>,
): Promise<Readonly<RecordCancellationDispatchResult>>;
}
export class CancellationDispatchError extends Error {
constructor(
message: string,
readonly code: string,
readonly retryable = false,
options?: ErrorOptions,
) {
super(message, options);
this.name = new.target.name;
}
}
export class InvalidCancellationDispatchCommandError extends CancellationDispatchError {
constructor(message: string) {
super(message, 'INVALID_CANCELLATION_DISPATCH_COMMAND');
}
}
export class CancellationDispatchBindingConflictError extends CancellationDispatchError {
constructor(runId: string, attemptId: string) {
super(
`Cancellation dispatch for Run ${runId} is already bound to another Attempt than ${attemptId}`,
'CANCELLATION_DISPATCH_BINDING_CONFLICT',
);
}
}
export class CancellationDispatchFenceRejectedError extends CancellationDispatchError {
constructor(runId: string) {
super(
`Cancellation dispatch lease for Run ${runId} is stale or no longer owned by this worker`,
'CANCELLATION_DISPATCH_FENCE_REJECTED',
);
}
}
export class CancellationDispatchRepositoryError extends CancellationDispatchError {
constructor(cause?: unknown) {
super(
'Cancellation dispatch repository operation failed',
'CANCELLATION_DISPATCH_REPOSITORY_FAILED',
false,
{ cause },
);
}
}
const SHA256_PATTERN = /^[a-f0-9]{64}$/u;
function record(value: unknown, name: string): Record<string, unknown> {
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
throw new InvalidCancellationDispatchCommandError(`${name} is invalid`);
}
return value as Record<string, unknown>;
}
function exactKeys(
value: Record<string, unknown>,
expected: readonly string[],
name: string,
): void {
const actual = Object.keys(value).sort();
const canonical = [...expected].sort();
if (
actual.length !== canonical.length ||
actual.some((key, index) => key !== canonical[index])
) {
throw new InvalidCancellationDispatchCommandError(
`${name} shape is invalid`,
);
}
}
function identifier(
value: unknown,
name: string,
maximum: number,
): string {
if (
typeof value !== 'string' ||
value.length < 1 ||
value.length > maximum ||
/[\u0000-\u001f\u007f]/u.test(value)
) {
throw new InvalidCancellationDispatchCommandError(`${name} is invalid`);
}
return value;
}
function integer(
value: unknown,
name: string,
minimum: number,
maximum = Number.MAX_SAFE_INTEGER,
): number {
if (
typeof value !== 'number' ||
!Number.isSafeInteger(value) ||
value < minimum ||
value > maximum
) {
throw new InvalidCancellationDispatchCommandError(`${name} is invalid`);
}
return value;
}
export function digestCancellationDispatchLeaseToken(value: string): string {
const token = identifier(value, 'leaseToken', 128);
return createHash('sha256')
.update('qinglong.cancellation-dispatch-lease.v1\0', 'utf8')
.update(token, 'utf8')
.digest('hex');
}
export function normalizeCancellationDispatchRunId(value: string): string {
return identifier(value, 'runId', 36);
}
export function normalizeClaimCancellationDispatchCommand(
value: Readonly<ClaimCancellationDispatchCommand>,
): Readonly<ClaimCancellationDispatchCommand> {
const command = record(value, 'claim command');
exactKeys(
command,
[
'runId',
'attemptId',
'requestedAtMs',
'owner',
'leaseToken',
'leaseDurationMs',
],
'claim command',
);
return Object.freeze({
runId: identifier(command.runId, 'runId', 36),
attemptId: identifier(command.attemptId, 'attemptId', 36),
requestedAtMs: integer(command.requestedAtMs, 'requestedAtMs', 0),
owner: identifier(command.owner, 'owner', 128),
leaseToken: identifier(command.leaseToken, 'leaseToken', 128),
leaseDurationMs: integer(
command.leaseDurationMs,
'leaseDurationMs',
1,
MAX_CANCELLATION_DISPATCH_LEASE_MS,
),
});
}
export function normalizeRecordCancellationDispatchResultCommand(
value: Readonly<RecordCancellationDispatchResultCommand>,
): Readonly<RecordCancellationDispatchResultCommand> {
const command = record(value, 'result command');
const retryable = CANCELLATION_DISPATCH_RETRYABLE_RESULTS.includes(
command.result as (typeof CANCELLATION_DISPATCH_RETRYABLE_RESULTS)[number],
);
exactKeys(
command,
[
'runId',
'attemptId',
'owner',
'leaseToken',
'expectedVersion',
'result',
...(retryable ? ['retryDelayMs'] : []),
'eventId',
],
'result command',
);
if (
!CANCELLATION_DISPATCH_RESULTS.includes(
command.result as CancellationDispatchResult,
)
) {
throw new InvalidCancellationDispatchCommandError('result is invalid');
}
return Object.freeze({
runId: identifier(command.runId, 'runId', 36),
attemptId: identifier(command.attemptId, 'attemptId', 36),
owner: identifier(command.owner, 'owner', 128),
leaseToken: identifier(command.leaseToken, 'leaseToken', 128),
expectedVersion: integer(
command.expectedVersion,
'expectedVersion',
1,
2_147_483_647,
),
result: command.result as CancellationDispatchResult,
...(retryable
? {
retryDelayMs: integer(
command.retryDelayMs,
'retryDelayMs',
1,
MAX_CANCELLATION_DISPATCH_RETRY_DELAY_MS,
),
}
: {}),
eventId: identifier(command.eventId, 'eventId', 36),
});
}
export function cancellationDispatchResultState(
result: CancellationDispatchResult,
): Readonly<{
status: Extract<
CancellationDispatchStatus,
'retry_wait' | 'dispatched' | 'blocked'
>;
eventType:
| 'run.cancel_dispatch_failed'
| 'run.cancel_dispatched'
| 'run.cancel_dispatch_blocked';
}> {
if (
CANCELLATION_DISPATCH_RETRYABLE_RESULTS.includes(
result as (typeof CANCELLATION_DISPATCH_RETRYABLE_RESULTS)[number],
)
) {
return Object.freeze({
status: 'retry_wait',
eventType: 'run.cancel_dispatch_failed',
});
}
if (
CANCELLATION_DISPATCH_BLOCKING_RESULTS.includes(
result as (typeof CANCELLATION_DISPATCH_BLOCKING_RESULTS)[number],
)
) {
return Object.freeze({
status: 'blocked',
eventType: 'run.cancel_dispatch_blocked',
});
}
if (!CANCELLATION_DISPATCH_RESULTS.includes(result)) {
throw new InvalidCancellationDispatchCommandError('result is invalid');
}
return Object.freeze({
status: 'dispatched',
eventType: 'run.cancel_dispatched',
});
}
export function normalizeCancellationDispatchRecord(
value: CancellationDispatchRecord,
): Readonly<CancellationDispatchRecord> {
const dispatch = record(value, 'dispatch record');
const optionalKeys = [
'nextAttemptAtMs',
'leaseOwner',
'leaseTokenDigest',
'leaseExpiresAtMs',
'lastResult',
'lastDispatchedAtMs',
].filter((key) => dispatch[key] !== undefined);
exactKeys(
dispatch,
[
'runId',
'attemptId',
'status',
'version',
'dispatchCount',
...optionalKeys,
'createdAtMs',
'updatedAtMs',
],
'dispatch record',
);
if (
!CANCELLATION_DISPATCH_STATUSES.includes(
dispatch.status as CancellationDispatchStatus,
)
) {
throw new InvalidCancellationDispatchCommandError('status is invalid');
}
const status = dispatch.status as CancellationDispatchStatus;
const createdAtMs = integer(dispatch.createdAtMs, 'createdAtMs', 0);
const updatedAtMs = integer(dispatch.updatedAtMs, 'updatedAtMs', createdAtMs);
const version = integer(dispatch.version, 'version', 0, 2_147_483_647);
const dispatchCount = integer(
dispatch.dispatchCount,
'dispatchCount',
0,
2_147_483_647,
);
const lastResult = dispatch.lastResult as
| CancellationDispatchResult
| undefined;
const hasLease =
dispatch.leaseOwner !== undefined ||
dispatch.leaseTokenDigest !== undefined ||
dispatch.leaseExpiresAtMs !== undefined;
if (
(status === 'leased' &&
(typeof dispatch.leaseTokenDigest !== 'string' ||
!SHA256_PATTERN.test(dispatch.leaseTokenDigest) ||
dispatch.leaseOwner === undefined ||
dispatch.leaseExpiresAtMs === undefined)) ||
(status !== 'leased' && hasLease) ||
((status === 'pending' || status === 'retry_wait') &&
dispatch.nextAttemptAtMs === undefined) ||
((status === 'leased' || status === 'dispatched' || status === 'blocked') &&
dispatch.nextAttemptAtMs !== undefined) ||
((status === 'dispatched' || status === 'blocked') &&
lastResult === undefined) ||
(status === 'pending' &&
(version !== 0 || dispatchCount !== 0 || lastResult !== undefined)) ||
(status !== 'pending' && dispatchCount < 1) ||
version < dispatchCount ||
(status === 'leased' &&
lastResult !== undefined &&
!CANCELLATION_DISPATCH_RETRYABLE_RESULTS.includes(
lastResult as (typeof CANCELLATION_DISPATCH_RETRYABLE_RESULTS)[number],
)) ||
(status === 'retry_wait' &&
!CANCELLATION_DISPATCH_RETRYABLE_RESULTS.includes(
lastResult as (typeof CANCELLATION_DISPATCH_RETRYABLE_RESULTS)[number],
)) ||
(status === 'dispatched' &&
lastResult !== 'termination_requested' &&
lastResult !== 'already_exited') ||
(status === 'blocked' &&
!CANCELLATION_DISPATCH_BLOCKING_RESULTS.includes(
lastResult as (typeof CANCELLATION_DISPATCH_BLOCKING_RESULTS)[number],
))
) {
throw new InvalidCancellationDispatchCommandError(
'dispatch state is inconsistent',
);
}
if (
dispatch.lastResult !== undefined &&
!CANCELLATION_DISPATCH_RESULTS.includes(
dispatch.lastResult as CancellationDispatchResult,
)
) {
throw new InvalidCancellationDispatchCommandError('lastResult is invalid');
}
return Object.freeze({
runId: identifier(dispatch.runId, 'runId', 36),
attemptId: identifier(dispatch.attemptId, 'attemptId', 36),
status,
version,
dispatchCount,
...(dispatch.nextAttemptAtMs === undefined
? {}
: {
nextAttemptAtMs: integer(
dispatch.nextAttemptAtMs,
'nextAttemptAtMs',
0,
),
}),
...(dispatch.leaseOwner === undefined
? {}
: { leaseOwner: identifier(dispatch.leaseOwner, 'leaseOwner', 128) }),
...(dispatch.leaseTokenDigest === undefined
? {}
: { leaseTokenDigest: dispatch.leaseTokenDigest as string }),
...(dispatch.leaseExpiresAtMs === undefined
? {}
: {
leaseExpiresAtMs: integer(
dispatch.leaseExpiresAtMs,
'leaseExpiresAtMs',
0,
),
}),
...(lastResult === undefined
? {}
: { lastResult }),
...(dispatch.lastDispatchedAtMs === undefined
? {}
: {
lastDispatchedAtMs: integer(
dispatch.lastDispatchedAtMs,
'lastDispatchedAtMs',
0,
),
}),
createdAtMs,
updatedAtMs,
});
}
@@ -0,0 +1,177 @@
const assert = require('node:assert/strict');
const { test } = require('node:test');
const root = require('../dist');
const contract = require('../dist/run/cancellation-dispatch/cancellationDispatch');
const RUN_ID = '019f71c0-0000-7000-8000-000000000001';
const ATTEMPT_ID = '019f71c0-0000-7000-8000-000000000002';
const EVENT_ID = '019f71c0-0000-7000-8000-000000000003';
const TOKEN_DIGEST =
'bf9dbe4700121e13b366bba7adbdfbb5a29d7e4b7a4b8d9181acd45b38c9a8bf';
function pendingRecord(overrides = {}) {
return {
runId: RUN_ID,
attemptId: ATTEMPT_ID,
status: 'pending',
version: 0,
dispatchCount: 0,
nextAttemptAtMs: 1_750_000_000_100,
createdAtMs: 1_750_000_000_100,
updatedAtMs: 1_750_000_000_100,
...overrides,
};
}
test('normalizes database-timed claim and result commands with exact bounds', () => {
const claim = contract.normalizeClaimCancellationDispatchCommand({
runId: RUN_ID,
attemptId: ATTEMPT_ID,
requestedAtMs: 1_750_000_000_100,
owner: 'primary-a',
leaseToken: 'lease-a',
leaseDurationMs: contract.MAX_CANCELLATION_DISPATCH_LEASE_MS,
});
assert.equal(Object.isFrozen(claim), true);
assert.equal('nowMs' in claim, false);
const result = contract.normalizeRecordCancellationDispatchResultCommand({
runId: RUN_ID,
attemptId: ATTEMPT_ID,
owner: 'primary-a',
leaseToken: 'lease-a',
expectedVersion: 1,
result: 'dispatch_error',
retryDelayMs: contract.MAX_CANCELLATION_DISPATCH_RETRY_DELAY_MS,
eventId: EVENT_ID,
});
assert.equal(Object.isFrozen(result), true);
assert.equal('atMs' in result, false);
assert.equal('nextAttemptAtMs' in result, false);
assert.throws(
() =>
contract.normalizeClaimCancellationDispatchCommand({
...claim,
nowMs: 1_750_000_000_100,
}),
contract.InvalidCancellationDispatchCommandError,
);
assert.throws(
() =>
contract.normalizeRecordCancellationDispatchResultCommand({
...result,
retryDelayMs: 0,
}),
contract.InvalidCancellationDispatchCommandError,
);
assert.throws(
() =>
contract.normalizeRecordCancellationDispatchResultCommand({
...result,
result: 'already_exited',
}),
contract.InvalidCancellationDispatchCommandError,
);
});
test('domain-separates lease digests and never admits a raw token into records', () => {
assert.equal(
contract.digestCancellationDispatchLeaseToken('lease-a'),
TOKEN_DIGEST,
);
assert.match(TOKEN_DIGEST, /^[0-9a-f]{64}$/);
const { nextAttemptAtMs: _nextAttemptAtMs, ...pending } = pendingRecord();
const leased = contract.normalizeCancellationDispatchRecord(
{
...pending,
status: 'leased',
version: 1,
dispatchCount: 1,
leaseOwner: 'primary-a',
leaseTokenDigest: TOKEN_DIGEST,
leaseExpiresAtMs: 1_750_000_000_200,
},
);
assert.equal(leased.leaseTokenDigest, TOKEN_DIGEST);
assert.equal('leaseToken' in leased, false);
assert.throws(
() =>
contract.normalizeCancellationDispatchRecord({
...leased,
leaseToken: 'lease-a',
}),
contract.InvalidCancellationDispatchCommandError,
);
});
test('rejects records that violate counter, lease, retry, or terminal state invariants', () => {
for (const invalid of [
pendingRecord({ version: 1 }),
pendingRecord({ status: 'retry_wait', version: 2, dispatchCount: 1 }),
pendingRecord({
status: 'leased',
version: 1,
dispatchCount: 1,
nextAttemptAtMs: undefined,
leaseOwner: 'primary-a',
leaseTokenDigest: TOKEN_DIGEST,
leaseExpiresAtMs: 1_750_000_000_200,
lastResult: 'already_exited',
}),
pendingRecord({
status: 'dispatched',
version: 2,
dispatchCount: 1,
nextAttemptAtMs: undefined,
lastResult: 'identity_mismatch',
}),
pendingRecord({
status: 'blocked',
version: 2,
dispatchCount: 1,
nextAttemptAtMs: undefined,
lastResult: 'dispatch_error',
}),
pendingRecord({ version: 0, dispatchCount: 1 }),
]) {
assert.throws(
() => contract.normalizeCancellationDispatchRecord(invalid),
contract.InvalidCancellationDispatchCommandError,
);
}
});
test('classifies every result into one durable state and low-sensitive event', () => {
for (const result of contract.CANCELLATION_DISPATCH_RESULTS) {
const state = contract.cancellationDispatchResultState(result);
if (contract.CANCELLATION_DISPATCH_RETRYABLE_RESULTS.includes(result)) {
assert.deepEqual(state, {
status: 'retry_wait',
eventType: 'run.cancel_dispatch_failed',
});
} else if (
contract.CANCELLATION_DISPATCH_BLOCKING_RESULTS.includes(result)
) {
assert.deepEqual(state, {
status: 'blocked',
eventType: 'run.cancel_dispatch_blocked',
});
} else {
assert.deepEqual(state, {
status: 'dispatched',
eventType: 'run.cancel_dispatched',
});
}
}
});
test('publishes the profile-neutral contract only through its explicit subpath', () => {
assert.equal(root.normalizeCancellationDispatchRecord, undefined);
assert.equal(
root.PostgresCancellationDispatchRepository,
undefined,
);
});