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
@@ -0,0 +1,452 @@
import type { PostgresClient, PostgresPool } from '@qinglong/runtime-core';
import {
CancellationDispatchBindingConflictError,
CancellationDispatchError,
CancellationDispatchFenceRejectedError,
CancellationDispatchRepositoryError,
cancellationDispatchResultState,
digestCancellationDispatchLeaseToken,
normalizeCancellationDispatchRecord,
normalizeCancellationDispatchRunId,
normalizeClaimCancellationDispatchCommand,
normalizeRecordCancellationDispatchResultCommand,
type CancellationDispatchRecord,
type CancellationDispatchRepository,
type ClaimCancellationDispatchCommand,
type ClaimCancellationDispatchResult,
type RecordCancellationDispatchResult,
type RecordCancellationDispatchResultCommand,
} from '@qinglong/runtime-core/cancellation-dispatch';
import type { RunEventRecord } from '@qinglong/runtime-core/run';
type Row = Record<string, unknown>;
const ACTIVE_RUN_STATUSES = new Set([
'created',
'queued',
'dispatching',
'running',
'waiting_approval',
'retry_wait',
'lost',
]);
const ACTIVE_ATTEMPT_STATUSES = new Set(['claimed', 'starting', 'running']);
const CONTROLLER_NOT_INVOKED_RESULTS = new Set([
'controller_missing',
'handle_missing',
]);
function text(row: Row, key: string): string {
const value = row[key];
if (typeof value !== 'string' || value.length < 1) {
throw new TypeError(`PostgreSQL cancellation dispatch ${key} is invalid`);
}
return value;
}
function integer(row: Row, key: string): number {
const raw = row[key];
const value =
typeof raw === 'string' && /^(0|[1-9]\d*)$/u.test(raw)
? Number(raw)
: raw;
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) {
throw new TypeError(`PostgreSQL cancellation dispatch ${key} is invalid`);
}
return value;
}
function optionalText(row: Row, key: string): string | undefined {
return row[key] === null || row[key] === undefined
? undefined
: text(row, key);
}
function optionalInteger(row: Row, key: string): number | undefined {
return row[key] === null || row[key] === undefined
? undefined
: integer(row, key);
}
function dispatchFromRow(row: Row): Readonly<CancellationDispatchRecord> {
const nextAttemptAtMs = optionalInteger(row, 'nextAttemptAtMs');
const leaseOwner = optionalText(row, 'leaseOwner');
const leaseTokenDigest = optionalText(row, 'leaseTokenDigest');
const leaseExpiresAtMs = optionalInteger(row, 'leaseExpiresAtMs');
const lastResult = optionalText(row, 'lastResult');
const lastDispatchedAtMs = optionalInteger(row, 'lastDispatchedAtMs');
return normalizeCancellationDispatchRecord({
runId: text(row, 'runId'),
attemptId: text(row, 'attemptId'),
status: text(row, 'status') as CancellationDispatchRecord['status'],
version: integer(row, 'version'),
dispatchCount: integer(row, 'dispatchCount'),
...(nextAttemptAtMs === undefined ? {} : { nextAttemptAtMs }),
...(leaseOwner === undefined ? {} : { leaseOwner }),
...(leaseTokenDigest === undefined ? {} : { leaseTokenDigest }),
...(leaseExpiresAtMs === undefined ? {} : { leaseExpiresAtMs }),
...(lastResult === undefined
? {}
: {
lastResult:
lastResult as NonNullable<CancellationDispatchRecord['lastResult']>,
}),
...(lastDispatchedAtMs === undefined ? {} : { lastDispatchedAtMs }),
createdAtMs: integer(row, 'createdAtMs'),
updatedAtMs: integer(row, 'updatedAtMs'),
});
}
const DISPATCH_COLUMNS = `
run_id AS "runId", attempt_id AS "attemptId", status,
version, dispatch_count AS "dispatchCount",
next_attempt_at_ms AS "nextAttemptAtMs", lease_owner AS "leaseOwner",
lease_token_digest AS "leaseTokenDigest",
lease_expires_at_ms AS "leaseExpiresAtMs", last_result AS "lastResult",
last_dispatched_at_ms AS "lastDispatchedAtMs",
created_at_ms AS "createdAtMs", updated_at_ms AS "updatedAtMs"
`;
async function begin(client: PostgresClient): Promise<void> {
await client.query('BEGIN');
await client.query(`SELECT set_config('statement_timeout', $1, true)`, [
'5000ms',
]);
await client.query(`SELECT set_config('lock_timeout', $1, true)`, ['1000ms']);
await client.query(
`SELECT set_config('idle_in_transaction_session_timeout', $1, true)`,
['10000ms'],
);
}
async function databaseNow(client: PostgresClient): Promise<number> {
const result = await client.query<Row>(`
SELECT floor(extract(epoch FROM transaction_timestamp()) * 1000)::bigint
AS "nowMs"
`);
if (result.rows.length !== 1) {
throw new TypeError('PostgreSQL cancellation dispatch clock is invalid');
}
return integer(result.rows[0]!, 'nowMs');
}
async function rollback(client: PostgresClient): Promise<void> {
try {
await client.query('ROLLBACK');
} catch {
// Preserve the transaction failure.
}
}
function repositoryFailure(error: unknown): never {
if (error instanceof CancellationDispatchError) throw error;
throw new CancellationDispatchRepositoryError(error);
}
export class PostgresCancellationDispatchRepository
implements CancellationDispatchRepository
{
constructor(private readonly pool: PostgresPool) {
if (!pool || typeof pool.connect !== 'function') {
throw new TypeError('PostgreSQL cancellation dispatch pool is invalid');
}
}
async findByRunId(
runId: string,
): Promise<Readonly<CancellationDispatchRecord> | null> {
try {
const normalizedRunId = normalizeCancellationDispatchRunId(runId);
const result = await this.pool.query<Row>(
`SELECT ${DISPATCH_COLUMNS}
FROM "ql3"."run_cancellation_dispatches"
WHERE run_id = $1`,
[normalizedRunId],
);
if (result.rows.length === 0) return null;
if (result.rows.length !== 1) {
throw new TypeError('PostgreSQL cancellation dispatch is not unique');
}
return dispatchFromRow(result.rows[0]!);
} catch (error) {
return repositoryFailure(error);
}
}
async claim(
value: Readonly<ClaimCancellationDispatchCommand>,
): Promise<ClaimCancellationDispatchResult> {
const command = normalizeClaimCancellationDispatchCommand(value);
return this.transaction(async (client) => {
const nowMs = await databaseNow(client);
const run = await client.query<Row>(
`SELECT execution_owner AS "executionOwner", status,
cancel_requested_at_ms AS "cancelRequestedAtMs"
FROM "ql3"."runs" WHERE id = $1 FOR UPDATE`,
[command.runId],
);
if (run.rows.length > 1) {
throw new TypeError('PostgreSQL cancellation dispatch Run is invalid');
}
const attempt = await client.query<Row>(
`SELECT run_id AS "runId", status
FROM "ql3"."run_attempts" WHERE id = $1 FOR UPDATE`,
[command.attemptId],
);
if (attempt.rows.length > 1) {
throw new TypeError(
'PostgreSQL cancellation dispatch Attempt is invalid',
);
}
const runRow = run.rows[0];
const attemptRow = attempt.rows[0];
if (
!runRow ||
!attemptRow ||
runRow.executionOwner !== 'runtime' ||
!ACTIVE_RUN_STATUSES.has(runRow.status as string) ||
optionalInteger(runRow, 'cancelRequestedAtMs') !==
command.requestedAtMs ||
attemptRow.runId !== command.runId ||
!ACTIVE_ATTEMPT_STATUSES.has(attemptRow.status as string)
) {
return Object.freeze({ status: 'not_eligible' as const });
}
let dispatchResult = await client.query<Row>(
`SELECT ${DISPATCH_COLUMNS}
FROM "ql3"."run_cancellation_dispatches"
WHERE run_id = $1 FOR UPDATE`,
[command.runId],
);
if (dispatchResult.rows.length === 0) {
dispatchResult = await client.query<Row>(
`INSERT INTO "ql3"."run_cancellation_dispatches" (
run_id, attempt_id, status, version, dispatch_count,
next_attempt_at_ms, created_at_ms, updated_at_ms
) VALUES ($1, $2, 'pending', 0, 0, $3, $4, $4)
RETURNING ${DISPATCH_COLUMNS}`,
[command.runId, command.attemptId, command.requestedAtMs, nowMs],
);
}
if (dispatchResult.rows.length !== 1) {
throw new TypeError('PostgreSQL cancellation dispatch is invalid');
}
const current = dispatchFromRow(dispatchResult.rows[0]!);
if (current.attemptId !== command.attemptId) {
throw new CancellationDispatchBindingConflictError(
command.runId,
command.attemptId,
);
}
if (current.status === 'dispatched' || current.status === 'blocked') {
return Object.freeze({ status: current.status, dispatch: current });
}
if (
current.status === 'leased' &&
current.leaseExpiresAtMs! > nowMs
) {
return Object.freeze({ status: 'leased' as const, dispatch: current });
}
if (
current.status !== 'leased' &&
current.nextAttemptAtMs! > nowMs
) {
return Object.freeze({ status: 'not_due' as const, dispatch: current });
}
if (
current.version >= 2_147_483_647 ||
current.dispatchCount >= 2_147_483_647
) {
throw new TypeError('PostgreSQL cancellation dispatch counter overflowed');
}
const leaseExpiresAtMs = nowMs + command.leaseDurationMs;
if (!Number.isSafeInteger(leaseExpiresAtMs)) {
throw new TypeError('PostgreSQL cancellation dispatch lease overflowed');
}
const leaseTokenDigest = digestCancellationDispatchLeaseToken(
command.leaseToken,
);
const claimed = await client.query<Row>(
`UPDATE "ql3"."run_cancellation_dispatches"
SET status = 'leased', version = version + 1,
dispatch_count = dispatch_count + 1,
next_attempt_at_ms = NULL, lease_owner = $3,
lease_token_digest = $4, lease_expires_at_ms = $5,
updated_at_ms = $6
WHERE run_id = $1 AND attempt_id = $2 AND version = $7
RETURNING ${DISPATCH_COLUMNS}`,
[
command.runId,
command.attemptId,
command.owner,
leaseTokenDigest,
leaseExpiresAtMs,
nowMs,
current.version,
],
);
if (claimed.rows.length !== 1) {
throw new CancellationDispatchFenceRejectedError(command.runId);
}
return Object.freeze({
status: 'claimed' as const,
dispatch: dispatchFromRow(claimed.rows[0]!),
leaseToken: command.leaseToken,
});
});
}
async recordResult(
value: Readonly<RecordCancellationDispatchResultCommand>,
): Promise<Readonly<RecordCancellationDispatchResult>> {
const command = normalizeRecordCancellationDispatchResultCommand(value);
return this.transaction(async (client) => {
const atMs = await databaseNow(client);
const run = await client.query<Row>(
`SELECT version, event_sequence AS "eventSequence"
FROM "ql3"."runs" WHERE id = $1 FOR UPDATE`,
[command.runId],
);
if (run.rows.length !== 1) {
throw new TypeError(
'PostgreSQL cancellation dispatch Run disappeared',
);
}
const dispatchResult = await client.query<Row>(
`SELECT ${DISPATCH_COLUMNS}
FROM "ql3"."run_cancellation_dispatches"
WHERE run_id = $1 FOR UPDATE`,
[command.runId],
);
if (dispatchResult.rows.length !== 1) {
throw new CancellationDispatchFenceRejectedError(command.runId);
}
const current = dispatchFromRow(dispatchResult.rows[0]!);
if (
current.attemptId !== command.attemptId ||
current.status !== 'leased' ||
current.version !== command.expectedVersion ||
current.leaseOwner !== command.owner ||
current.leaseTokenDigest !==
digestCancellationDispatchLeaseToken(command.leaseToken)
) {
throw new CancellationDispatchFenceRejectedError(command.runId);
}
const runVersion = integer(run.rows[0]!, 'version');
const eventSequence = integer(run.rows[0]!, 'eventSequence');
if (
current.version >= 2_147_483_647 ||
runVersion >= 2_147_483_647 ||
eventSequence >= 2_147_483_647
) {
throw new TypeError('PostgreSQL cancellation dispatch counter overflowed');
}
const nextAttemptAtMs =
command.retryDelayMs === undefined
? undefined
: atMs + command.retryDelayMs;
if (
nextAttemptAtMs !== undefined &&
!Number.isSafeInteger(nextAttemptAtMs)
) {
throw new TypeError('PostgreSQL cancellation dispatch retry overflowed');
}
const state = cancellationDispatchResultState(command.result);
const nextSequence = eventSequence + 1;
const runUpdated = await client.query(
`UPDATE "ql3"."runs"
SET version = version + 1, event_sequence = $2
WHERE id = $1 AND version = $3`,
[command.runId, nextSequence, runVersion],
);
if (runUpdated.rowCount !== 1) {
throw new CancellationDispatchFenceRejectedError(command.runId);
}
const dispatchUpdated = await client.query<Row>(
`UPDATE "ql3"."run_cancellation_dispatches"
SET status = $6, version = version + 1,
next_attempt_at_ms = $7, lease_owner = NULL,
lease_token_digest = NULL, lease_expires_at_ms = NULL,
last_result = $8,
last_dispatched_at_ms = CASE
WHEN $9::boolean THEN last_dispatched_at_ms ELSE $10 END,
updated_at_ms = $10
WHERE run_id = $1 AND attempt_id = $2 AND status = 'leased'
AND version = $3 AND lease_owner = $4
AND lease_token_digest = $5
RETURNING ${DISPATCH_COLUMNS}`,
[
command.runId,
command.attemptId,
command.expectedVersion,
command.owner,
current.leaseTokenDigest,
state.status,
nextAttemptAtMs ?? null,
command.result,
CONTROLLER_NOT_INVOKED_RESULTS.has(command.result),
atMs,
],
);
if (dispatchUpdated.rows.length !== 1) {
throw new CancellationDispatchFenceRejectedError(command.runId);
}
const event: Readonly<RunEventRecord> = Object.freeze({
id: command.eventId,
runId: command.runId,
sequence: nextSequence,
type: state.eventType,
dedupeKey: `cancel-dispatch:${command.attemptId}:${current.dispatchCount}`,
actorType: 'worker',
actorId: command.owner,
attemptId: command.attemptId,
payload: Object.freeze({
attempt_id: command.attemptId,
dispatch_count: current.dispatchCount,
result: command.result,
}),
createdAtMs: atMs,
});
await client.query(
`INSERT INTO "ql3"."run_events" (
id, run_id, sequence, type, dedupe_key, actor_type, actor_id,
attempt_id, step_run_id, payload, created_at_ms
) VALUES ($1, $2, $3, $4, $5, 'worker', $6, $7, NULL, $8::jsonb, $9)`,
[
event.id,
event.runId,
event.sequence,
event.type,
event.dedupeKey,
event.actorId,
event.attemptId,
JSON.stringify(event.payload),
event.createdAtMs,
],
);
return Object.freeze({
dispatch: dispatchFromRow(dispatchUpdated.rows[0]!),
event,
});
});
}
private async transaction<T>(
operation: (client: PostgresClient) => Promise<T>,
): Promise<T> {
let client: PostgresClient | undefined;
try {
client = await this.pool.connect();
await begin(client);
const result = await operation(client);
await client.query('COMMIT');
return result;
} catch (error) {
if (client) await rollback(client);
return repositoryFailure(error);
} finally {
client?.release();
}
}
}
@@ -0,0 +1,99 @@
import { CAPABILITIES_V64 } from '../../approved-action/pg-0065-approved-action-manual-recovery';
import { definePostgresSqlMigration } from '../../migrations/sqlMigration';
export const POSTGRESQL_CANCELLATION_DISPATCH_TABLE =
'run_cancellation_dispatches';
export const CAPABILITIES_V65 = CAPABILITIES_V64.replace(
'"run_core":1,',
'"run_cancellation_dispatch":1,"run_core":1,',
);
export const pg0066CancellationDispatchMigration =
definePostgresSqlMigration({
id: 'pg-0066-cancellation-dispatch',
statements: [
`CREATE UNIQUE INDEX ql3_run_attempts_run_id_uidx ON "ql3"."run_attempts" (run_id, id)`,
`
CREATE TABLE "ql3"."${POSTGRESQL_CANCELLATION_DISPATCH_TABLE}" (
run_id varchar(36) PRIMARY KEY,
attempt_id varchar(36) NOT NULL,
status varchar(32) NOT NULL,
version integer NOT NULL,
dispatch_count integer NOT NULL,
next_attempt_at_ms bigint,
lease_owner varchar(128),
lease_token_digest char(64),
lease_expires_at_ms bigint,
last_result varchar(32),
last_dispatched_at_ms bigint,
created_at_ms bigint NOT NULL,
updated_at_ms bigint NOT NULL,
CONSTRAINT ql3_run_cancellation_dispatch_run_fk
FOREIGN KEY (run_id) REFERENCES "ql3"."runs" (id)
ON DELETE CASCADE ON UPDATE RESTRICT,
CONSTRAINT ql3_run_cancellation_dispatch_attempt_fk
FOREIGN KEY (run_id, attempt_id)
REFERENCES "ql3"."run_attempts" (run_id, id)
ON DELETE CASCADE ON UPDATE RESTRICT,
CONSTRAINT ql3_run_cancellation_dispatch_status_check CHECK (
status IN ('pending', 'leased', 'retry_wait', 'dispatched', 'blocked')
),
CONSTRAINT ql3_run_cancellation_dispatch_result_check CHECK (
last_result IS NULL OR last_result IN (
'termination_requested', 'already_exited', 'identity_mismatch',
'pid_mismatch', 'unsupported', 'invalid', 'controller_missing',
'handle_missing', 'dispatch_error'
)
),
CONSTRAINT ql3_run_cancellation_dispatch_counter_check CHECK (
version BETWEEN 0 AND 2147483647 AND
dispatch_count BETWEEN 0 AND 2147483647 AND
version >= dispatch_count AND
((status = 'pending' AND version = 0 AND dispatch_count = 0) OR
(status <> 'pending' AND dispatch_count >= 1))
),
CONSTRAINT ql3_run_cancellation_dispatch_time_check CHECK (
(next_attempt_at_ms IS NULL OR next_attempt_at_ms >= 0) AND
(lease_expires_at_ms IS NULL OR lease_expires_at_ms >= 0) AND
(last_dispatched_at_ms IS NULL OR last_dispatched_at_ms >= 0) AND
created_at_ms >= 0 AND updated_at_ms >= created_at_ms
),
CONSTRAINT ql3_run_cancellation_dispatch_lease_digest_check CHECK (
lease_token_digest IS NULL OR lease_token_digest ~ '^[0-9a-f]{64}$'
),
CONSTRAINT ql3_run_cancellation_dispatch_shape_check CHECK (
(status = 'leased' AND next_attempt_at_ms IS NULL AND
lease_owner IS NOT NULL AND
octet_length(lease_owner) BETWEEN 1 AND 128 AND
lease_owner !~ '[[:cntrl:]]' AND
lease_token_digest IS NOT NULL AND lease_expires_at_ms IS NOT NULL) OR
(status IN ('pending', 'retry_wait') AND next_attempt_at_ms IS NOT NULL AND
lease_owner IS NULL AND lease_token_digest IS NULL AND
lease_expires_at_ms IS NULL) OR
(status IN ('dispatched', 'blocked') AND next_attempt_at_ms IS NULL AND
lease_owner IS NULL AND lease_token_digest IS NULL AND
lease_expires_at_ms IS NULL AND last_result IS NOT NULL)
),
CONSTRAINT ql3_run_cancellation_dispatch_result_state_check CHECK (
(status = 'pending' AND last_result IS NULL) OR
(status IN ('leased', 'retry_wait') AND
(last_result IS NULL OR last_result IN (
'controller_missing', 'handle_missing', 'dispatch_error'
))) OR
(status = 'dispatched' AND last_result IN (
'termination_requested', 'already_exited'
)) OR
(status = 'blocked' AND last_result IN (
'identity_mismatch', 'pid_mismatch', 'unsupported', 'invalid'
))
)
)
`.trim(),
`CREATE INDEX ql3_run_cancellation_dispatch_due_idx ON "ql3"."${POSTGRESQL_CANCELLATION_DISPATCH_TABLE}" (next_attempt_at_ms, run_id) WHERE status IN ('pending', 'retry_wait')`,
`CREATE INDEX ql3_run_cancellation_dispatch_lease_expiry_idx ON "ql3"."${POSTGRESQL_CANCELLATION_DISPATCH_TABLE}" (lease_expires_at_ms, run_id) WHERE status = 'leased'`,
`REVOKE ALL ON "ql3"."${POSTGRESQL_CANCELLATION_DISPATCH_TABLE}" FROM PUBLIC, ql3_runtime, ql3_admin, ql3_package_manager, ql3_package_executor, ql3_worker_ingress, ql3_worker_credential_manager, ql3_worker_credential_executor, ql3_automation_manager, ql3_approval_manager, ql3_run_manager`,
`GRANT SELECT, INSERT, UPDATE ON "ql3"."${POSTGRESQL_CANCELLATION_DISPATCH_TABLE}" TO ql3_runtime`,
`DO $ql3$ BEGIN UPDATE "ql3"."schema_capabilities" SET contract_version = 65, migration_id = 'pg-0066-cancellation-dispatch', capabilities = '${CAPABILITIES_V65}'::jsonb, updated_at_ms = floor(extract(epoch FROM transaction_timestamp()) * 1000)::bigint WHERE contract_name = 'control-core' AND contract_version = 64 AND migration_id = 'pg-0065-approved-action-manual-recovery' AND capabilities = '${CAPABILITIES_V64}'::jsonb; IF NOT FOUND THEN RAISE EXCEPTION 'control-core capability is not at version 64' USING ERRCODE = 'check_violation'; END IF; END $ql3$`,
],
});