mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
@@ -0,0 +1 @@
|
||||
dist
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"name": "@qinglong/local-execution",
|
||||
"version": "3.0.0-alpha.0",
|
||||
"private": true,
|
||||
"description": "QingLong 3.0 local execution, control, recovery and dispatch runtime",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=24.18.0 <25"
|
||||
},
|
||||
"exports": {
|
||||
"./execution": {
|
||||
"types": "./dist/execution/index.d.ts",
|
||||
"require": "./dist/execution/index.js",
|
||||
"default": "./dist/execution/index.js"
|
||||
},
|
||||
"./control": {
|
||||
"types": "./dist/control/index.d.ts",
|
||||
"require": "./dist/control/index.js",
|
||||
"default": "./dist/control/index.js"
|
||||
},
|
||||
"./recovery": {
|
||||
"types": "./dist/recovery/index.d.ts",
|
||||
"require": "./dist/recovery/index.js",
|
||||
"default": "./dist/recovery/index.js"
|
||||
},
|
||||
"./dispatch": {
|
||||
"types": "./dist/dispatch/index.d.ts",
|
||||
"require": "./dist/dispatch/index.js",
|
||||
"default": "./dist/dispatch/index.js"
|
||||
},
|
||||
"./scheduler": {
|
||||
"types": "./dist/scheduler/index.d.ts",
|
||||
"require": "./dist/scheduler/index.js",
|
||||
"default": "./dist/scheduler/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*.js",
|
||||
"dist/**/*.d.ts"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"check": "node ../../scripts/ql3-build-package-closure.cjs && tsc -p tsconfig.json --noEmit",
|
||||
"test": "node ../../scripts/ql3-build-package-closure.cjs && node --test test/*.test.cjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@qinglong/local-process": "workspace:*",
|
||||
"@qinglong/runtime-core": "workspace:*",
|
||||
"croner": "7.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@qinglong/local-sqlite": "workspace:*",
|
||||
"@types/node": "24.13.3",
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
import { createHash, randomUUID, timingSafeEqual } from 'crypto';
|
||||
import type { LocalCompletionReceiptJournal } from '@qinglong/runtime-core/local-completion-receipt-journal';
|
||||
import type {
|
||||
RunAttemptRecord,
|
||||
RunEventRecord,
|
||||
RunRecord,
|
||||
RunRepository,
|
||||
RunRepositoryTransaction,
|
||||
} from '@qinglong/runtime-core/run-repository';
|
||||
import {
|
||||
InvalidCompletionReceiptError,
|
||||
type CompletionReceipt,
|
||||
type CompletionReceiptStore,
|
||||
} from '@qinglong/local-process';
|
||||
import type { LocalWorkflowTaskExecutionRepository } from '../execution/workflowTaskExecution';
|
||||
|
||||
export const MAX_LOCAL_COMPLETION_QUARANTINE_RETENTION_MS = 24 * 60 * 60_000;
|
||||
|
||||
const ACTIVE_ATTEMPT_STATUSES = new Set(['claimed', 'starting', 'running']);
|
||||
const TERMINAL_ATTEMPT_STATUSES = new Set([
|
||||
'succeeded',
|
||||
'failed',
|
||||
'cancelled',
|
||||
'timed_out',
|
||||
'lost',
|
||||
]);
|
||||
const TERMINAL_RUN_STATUSES = new Set([
|
||||
'succeeded',
|
||||
'failed',
|
||||
'cancelled',
|
||||
'timed_out',
|
||||
'lost',
|
||||
]);
|
||||
|
||||
type MaintainedCompletionReceiptStore = CompletionReceiptStore & {
|
||||
quarantine?(attemptId: string): Promise<string | undefined>;
|
||||
quarantineReference?(attemptId: string): string;
|
||||
};
|
||||
|
||||
export type LocalCompletionDisposition =
|
||||
| 'completed'
|
||||
| 'already_terminal'
|
||||
| 'missing'
|
||||
| 'invalid'
|
||||
| 'stale';
|
||||
|
||||
export interface LocalCompletionReceiptProcessorOptions {
|
||||
readonly clock?: { now(): number };
|
||||
readonly createEventId?: () => string;
|
||||
readonly journal?: Pick<
|
||||
LocalCompletionReceiptJournal,
|
||||
'markQuarantined' | 'resolve'
|
||||
>;
|
||||
readonly quarantineRetentionMs?: number;
|
||||
readonly onDiagnostic?: (
|
||||
record: Readonly<{
|
||||
kind:
|
||||
| 'receipt_cleanup_failed'
|
||||
| 'receipt_quarantined'
|
||||
| 'journal_cleanup_failed';
|
||||
runId: string;
|
||||
attemptId: string;
|
||||
}>,
|
||||
) => void | Promise<void>;
|
||||
readonly workflowTasks?: LocalWorkflowTaskExecutionRepository;
|
||||
}
|
||||
|
||||
interface AggregateSnapshot {
|
||||
readonly run: RunRecord;
|
||||
readonly attempt: RunAttemptRecord;
|
||||
}
|
||||
|
||||
interface TerminalMapping {
|
||||
readonly status: 'succeeded' | 'failed' | 'cancelled' | 'timed_out';
|
||||
readonly errorCode?: string;
|
||||
readonly errorSummary?: string;
|
||||
}
|
||||
|
||||
class LocalCompletionConcurrentWriteError extends Error {}
|
||||
|
||||
function timestamp(value: number, field: string): number {
|
||||
if (!Number.isSafeInteger(value) || value < 0) {
|
||||
throw new RangeError(`${field} must be a non-negative safe integer`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function atOrAfter(
|
||||
now: number,
|
||||
run: RunRecord,
|
||||
attempt: RunAttemptRecord,
|
||||
): number {
|
||||
return Math.max(
|
||||
timestamp(now, 'Local completion observation'),
|
||||
run.createdAtMs,
|
||||
run.startedAtMs ?? 0,
|
||||
attempt.createdAtMs,
|
||||
attempt.startedAtMs ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
function reserveEvent(run: RunRecord): Readonly<{
|
||||
run: RunRecord;
|
||||
sequence: number;
|
||||
}> {
|
||||
const version = run.version + 1;
|
||||
const sequence = run.eventSequence + 1;
|
||||
if (!Number.isSafeInteger(version) || !Number.isSafeInteger(sequence)) {
|
||||
throw new RangeError('Local completion aggregate counter overflowed');
|
||||
}
|
||||
return Object.freeze({
|
||||
run: { ...run, version, eventSequence: sequence },
|
||||
sequence,
|
||||
});
|
||||
}
|
||||
|
||||
function mapping(run: RunRecord, receipt: CompletionReceipt): TerminalMapping {
|
||||
if (run.cancelRequestedAtMs !== undefined) {
|
||||
return run.cancelReason === 'timeout'
|
||||
? Object.freeze({
|
||||
status: 'timed_out' as const,
|
||||
errorCode: 'EXECUTION_TIMED_OUT',
|
||||
errorSummary: 'Execution exceeded its configured timeout',
|
||||
})
|
||||
: Object.freeze({
|
||||
status: 'cancelled' as const,
|
||||
errorCode: 'EXECUTION_CANCELLED',
|
||||
errorSummary: 'Execution was cancelled',
|
||||
});
|
||||
}
|
||||
return receipt.exitCode === 0
|
||||
? Object.freeze({ status: 'succeeded' as const })
|
||||
: Object.freeze({
|
||||
status: 'failed' as const,
|
||||
errorCode: 'EXECUTION_FAILED',
|
||||
errorSummary: 'Execution completed without success',
|
||||
});
|
||||
}
|
||||
|
||||
function authenticate(
|
||||
run: RunRecord,
|
||||
attempt: RunAttemptRecord,
|
||||
receipt: CompletionReceipt,
|
||||
terminal: boolean,
|
||||
): void {
|
||||
const allowedSequence = terminal
|
||||
? receipt.callbackSequence === attempt.callbackSequence ||
|
||||
receipt.callbackSequence === attempt.callbackSequence + 1
|
||||
: receipt.callbackSequence === attempt.callbackSequence + 1;
|
||||
if (
|
||||
run.executionOwner !== 'runtime' ||
|
||||
receipt.runId !== run.id ||
|
||||
receipt.attemptId !== attempt.id ||
|
||||
!allowedSequence ||
|
||||
receipt.startedAtMs < attempt.createdAtMs ||
|
||||
(attempt.startedAtMs !== undefined &&
|
||||
receipt.startedAtMs < attempt.startedAtMs) ||
|
||||
!attempt.callbackTokenHash ||
|
||||
!/^[a-f0-9]{64}$/.test(attempt.callbackTokenHash)
|
||||
) {
|
||||
throw new InvalidCompletionReceiptError(
|
||||
'Completion receipt does not match durable Run authority',
|
||||
);
|
||||
}
|
||||
const expected = Buffer.from(attempt.callbackTokenHash, 'hex');
|
||||
const actual = createHash('sha256').update(receipt.token, 'utf8').digest();
|
||||
if (!timingSafeEqual(expected, actual)) {
|
||||
throw new InvalidCompletionReceiptError(
|
||||
'Completion receipt token does not match durable authority',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function event(
|
||||
id: string,
|
||||
runId: string,
|
||||
attemptId: string,
|
||||
sequence: number,
|
||||
type: string,
|
||||
dedupeKey: string,
|
||||
payload: Readonly<Record<string, unknown>>,
|
||||
atMs: number,
|
||||
): RunEventRecord {
|
||||
return {
|
||||
id,
|
||||
runId,
|
||||
attemptId,
|
||||
sequence,
|
||||
type,
|
||||
dedupeKey,
|
||||
actorType: 'executor',
|
||||
actorId: 'completion-receipt',
|
||||
payload,
|
||||
createdAtMs: atMs,
|
||||
};
|
||||
}
|
||||
|
||||
export class LocalCompletionReceiptProcessor {
|
||||
private readonly clock: { now(): number };
|
||||
private readonly createEventId: () => string;
|
||||
private readonly journal?: LocalCompletionReceiptProcessorOptions['journal'];
|
||||
private readonly quarantineRetentionMs: number;
|
||||
private readonly onDiagnostic?: LocalCompletionReceiptProcessorOptions['onDiagnostic'];
|
||||
private readonly workflowTasks:
|
||||
| LocalWorkflowTaskExecutionRepository
|
||||
| undefined;
|
||||
|
||||
constructor(
|
||||
private readonly repository: RunRepository,
|
||||
private readonly receipts: MaintainedCompletionReceiptStore,
|
||||
options: LocalCompletionReceiptProcessorOptions = {},
|
||||
) {
|
||||
this.clock = options.clock ?? { now: Date.now };
|
||||
this.createEventId = options.createEventId ?? randomUUID;
|
||||
this.journal = options.journal;
|
||||
this.quarantineRetentionMs = options.quarantineRetentionMs ?? 60 * 60_000;
|
||||
if (
|
||||
!Number.isSafeInteger(this.quarantineRetentionMs) ||
|
||||
this.quarantineRetentionMs < 0 ||
|
||||
this.quarantineRetentionMs > MAX_LOCAL_COMPLETION_QUARANTINE_RETENTION_MS
|
||||
) {
|
||||
throw new RangeError('Local completion quarantine retention is invalid');
|
||||
}
|
||||
this.onDiagnostic = options.onDiagnostic;
|
||||
this.workflowTasks = options.workflowTasks;
|
||||
if (
|
||||
this.workflowTasks !== undefined &&
|
||||
typeof this.workflowTasks.complete !== 'function'
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Local Workflow Task completion repository is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async process(attemptId: string): Promise<LocalCompletionDisposition> {
|
||||
const snapshot = await this.load(attemptId);
|
||||
if (!snapshot || snapshot.attempt.executorType !== 'local_process') {
|
||||
return 'stale';
|
||||
}
|
||||
let receipt: CompletionReceipt | undefined;
|
||||
try {
|
||||
receipt = await this.receipts.read(attemptId);
|
||||
if (!receipt) return 'missing';
|
||||
authenticate(
|
||||
snapshot.run,
|
||||
snapshot.attempt,
|
||||
receipt,
|
||||
TERMINAL_RUN_STATUSES.has(snapshot.run.status) ||
|
||||
TERMINAL_ATTEMPT_STATUSES.has(snapshot.attempt.status),
|
||||
);
|
||||
} catch (error) {
|
||||
if (!(error instanceof InvalidCompletionReceiptError)) throw error;
|
||||
await this.quarantine(snapshot);
|
||||
return 'invalid';
|
||||
}
|
||||
|
||||
if (
|
||||
TERMINAL_RUN_STATUSES.has(snapshot.run.status) ||
|
||||
TERMINAL_ATTEMPT_STATUSES.has(snapshot.attempt.status)
|
||||
) {
|
||||
await this.cleanup(receipt);
|
||||
return 'already_terminal';
|
||||
}
|
||||
if (!ACTIVE_ATTEMPT_STATUSES.has(snapshot.attempt.status)) return 'stale';
|
||||
|
||||
if (snapshot.attempt.stepRunId !== undefined) {
|
||||
if (!this.workflowTasks) return 'stale';
|
||||
const terminal = mapping(snapshot.run, receipt);
|
||||
const disposition = await this.workflowTasks.complete({
|
||||
run: snapshot.run,
|
||||
attempt: snapshot.attempt,
|
||||
callbackSequence: receipt.callbackSequence,
|
||||
startedAtMs: receipt.startedAtMs,
|
||||
finishedAtMs: Math.max(
|
||||
atOrAfter(this.clock.now(), snapshot.run, snapshot.attempt),
|
||||
receipt.finishedAtMs,
|
||||
),
|
||||
exitCode: receipt.exitCode,
|
||||
terminalStatus: terminal.status,
|
||||
...(terminal.errorCode === undefined
|
||||
? {}
|
||||
: {
|
||||
errorCode: terminal.errorCode,
|
||||
errorSummary: terminal.errorSummary,
|
||||
}),
|
||||
attemptEventId: this.createEventId(),
|
||||
syntheticStartMutationId: this.createEventId(),
|
||||
terminalStepMutationId: this.createEventId(),
|
||||
});
|
||||
if (
|
||||
disposition === 'completed' ||
|
||||
disposition === 'already_terminal'
|
||||
) {
|
||||
await this.cleanup(receipt);
|
||||
}
|
||||
return disposition;
|
||||
}
|
||||
|
||||
const completed = await this.repository.transaction(async (transaction) => {
|
||||
const run = await transaction.findRunById(snapshot.run.id);
|
||||
const attempt = await transaction.findAttemptById(snapshot.attempt.id);
|
||||
if (
|
||||
!run ||
|
||||
!attempt ||
|
||||
run.version !== snapshot.run.version ||
|
||||
run.status !== snapshot.run.status ||
|
||||
attempt.status !== snapshot.attempt.status ||
|
||||
attempt.callbackSequence !== snapshot.attempt.callbackSequence ||
|
||||
attempt.runId !== run.id
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
authenticate(run, attempt, receipt, false);
|
||||
const terminal = mapping(run, receipt);
|
||||
const atMs = Math.max(
|
||||
atOrAfter(this.clock.now(), run, attempt),
|
||||
receipt.finishedAtMs,
|
||||
);
|
||||
const attemptReserved = reserveEvent(run);
|
||||
const nextAttempt: RunAttemptRecord = {
|
||||
...attempt,
|
||||
status: terminal.status,
|
||||
callbackSequence: receipt.callbackSequence,
|
||||
finishedAtMs: atMs,
|
||||
exitCode: receipt.exitCode,
|
||||
...(terminal.errorCode === undefined
|
||||
? {}
|
||||
: {
|
||||
errorCode: terminal.errorCode,
|
||||
errorSummary: terminal.errorSummary,
|
||||
}),
|
||||
};
|
||||
if (
|
||||
!(await transaction.compareAndSetRun(attemptReserved.run, run.version))
|
||||
) {
|
||||
throw new LocalCompletionConcurrentWriteError();
|
||||
}
|
||||
if (
|
||||
!(await transaction.compareAndSetAttempt(nextAttempt, {
|
||||
status: attempt.status,
|
||||
callbackSequence: attempt.callbackSequence,
|
||||
}))
|
||||
) {
|
||||
throw new LocalCompletionConcurrentWriteError();
|
||||
}
|
||||
await transaction.appendEvent(
|
||||
event(
|
||||
this.createEventId(),
|
||||
run.id,
|
||||
attempt.id,
|
||||
attemptReserved.sequence,
|
||||
`attempt.${terminal.status}`,
|
||||
`local-completion:${attempt.id}:${receipt.callbackSequence}:attempt`,
|
||||
Object.freeze({
|
||||
attempt_id: attempt.id,
|
||||
from_status: attempt.status,
|
||||
to_status: terminal.status,
|
||||
callback_sequence: receipt.callbackSequence,
|
||||
exit_code: receipt.exitCode,
|
||||
version: attemptReserved.run.version,
|
||||
}),
|
||||
atMs,
|
||||
),
|
||||
);
|
||||
const runReserved = reserveEvent(attemptReserved.run);
|
||||
const nextRun: RunRecord = {
|
||||
...runReserved.run,
|
||||
status: terminal.status,
|
||||
finishedAtMs: atMs,
|
||||
...(terminal.errorCode === undefined
|
||||
? {}
|
||||
: {
|
||||
errorCode: terminal.errorCode,
|
||||
errorSummary: terminal.errorSummary,
|
||||
}),
|
||||
};
|
||||
if (
|
||||
!(await transaction.compareAndSetRun(
|
||||
nextRun,
|
||||
attemptReserved.run.version,
|
||||
))
|
||||
) {
|
||||
throw new LocalCompletionConcurrentWriteError();
|
||||
}
|
||||
await transaction.appendEvent(
|
||||
event(
|
||||
this.createEventId(),
|
||||
run.id,
|
||||
attempt.id,
|
||||
runReserved.sequence,
|
||||
`run.${terminal.status}`,
|
||||
`local-completion:${attempt.id}:${receipt.callbackSequence}:run`,
|
||||
Object.freeze({
|
||||
from_status: run.status,
|
||||
to_status: terminal.status,
|
||||
version: nextRun.version,
|
||||
}),
|
||||
atMs,
|
||||
),
|
||||
);
|
||||
return true;
|
||||
});
|
||||
if (!completed) return 'stale';
|
||||
await this.cleanup(receipt);
|
||||
return 'completed';
|
||||
}
|
||||
|
||||
private load(attemptId: string): Promise<AggregateSnapshot | null> {
|
||||
return this.repository.transaction(async (transaction) => {
|
||||
const attempt = await transaction.findAttemptById(attemptId);
|
||||
if (!attempt) return null;
|
||||
const run = await transaction.findRunById(attempt.runId);
|
||||
return run ? Object.freeze({ run, attempt }) : null;
|
||||
});
|
||||
}
|
||||
|
||||
private async quarantine(snapshot: AggregateSnapshot): Promise<void> {
|
||||
const reference = this.receipts.quarantineReference?.(snapshot.attempt.id);
|
||||
if (reference && this.journal) {
|
||||
const updatedAtMs = atOrAfter(
|
||||
this.clock.now(),
|
||||
snapshot.run,
|
||||
snapshot.attempt,
|
||||
);
|
||||
const purgeAfterMs = timestamp(
|
||||
updatedAtMs + this.quarantineRetentionMs,
|
||||
'Local completion quarantine expiry',
|
||||
);
|
||||
await this.journal.markQuarantined({
|
||||
attemptId: snapshot.attempt.id,
|
||||
quarantineRef: reference,
|
||||
updatedAtMs,
|
||||
purgeAfterMs,
|
||||
});
|
||||
}
|
||||
const quarantined = await this.receipts.quarantine?.(snapshot.attempt.id);
|
||||
if (quarantined) {
|
||||
await this.diagnostic({
|
||||
kind: 'receipt_quarantined',
|
||||
runId: snapshot.run.id,
|
||||
attemptId: snapshot.attempt.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async cleanup(receipt: CompletionReceipt): Promise<void> {
|
||||
try {
|
||||
await this.receipts.remove(receipt.attemptId);
|
||||
} catch {
|
||||
await this.diagnostic({
|
||||
kind: 'receipt_cleanup_failed',
|
||||
runId: receipt.runId,
|
||||
attemptId: receipt.attemptId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!this.journal) return;
|
||||
try {
|
||||
await this.journal.resolve(receipt.attemptId);
|
||||
} catch {
|
||||
await this.diagnostic({
|
||||
kind: 'journal_cleanup_failed',
|
||||
runId: receipt.runId,
|
||||
attemptId: receipt.attemptId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async diagnostic(
|
||||
record: Parameters<NonNullable<typeof this.onDiagnostic>>[0],
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.onDiagnostic?.(Object.freeze(record));
|
||||
} catch {
|
||||
// Diagnostic failure cannot replace the durable completion result.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,696 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import {
|
||||
assertLocalExecutionControlLimit,
|
||||
normalizeLocalActiveExecutionCandidate,
|
||||
normalizeLocalExecutionControlCandidate,
|
||||
type LocalActiveExecutionCandidate,
|
||||
type LocalActiveExecutionCursor,
|
||||
type LocalExecutionControlCandidate,
|
||||
type LocalExecutionControlCursor,
|
||||
type LocalExecutionControlSource,
|
||||
} from '@qinglong/runtime-core/local-execution-control';
|
||||
import type {
|
||||
RunAttemptRecord,
|
||||
RunCancellationReason,
|
||||
RunEventRecord,
|
||||
RunRecord,
|
||||
RunRepository,
|
||||
} from '@qinglong/runtime-core/run-repository';
|
||||
import type {
|
||||
LocalProcessController,
|
||||
LocalProcessStopResult,
|
||||
} from '@qinglong/local-process';
|
||||
import type { LocalCompletionReceiptProcessor } from './completion';
|
||||
import type { LocalWorkflowTaskExecutionRepository } from '../execution/workflowTaskExecution';
|
||||
|
||||
export const MAX_LOCAL_EXECUTION_CONTROL_PAGES = 16;
|
||||
|
||||
const ACTIVE_ATTEMPT_STATUSES = new Set(['claimed', 'starting', 'running']);
|
||||
const ACTIVE_RUN_STATUSES = new Set(['dispatching', 'running']);
|
||||
const TERMINAL_RUN_STATUSES = new Set([
|
||||
'succeeded',
|
||||
'failed',
|
||||
'cancelled',
|
||||
'timed_out',
|
||||
'lost',
|
||||
]);
|
||||
|
||||
export type LocalExecutionControlDisposition =
|
||||
| 'terminal'
|
||||
| 'cancel_requested'
|
||||
| 'stale'
|
||||
| 'remaining';
|
||||
|
||||
export interface LocalExecutionControlCoordinatorOptions {
|
||||
readonly clock?: { now(): number };
|
||||
readonly createEventId?: () => string;
|
||||
readonly workflowTasks?: LocalWorkflowTaskExecutionRepository;
|
||||
}
|
||||
|
||||
interface ActiveSnapshot {
|
||||
readonly run: RunRecord;
|
||||
readonly attempt: RunAttemptRecord;
|
||||
}
|
||||
|
||||
class LocalExecutionControlConcurrentWriteError extends Error {}
|
||||
|
||||
function timestamp(value: number, field: string): number {
|
||||
if (!Number.isSafeInteger(value) || value < 0) {
|
||||
throw new RangeError(`${field} must be a non-negative safe integer`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function reserveEvent(run: RunRecord): Readonly<{
|
||||
run: RunRecord;
|
||||
sequence: number;
|
||||
}> {
|
||||
const version = run.version + 1;
|
||||
const sequence = run.eventSequence + 1;
|
||||
if (!Number.isSafeInteger(version) || !Number.isSafeInteger(sequence)) {
|
||||
throw new RangeError(
|
||||
'Local execution control aggregate counter overflowed',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
run: { ...run, version, eventSequence: sequence },
|
||||
sequence,
|
||||
});
|
||||
}
|
||||
|
||||
function atOrAfter(
|
||||
now: number,
|
||||
run: RunRecord,
|
||||
attempt?: RunAttemptRecord,
|
||||
): number {
|
||||
return Math.max(
|
||||
timestamp(now, 'Local execution control observation'),
|
||||
run.createdAtMs,
|
||||
run.startedAtMs ?? 0,
|
||||
attempt?.createdAtMs ?? 0,
|
||||
attempt?.startedAtMs ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
function event(
|
||||
id: string,
|
||||
run: RunRecord,
|
||||
attemptId: string,
|
||||
sequence: number,
|
||||
type: string,
|
||||
dedupeKey: string,
|
||||
payload: Readonly<Record<string, unknown>>,
|
||||
actorId: string,
|
||||
atMs: number,
|
||||
): RunEventRecord {
|
||||
return {
|
||||
id,
|
||||
runId: run.id,
|
||||
attemptId,
|
||||
sequence,
|
||||
type,
|
||||
dedupeKey,
|
||||
actorType: 'reconciler',
|
||||
actorId,
|
||||
payload,
|
||||
createdAtMs: atMs,
|
||||
};
|
||||
}
|
||||
|
||||
function terminalFor(reason: RunCancellationReason): Readonly<{
|
||||
status: 'cancelled' | 'timed_out';
|
||||
errorCode: string;
|
||||
errorSummary: string;
|
||||
}> {
|
||||
return reason === 'timeout'
|
||||
? Object.freeze({
|
||||
status: 'timed_out' as const,
|
||||
errorCode: 'EXECUTION_TIMED_OUT',
|
||||
errorSummary: 'Execution exceeded its configured timeout',
|
||||
})
|
||||
: Object.freeze({
|
||||
status: 'cancelled' as const,
|
||||
errorCode: 'EXECUTION_CANCELLED',
|
||||
errorSummary: 'Execution was cancelled',
|
||||
});
|
||||
}
|
||||
|
||||
function conclusiveStop(result: LocalProcessStopResult): boolean {
|
||||
return result.status === 'stopped' || result.status === 'already_exited';
|
||||
}
|
||||
|
||||
export class LocalExecutionControlCoordinator {
|
||||
private readonly clock: { now(): number };
|
||||
private readonly createEventId: () => string;
|
||||
private readonly workflowTasks:
|
||||
| LocalWorkflowTaskExecutionRepository
|
||||
| undefined;
|
||||
|
||||
constructor(
|
||||
private readonly repository: RunRepository,
|
||||
private readonly completions: Pick<
|
||||
LocalCompletionReceiptProcessor,
|
||||
'process'
|
||||
>,
|
||||
private readonly controller: Pick<LocalProcessController, 'stop'>,
|
||||
options: LocalExecutionControlCoordinatorOptions = {},
|
||||
) {
|
||||
this.clock = options.clock ?? { now: Date.now };
|
||||
this.createEventId = options.createEventId ?? randomUUID;
|
||||
this.workflowTasks = options.workflowTasks;
|
||||
if (
|
||||
this.workflowTasks !== undefined &&
|
||||
(typeof this.workflowTasks.requestTimeout !== 'function' ||
|
||||
typeof this.workflowTasks.recordControlTerminal !== 'function')
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Local Workflow Task execution control repository is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async process(
|
||||
value: LocalExecutionControlCandidate,
|
||||
): Promise<LocalExecutionControlDisposition> {
|
||||
let candidate = normalizeLocalExecutionControlCandidate(value);
|
||||
if (candidate.kind === 'deadline') {
|
||||
const current = await this.loadActive(
|
||||
candidate.runId,
|
||||
candidate.attemptId,
|
||||
);
|
||||
if (!current) return 'stale';
|
||||
const requested =
|
||||
current.attempt.stepRunId === undefined
|
||||
? await this.requestCancellation(
|
||||
candidate.runId,
|
||||
candidate.attemptId,
|
||||
'timeout',
|
||||
candidate.dueAtMs,
|
||||
true,
|
||||
)
|
||||
: this.workflowTasks === undefined
|
||||
? false
|
||||
: (await this.workflowTasks.requestTimeout({
|
||||
run: current.run,
|
||||
attempt: current.attempt,
|
||||
dueAtMs: candidate.dueAtMs,
|
||||
eventId: this.createEventId(),
|
||||
})) !== 'stale';
|
||||
if (!requested) return 'stale';
|
||||
candidate = Object.freeze({
|
||||
kind: 'cancellation',
|
||||
runId: candidate.runId,
|
||||
attemptId: candidate.attemptId,
|
||||
dueAtMs: candidate.dueAtMs,
|
||||
cancelReason: 'timeout',
|
||||
});
|
||||
}
|
||||
|
||||
const completion = await this.completions.process(candidate.attemptId);
|
||||
if (completion === 'completed' || completion === 'already_terminal') {
|
||||
return 'terminal';
|
||||
}
|
||||
if (completion === 'invalid') return 'remaining';
|
||||
|
||||
const snapshot = await this.loadActive(
|
||||
candidate.runId,
|
||||
candidate.attemptId,
|
||||
);
|
||||
if (!snapshot) return 'stale';
|
||||
const reason =
|
||||
snapshot.attempt.stepRunId === undefined
|
||||
? snapshot.run.cancelReason
|
||||
: snapshot.run.cancelRequestedAtMs === undefined
|
||||
? candidate.cancelReason === 'timeout'
|
||||
? 'timeout'
|
||||
: undefined
|
||||
: snapshot.run.cancelReason;
|
||||
if (reason === undefined) return 'stale';
|
||||
if (snapshot.attempt.status === 'claimed') {
|
||||
return (await this.markTerminal(snapshot, reason))
|
||||
? 'terminal'
|
||||
: 'remaining';
|
||||
}
|
||||
if (!snapshot.attempt.executorHandle) return 'remaining';
|
||||
|
||||
const stopped = await this.controller.stop(snapshot.attempt.executorHandle);
|
||||
const lateCompletion = await this.completions.process(candidate.attemptId);
|
||||
if (
|
||||
lateCompletion === 'completed' ||
|
||||
lateCompletion === 'already_terminal'
|
||||
) {
|
||||
return 'terminal';
|
||||
}
|
||||
if (!conclusiveStop(stopped)) return 'remaining';
|
||||
return (await this.markTerminal(snapshot, reason))
|
||||
? 'terminal'
|
||||
: 'remaining';
|
||||
}
|
||||
|
||||
async requestShutdown(
|
||||
value: LocalActiveExecutionCandidate,
|
||||
requestedAtMs: number,
|
||||
): Promise<LocalExecutionControlDisposition> {
|
||||
const candidate = normalizeLocalActiveExecutionCandidate(value);
|
||||
const current = await this.loadActive(candidate.runId, candidate.attemptId);
|
||||
if (!current) return 'stale';
|
||||
const reason = current.run.cancelReason ?? 'shutdown';
|
||||
if (current.run.cancelRequestedAtMs === undefined) {
|
||||
const requested = await this.requestCancellation(
|
||||
candidate.runId,
|
||||
candidate.attemptId,
|
||||
reason,
|
||||
requestedAtMs,
|
||||
false,
|
||||
);
|
||||
if (!requested) return 'stale';
|
||||
}
|
||||
return this.process({
|
||||
kind: 'cancellation',
|
||||
runId: candidate.runId,
|
||||
attemptId: candidate.attemptId,
|
||||
dueAtMs: current.run.cancelRequestedAtMs ?? requestedAtMs,
|
||||
cancelReason: reason,
|
||||
});
|
||||
}
|
||||
|
||||
private loadActive(
|
||||
runId: string,
|
||||
attemptId: string,
|
||||
): Promise<ActiveSnapshot | null> {
|
||||
return this.repository.transaction(async (transaction) => {
|
||||
const run = await transaction.findRunById(runId);
|
||||
const attempt = await transaction.findAttemptById(attemptId);
|
||||
if (
|
||||
!run ||
|
||||
!attempt ||
|
||||
attempt.runId !== run.id ||
|
||||
run.executionOwner !== 'runtime' ||
|
||||
!ACTIVE_RUN_STATUSES.has(run.status) ||
|
||||
!ACTIVE_ATTEMPT_STATUSES.has(attempt.status) ||
|
||||
attempt.executorType !== 'local_process'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (attempt.stepRunId === undefined) {
|
||||
const latest = await transaction.findLatestAttemptByRunId(run.id);
|
||||
if (latest?.id !== attempt.id) return null;
|
||||
}
|
||||
return Object.freeze({ run, attempt });
|
||||
});
|
||||
}
|
||||
|
||||
private requestCancellation(
|
||||
runId: string,
|
||||
attemptId: string,
|
||||
reason: RunCancellationReason,
|
||||
requestedAtMs: number,
|
||||
requireDeadline: boolean,
|
||||
): Promise<boolean> {
|
||||
return this.repository.transaction(async (transaction) => {
|
||||
const run = await transaction.findRunById(runId);
|
||||
const attempt = await transaction.findAttemptById(attemptId);
|
||||
if (
|
||||
!run ||
|
||||
!attempt ||
|
||||
attempt.runId !== run.id ||
|
||||
run.executionOwner !== 'runtime' ||
|
||||
!ACTIVE_RUN_STATUSES.has(run.status) ||
|
||||
!ACTIVE_ATTEMPT_STATUSES.has(attempt.status) ||
|
||||
attempt.executorType !== 'local_process' ||
|
||||
(attempt.stepRunId === undefined &&
|
||||
(await transaction.findLatestAttemptByRunId(run.id))?.id !==
|
||||
attempt.id)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (run.cancelRequestedAtMs !== undefined) {
|
||||
return run.cancelReason === reason;
|
||||
}
|
||||
const observedAtMs = timestamp(
|
||||
requestedAtMs,
|
||||
'Cancellation request time',
|
||||
);
|
||||
if (
|
||||
requireDeadline &&
|
||||
(attempt.deadlineAtMs === undefined ||
|
||||
attempt.deadlineAtMs > observedAtMs)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const atMs = atOrAfter(observedAtMs, run, attempt);
|
||||
const reserved = reserveEvent(run);
|
||||
const next: RunRecord = {
|
||||
...reserved.run,
|
||||
cancelRequestedAtMs: atMs,
|
||||
cancelReason: reason,
|
||||
};
|
||||
if (!(await transaction.compareAndSetRun(next, run.version))) {
|
||||
throw new LocalExecutionControlConcurrentWriteError();
|
||||
}
|
||||
await transaction.appendEvent(
|
||||
event(
|
||||
this.createEventId(),
|
||||
run,
|
||||
attempt.id,
|
||||
reserved.sequence,
|
||||
'run.cancel_requested',
|
||||
`local-control:${attempt.id}:${reason}:${run.version}`,
|
||||
Object.freeze({
|
||||
reason,
|
||||
from_status: run.status,
|
||||
version: next.version,
|
||||
}),
|
||||
requireDeadline ? 'local-deadline' : 'local-shutdown',
|
||||
atMs,
|
||||
),
|
||||
);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
private markTerminal(
|
||||
expected: ActiveSnapshot,
|
||||
reason: RunCancellationReason,
|
||||
): Promise<boolean> {
|
||||
if (expected.attempt.stepRunId !== undefined) {
|
||||
if (!this.workflowTasks) return Promise.resolve(false);
|
||||
const terminal = terminalFor(reason);
|
||||
const atMs = atOrAfter(this.clock.now(), expected.run, expected.attempt);
|
||||
return this.workflowTasks
|
||||
.recordControlTerminal({
|
||||
run: expected.run,
|
||||
attempt: expected.attempt,
|
||||
reason,
|
||||
terminalStatus: terminal.status,
|
||||
errorCode: terminal.errorCode,
|
||||
errorSummary: terminal.errorSummary,
|
||||
finishedAtMs: atMs,
|
||||
attemptEventId: this.createEventId(),
|
||||
stepMutationId: this.createEventId(),
|
||||
})
|
||||
.then(
|
||||
(result) =>
|
||||
result === 'terminal' || result === 'already_terminal',
|
||||
);
|
||||
}
|
||||
return this.repository.transaction(async (transaction) => {
|
||||
const run = await transaction.findRunById(expected.run.id);
|
||||
const attempt = await transaction.findAttemptById(expected.attempt.id);
|
||||
if (
|
||||
!run ||
|
||||
!attempt ||
|
||||
run.version < expected.run.version ||
|
||||
attempt.runId !== run.id ||
|
||||
run.executionOwner !== 'runtime' ||
|
||||
!ACTIVE_RUN_STATUSES.has(run.status) ||
|
||||
!ACTIVE_ATTEMPT_STATUSES.has(attempt.status) ||
|
||||
attempt.callbackSequence !== expected.attempt.callbackSequence ||
|
||||
attempt.executorHandle !== expected.attempt.executorHandle ||
|
||||
attempt.pid !== expected.attempt.pid ||
|
||||
run.cancelRequestedAtMs === undefined ||
|
||||
run.cancelReason !== reason ||
|
||||
(await transaction.findLatestAttemptByRunId(run.id))?.id !== attempt.id
|
||||
) {
|
||||
return TERMINAL_RUN_STATUSES.has(run?.status ?? 'created');
|
||||
}
|
||||
const terminal = terminalFor(reason);
|
||||
const atMs = atOrAfter(this.clock.now(), run, attempt);
|
||||
const attemptReserved = reserveEvent(run);
|
||||
const nextAttempt: RunAttemptRecord = {
|
||||
...attempt,
|
||||
status: terminal.status,
|
||||
finishedAtMs: atMs,
|
||||
errorCode: terminal.errorCode,
|
||||
errorSummary: terminal.errorSummary,
|
||||
};
|
||||
if (
|
||||
!(await transaction.compareAndSetRun(attemptReserved.run, run.version))
|
||||
) {
|
||||
throw new LocalExecutionControlConcurrentWriteError();
|
||||
}
|
||||
if (
|
||||
!(await transaction.compareAndSetAttempt(nextAttempt, {
|
||||
status: attempt.status,
|
||||
callbackSequence: attempt.callbackSequence,
|
||||
}))
|
||||
) {
|
||||
throw new LocalExecutionControlConcurrentWriteError();
|
||||
}
|
||||
await transaction.appendEvent(
|
||||
event(
|
||||
this.createEventId(),
|
||||
run,
|
||||
attempt.id,
|
||||
attemptReserved.sequence,
|
||||
`attempt.${terminal.status}`,
|
||||
`local-control:${attempt.id}:${attempt.callbackSequence}:attempt`,
|
||||
Object.freeze({
|
||||
attempt_id: attempt.id,
|
||||
from_status: attempt.status,
|
||||
to_status: terminal.status,
|
||||
reason,
|
||||
version: attemptReserved.run.version,
|
||||
}),
|
||||
'local-execution-control',
|
||||
atMs,
|
||||
),
|
||||
);
|
||||
const runReserved = reserveEvent(attemptReserved.run);
|
||||
const nextRun: RunRecord = {
|
||||
...runReserved.run,
|
||||
status: terminal.status,
|
||||
finishedAtMs: atMs,
|
||||
errorCode: terminal.errorCode,
|
||||
errorSummary: terminal.errorSummary,
|
||||
};
|
||||
if (
|
||||
!(await transaction.compareAndSetRun(
|
||||
nextRun,
|
||||
attemptReserved.run.version,
|
||||
))
|
||||
) {
|
||||
throw new LocalExecutionControlConcurrentWriteError();
|
||||
}
|
||||
await transaction.appendEvent(
|
||||
event(
|
||||
this.createEventId(),
|
||||
run,
|
||||
attempt.id,
|
||||
runReserved.sequence,
|
||||
`run.${terminal.status}`,
|
||||
`local-control:${attempt.id}:${attempt.callbackSequence}:run`,
|
||||
Object.freeze({
|
||||
from_status: run.status,
|
||||
to_status: terminal.status,
|
||||
reason,
|
||||
version: nextRun.version,
|
||||
}),
|
||||
'local-execution-control',
|
||||
atMs,
|
||||
),
|
||||
);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export interface LocalExecutionControlScanSummary {
|
||||
readonly scanned: number;
|
||||
readonly terminal: number;
|
||||
readonly cancelRequested: number;
|
||||
readonly stale: number;
|
||||
readonly remaining: number;
|
||||
readonly failed: number;
|
||||
readonly truncated: boolean;
|
||||
readonly nextCursor?: LocalExecutionControlCursor;
|
||||
}
|
||||
|
||||
export interface LocalExecutionDrainSummary {
|
||||
readonly scanned: number;
|
||||
readonly terminal: number;
|
||||
readonly remaining: number;
|
||||
readonly failed: number;
|
||||
readonly truncated: boolean;
|
||||
}
|
||||
|
||||
function controlCursor(candidate: LocalExecutionControlCandidate) {
|
||||
return Object.freeze({
|
||||
dueAtMs: candidate.dueAtMs,
|
||||
kind: candidate.kind,
|
||||
attemptId: candidate.attemptId,
|
||||
});
|
||||
}
|
||||
|
||||
function controlAdvances(
|
||||
previous: LocalExecutionControlCursor,
|
||||
next: LocalExecutionControlCursor,
|
||||
): boolean {
|
||||
return (
|
||||
next.dueAtMs > previous.dueAtMs ||
|
||||
(next.dueAtMs === previous.dueAtMs &&
|
||||
(next.kind > previous.kind ||
|
||||
(next.kind === previous.kind && next.attemptId > previous.attemptId)))
|
||||
);
|
||||
}
|
||||
|
||||
function activeCursor(candidate: LocalActiveExecutionCandidate) {
|
||||
return Object.freeze({
|
||||
attemptCreatedAtMs: candidate.attemptCreatedAtMs,
|
||||
attemptId: candidate.attemptId,
|
||||
});
|
||||
}
|
||||
|
||||
function activeAdvances(
|
||||
previous: LocalActiveExecutionCursor,
|
||||
next: LocalActiveExecutionCursor,
|
||||
): boolean {
|
||||
return (
|
||||
next.attemptCreatedAtMs > previous.attemptCreatedAtMs ||
|
||||
(next.attemptCreatedAtMs === previous.attemptCreatedAtMs &&
|
||||
next.attemptId > previous.attemptId)
|
||||
);
|
||||
}
|
||||
|
||||
export class LocalExecutionControlScanner {
|
||||
constructor(
|
||||
private readonly source: LocalExecutionControlSource,
|
||||
private readonly coordinator: Pick<
|
||||
LocalExecutionControlCoordinator,
|
||||
'process' | 'requestShutdown'
|
||||
>,
|
||||
private readonly clock: { now(): number } = { now: Date.now },
|
||||
) {}
|
||||
|
||||
async scan(options: {
|
||||
readonly limit: number;
|
||||
readonly cursor?: LocalExecutionControlCursor;
|
||||
}): Promise<LocalExecutionControlScanSummary> {
|
||||
assertLocalExecutionControlLimit(options.limit);
|
||||
const observedAtMs = timestamp(
|
||||
this.clock.now(),
|
||||
'Local execution control scan time',
|
||||
);
|
||||
const page = await this.source.listLocalExecutionControlCandidates({
|
||||
observedAtMs,
|
||||
limit: options.limit,
|
||||
...(options.cursor === undefined ? {} : { after: options.cursor }),
|
||||
});
|
||||
if (page.candidates.length > options.limit) {
|
||||
throw new RangeError('Local execution control source exceeded page size');
|
||||
}
|
||||
let previous = options.cursor;
|
||||
let terminal = 0;
|
||||
let cancelRequested = 0;
|
||||
let stale = 0;
|
||||
let remaining = 0;
|
||||
let failed = 0;
|
||||
for (const value of page.candidates) {
|
||||
const candidate = normalizeLocalExecutionControlCandidate(value);
|
||||
const cursor = controlCursor(candidate);
|
||||
if (previous && !controlAdvances(previous, cursor)) {
|
||||
throw new TypeError(
|
||||
'Local execution control page is not strictly ordered',
|
||||
);
|
||||
}
|
||||
previous = cursor;
|
||||
try {
|
||||
const disposition = await this.coordinator.process(candidate);
|
||||
if (disposition === 'terminal') terminal += 1;
|
||||
if (disposition === 'cancel_requested') cancelRequested += 1;
|
||||
if (disposition === 'stale') stale += 1;
|
||||
if (disposition === 'remaining') remaining += 1;
|
||||
} catch {
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
if (page.truncated && page.candidates.length === 0) {
|
||||
throw new TypeError(
|
||||
'Local execution control source returned an empty truncated page',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
scanned: page.candidates.length,
|
||||
terminal,
|
||||
cancelRequested,
|
||||
stale,
|
||||
remaining,
|
||||
failed,
|
||||
truncated: page.truncated,
|
||||
...(page.truncated && previous ? { nextCursor: previous } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
async drain(options: {
|
||||
readonly limit: number;
|
||||
readonly maxPages: number;
|
||||
}): Promise<LocalExecutionDrainSummary> {
|
||||
assertLocalExecutionControlLimit(options.limit);
|
||||
if (
|
||||
!Number.isSafeInteger(options.maxPages) ||
|
||||
options.maxPages < 1 ||
|
||||
options.maxPages > MAX_LOCAL_EXECUTION_CONTROL_PAGES
|
||||
) {
|
||||
throw new RangeError('Local execution drain page budget is invalid');
|
||||
}
|
||||
const requestedAtMs = timestamp(
|
||||
this.clock.now(),
|
||||
'Local execution drain time',
|
||||
);
|
||||
let cursor: LocalActiveExecutionCursor | undefined;
|
||||
let scanned = 0;
|
||||
let terminal = 0;
|
||||
let remaining = 0;
|
||||
let failed = 0;
|
||||
let truncated = false;
|
||||
for (let pageIndex = 0; pageIndex < options.maxPages; pageIndex += 1) {
|
||||
const page = await this.source.listLocalActiveExecutions({
|
||||
limit: options.limit,
|
||||
...(cursor === undefined ? {} : { after: cursor }),
|
||||
});
|
||||
if (page.candidates.length > options.limit) {
|
||||
throw new RangeError(
|
||||
'Local active execution source exceeded page size',
|
||||
);
|
||||
}
|
||||
let previous = cursor;
|
||||
for (const value of page.candidates) {
|
||||
const candidate = normalizeLocalActiveExecutionCandidate(value);
|
||||
const next = activeCursor(candidate);
|
||||
if (previous && !activeAdvances(previous, next)) {
|
||||
throw new TypeError(
|
||||
'Local active execution page is not strictly ordered',
|
||||
);
|
||||
}
|
||||
previous = next;
|
||||
scanned += 1;
|
||||
try {
|
||||
const disposition = await this.coordinator.requestShutdown(
|
||||
candidate,
|
||||
requestedAtMs,
|
||||
);
|
||||
if (disposition === 'terminal' || disposition === 'stale')
|
||||
terminal += 1;
|
||||
else remaining += 1;
|
||||
} catch {
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
truncated = page.truncated;
|
||||
if (!page.truncated) break;
|
||||
if (!previous) {
|
||||
throw new TypeError(
|
||||
'Local active execution source returned an empty truncated page',
|
||||
);
|
||||
}
|
||||
cursor = previous;
|
||||
}
|
||||
return Object.freeze({
|
||||
scanned,
|
||||
terminal,
|
||||
remaining,
|
||||
failed,
|
||||
truncated,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './completion';
|
||||
export * from './control';
|
||||
export * from './lifecycle';
|
||||
@@ -0,0 +1,291 @@
|
||||
import type { LocalCompletionReceiptJournalCursor } from '@qinglong/runtime-core/local-completion-receipt-journal';
|
||||
import { assertLocalExecutionControlLimit } from '@qinglong/runtime-core/local-execution-control';
|
||||
import type {
|
||||
LocalCompletionReceiptCleanupScanner,
|
||||
LocalCompletionReceiptCleanupSummary,
|
||||
} from '@qinglong/local-process';
|
||||
import type { LocalCompletionReceiptProcessor } from './completion';
|
||||
import type {
|
||||
LocalExecutionControlScanSummary,
|
||||
LocalExecutionControlScanner,
|
||||
LocalExecutionDrainSummary,
|
||||
} from './control';
|
||||
|
||||
export const MAX_LOCAL_COMPLETION_NOTIFICATIONS = 64;
|
||||
|
||||
export interface LocalExecutionControlLifecycleOptions {
|
||||
readonly intervalMs: number;
|
||||
readonly pageSize: number;
|
||||
readonly cleanupIntervalMs: number;
|
||||
readonly cleanupPageSize: number;
|
||||
readonly stopTimeoutMs: number;
|
||||
readonly maxDrainPages: number;
|
||||
readonly maxNotifications?: number;
|
||||
readonly clock?: { now(): number };
|
||||
readonly onDiagnostic?: (
|
||||
error: unknown,
|
||||
summary?: LocalExecutionControlCycleSummary,
|
||||
) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface LocalExecutionControlCycleSummary {
|
||||
readonly completions: number;
|
||||
readonly completionFailures: number;
|
||||
readonly control: LocalExecutionControlScanSummary;
|
||||
readonly cleanup?: LocalCompletionReceiptCleanupSummary;
|
||||
}
|
||||
|
||||
export interface LocalExecutionControlStopSummary {
|
||||
readonly status: 'stopped' | 'timed_out';
|
||||
readonly drain?: LocalExecutionDrainSummary;
|
||||
readonly cleanup?: LocalCompletionReceiptCleanupSummary;
|
||||
}
|
||||
|
||||
export class LocalExecutionControlLifecycle {
|
||||
private readonly clock: { now(): number };
|
||||
private readonly maxNotifications: number;
|
||||
private timer: NodeJS.Timeout | undefined;
|
||||
private inFlight: Promise<LocalExecutionControlCycleSummary> | undefined;
|
||||
private stopPromise: Promise<LocalExecutionControlStopSummary> | undefined;
|
||||
private running = false;
|
||||
private stopping = false;
|
||||
private kickQueued = false;
|
||||
private controlCursor:
|
||||
| NonNullable<LocalExecutionControlScanSummary['nextCursor']>
|
||||
| undefined;
|
||||
private cleanupCursor: LocalCompletionReceiptJournalCursor | undefined;
|
||||
private lastCleanupAtMs: number | undefined;
|
||||
private readonly pending = new Set<string>();
|
||||
|
||||
constructor(
|
||||
private readonly completions: Pick<
|
||||
LocalCompletionReceiptProcessor,
|
||||
'process'
|
||||
>,
|
||||
private readonly control: Pick<
|
||||
LocalExecutionControlScanner,
|
||||
'scan' | 'drain'
|
||||
>,
|
||||
private readonly cleanup: Pick<
|
||||
LocalCompletionReceiptCleanupScanner,
|
||||
'scan'
|
||||
>,
|
||||
private readonly options: LocalExecutionControlLifecycleOptions,
|
||||
) {
|
||||
if (
|
||||
!Number.isSafeInteger(options.intervalMs) ||
|
||||
options.intervalMs < 250 ||
|
||||
options.intervalMs > 60 * 60_000
|
||||
) {
|
||||
throw new RangeError('Local execution control interval is invalid');
|
||||
}
|
||||
assertLocalExecutionControlLimit(options.pageSize);
|
||||
if (
|
||||
!Number.isSafeInteger(options.cleanupIntervalMs) ||
|
||||
options.cleanupIntervalMs < 1_000 ||
|
||||
options.cleanupIntervalMs > 24 * 60 * 60_000
|
||||
) {
|
||||
throw new RangeError('Local execution cleanup interval is invalid');
|
||||
}
|
||||
assertLocalExecutionControlLimit(options.cleanupPageSize);
|
||||
if (
|
||||
!Number.isSafeInteger(options.stopTimeoutMs) ||
|
||||
options.stopTimeoutMs < 100 ||
|
||||
options.stopTimeoutMs > 30_000
|
||||
) {
|
||||
throw new RangeError('Local execution control stop timeout is invalid');
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(options.maxDrainPages) ||
|
||||
options.maxDrainPages < 1 ||
|
||||
options.maxDrainPages > 16
|
||||
) {
|
||||
throw new RangeError('Local execution control drain budget is invalid');
|
||||
}
|
||||
this.maxNotifications =
|
||||
options.maxNotifications ?? MAX_LOCAL_COMPLETION_NOTIFICATIONS;
|
||||
if (
|
||||
!Number.isSafeInteger(this.maxNotifications) ||
|
||||
this.maxNotifications < 1 ||
|
||||
this.maxNotifications > MAX_LOCAL_COMPLETION_NOTIFICATIONS
|
||||
) {
|
||||
throw new RangeError('Local completion notification budget is invalid');
|
||||
}
|
||||
this.clock = options.clock ?? { now: Date.now };
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.running || this.stopping) return;
|
||||
this.running = true;
|
||||
this.schedule();
|
||||
}
|
||||
|
||||
notifyCompletion(attemptId: string): boolean {
|
||||
if (
|
||||
this.stopping ||
|
||||
typeof attemptId !== 'string' ||
|
||||
attemptId.length > 128
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
!this.pending.has(attemptId) &&
|
||||
this.pending.size >= this.maxNotifications
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
this.pending.add(attemptId);
|
||||
this.kick();
|
||||
return true;
|
||||
}
|
||||
|
||||
runOnce(forceCleanup = false): Promise<LocalExecutionControlCycleSummary> {
|
||||
if (this.inFlight) return this.inFlight;
|
||||
const work = this.cycle(forceCleanup).finally(() => {
|
||||
if (this.inFlight === work) this.inFlight = undefined;
|
||||
});
|
||||
this.inFlight = work;
|
||||
return work;
|
||||
}
|
||||
|
||||
stopAndDrain(): Promise<LocalExecutionControlStopSummary> {
|
||||
if (this.stopPromise) return this.stopPromise;
|
||||
this.stopping = true;
|
||||
this.running = false;
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.timer = undefined;
|
||||
|
||||
const stopWork = (async () => {
|
||||
await this.inFlight;
|
||||
await this.processPending();
|
||||
const drain = await this.control.drain({
|
||||
limit: this.options.pageSize,
|
||||
maxPages: this.options.maxDrainPages,
|
||||
});
|
||||
const cleanup = await this.cleanup.scan({
|
||||
limit: this.options.cleanupPageSize,
|
||||
...(this.cleanupCursor === undefined
|
||||
? {}
|
||||
: { cursor: this.cleanupCursor }),
|
||||
});
|
||||
this.cleanupCursor = cleanup.truncated ? cleanup.nextCursor : undefined;
|
||||
return Object.freeze({
|
||||
status:
|
||||
drain.remaining === 0 && drain.failed === 0 && !drain.truncated
|
||||
? ('stopped' as const)
|
||||
: ('timed_out' as const),
|
||||
drain,
|
||||
cleanup,
|
||||
});
|
||||
})();
|
||||
this.stopPromise = Promise.race([
|
||||
stopWork,
|
||||
new Promise<LocalExecutionControlStopSummary>((resolve) => {
|
||||
const timer = setTimeout(
|
||||
() => resolve(Object.freeze({ status: 'timed_out' as const })),
|
||||
this.options.stopTimeoutMs,
|
||||
);
|
||||
timer.unref?.();
|
||||
}),
|
||||
]);
|
||||
return this.stopPromise;
|
||||
}
|
||||
|
||||
private schedule(): void {
|
||||
if (!this.running || this.timer) return;
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = undefined;
|
||||
if (!this.running) return;
|
||||
void this.runOnce()
|
||||
.then((summary) => this.diagnostic(undefined, summary))
|
||||
.catch((error) => this.diagnostic(error))
|
||||
.finally(() => this.schedule());
|
||||
}, this.options.intervalMs);
|
||||
this.timer.unref?.();
|
||||
}
|
||||
|
||||
private kick(): void {
|
||||
if (this.kickQueued || this.inFlight || this.stopping) return;
|
||||
this.kickQueued = true;
|
||||
queueMicrotask(() => {
|
||||
this.kickQueued = false;
|
||||
if (this.stopping || this.inFlight) return;
|
||||
void this.runOnce()
|
||||
.then((summary) => this.diagnostic(undefined, summary))
|
||||
.catch((error) => this.diagnostic(error));
|
||||
});
|
||||
}
|
||||
|
||||
private async cycle(
|
||||
forceCleanup: boolean,
|
||||
): Promise<LocalExecutionControlCycleSummary> {
|
||||
const completion = await this.processPending();
|
||||
const control = await this.control.scan({
|
||||
limit: this.options.pageSize,
|
||||
...(this.controlCursor === undefined
|
||||
? {}
|
||||
: { cursor: this.controlCursor }),
|
||||
});
|
||||
this.controlCursor = control.truncated ? control.nextCursor : undefined;
|
||||
const now = this.clock.now();
|
||||
if (!Number.isSafeInteger(now) || now < 0) {
|
||||
throw new RangeError(
|
||||
'Local execution control lifecycle clock is invalid',
|
||||
);
|
||||
}
|
||||
let cleanup: LocalCompletionReceiptCleanupSummary | undefined;
|
||||
if (
|
||||
forceCleanup ||
|
||||
this.lastCleanupAtMs === undefined ||
|
||||
now - this.lastCleanupAtMs >= this.options.cleanupIntervalMs
|
||||
) {
|
||||
cleanup = await this.cleanup.scan({
|
||||
limit: this.options.cleanupPageSize,
|
||||
...(this.cleanupCursor === undefined
|
||||
? {}
|
||||
: { cursor: this.cleanupCursor }),
|
||||
});
|
||||
this.cleanupCursor = cleanup.truncated ? cleanup.nextCursor : undefined;
|
||||
this.lastCleanupAtMs = now;
|
||||
}
|
||||
if (this.pending.size > 0) this.kick();
|
||||
return Object.freeze({
|
||||
completions: completion.processed,
|
||||
completionFailures: completion.failed,
|
||||
control,
|
||||
...(cleanup === undefined ? {} : { cleanup }),
|
||||
});
|
||||
}
|
||||
|
||||
private async processPending(): Promise<
|
||||
Readonly<{
|
||||
processed: number;
|
||||
failed: number;
|
||||
}>
|
||||
> {
|
||||
const batch = [...this.pending].slice(0, this.maxNotifications);
|
||||
for (const attemptId of batch) this.pending.delete(attemptId);
|
||||
let processed = 0;
|
||||
let failed = 0;
|
||||
for (const attemptId of batch) {
|
||||
try {
|
||||
await this.completions.process(attemptId);
|
||||
processed += 1;
|
||||
} catch {
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
return Object.freeze({ processed, failed });
|
||||
}
|
||||
|
||||
private async diagnostic(
|
||||
error: unknown,
|
||||
summary?: LocalExecutionControlCycleSummary,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.options.onDiagnostic?.(error, summary);
|
||||
} catch {
|
||||
// Diagnostics cannot own execution-control liveness.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { constants } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type { LocalDispatchCandidate } from '@qinglong/runtime-core/local-dispatch';
|
||||
import { normalizeLocalDispatchCandidate } from '@qinglong/runtime-core/local-dispatch';
|
||||
|
||||
export const MIN_LOCAL_ARTIFACT_MAXIMUM_BYTES = 64 * 1024;
|
||||
export const MAX_LOCAL_ARTIFACT_MAXIMUM_BYTES = 1024 * 1024 * 1024;
|
||||
export const MAX_LOCAL_ARTIFACT_MINIMUM_FREE_BYTES = 1024 ** 4;
|
||||
|
||||
export interface LocalArtifactCapacityPolicy {
|
||||
readonly maximumAttemptBytes: number;
|
||||
readonly minimumFreeBytes: number;
|
||||
}
|
||||
|
||||
export interface LocalArtifactCapacityProbe {
|
||||
inspect(directory: string): Promise<bigint>;
|
||||
}
|
||||
|
||||
export interface PreparedLocalArtifact {
|
||||
readonly logArtifactId: string;
|
||||
readonly output: Readonly<{
|
||||
filePath: string;
|
||||
maximumBytes: number;
|
||||
logArtifactId: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface LocalArtifactAllocator {
|
||||
prepare(candidate: LocalDispatchCandidate): Promise<PreparedLocalArtifact>;
|
||||
}
|
||||
|
||||
export class LocalArtifactCapacityUnavailableError extends Error {
|
||||
readonly code = 'LOCAL_ARTIFACT_CAPACITY_UNAVAILABLE';
|
||||
|
||||
constructor() {
|
||||
super('Local Artifact capacity is unavailable');
|
||||
this.name = 'LocalArtifactCapacityUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalArtifactIdentityConflictError extends Error {
|
||||
readonly code = 'LOCAL_ARTIFACT_IDENTITY_CONFLICT';
|
||||
|
||||
constructor() {
|
||||
super('Local Artifact identity is already occupied');
|
||||
this.name = 'LocalArtifactIdentityConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
class StatFsLocalArtifactCapacityProbe implements LocalArtifactCapacityProbe {
|
||||
async inspect(directory: string): Promise<bigint> {
|
||||
const stat = await fs.statfs(directory, { bigint: true });
|
||||
return stat.bavail * stat.bsize;
|
||||
}
|
||||
}
|
||||
|
||||
function assertAbsoluteRoot(value: string): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!path.isAbsolute(value) ||
|
||||
path.parse(value).root === value ||
|
||||
value.includes('\0') ||
|
||||
Buffer.byteLength(value, 'utf8') > 4096
|
||||
) {
|
||||
throw new TypeError('Local Artifact root is invalid');
|
||||
}
|
||||
return path.resolve(value);
|
||||
}
|
||||
|
||||
function normalizePolicy(
|
||||
policy: LocalArtifactCapacityPolicy,
|
||||
): Readonly<LocalArtifactCapacityPolicy> {
|
||||
if (!policy || typeof policy !== 'object' || Array.isArray(policy)) {
|
||||
throw new TypeError('Local Artifact capacity policy is invalid');
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(policy.maximumAttemptBytes) ||
|
||||
policy.maximumAttemptBytes < MIN_LOCAL_ARTIFACT_MAXIMUM_BYTES ||
|
||||
policy.maximumAttemptBytes > MAX_LOCAL_ARTIFACT_MAXIMUM_BYTES ||
|
||||
!Number.isSafeInteger(policy.minimumFreeBytes) ||
|
||||
policy.minimumFreeBytes < 0 ||
|
||||
policy.minimumFreeBytes > MAX_LOCAL_ARTIFACT_MINIMUM_FREE_BYTES
|
||||
) {
|
||||
throw new RangeError('Local Artifact capacity policy is out of range');
|
||||
}
|
||||
return Object.freeze({ ...policy });
|
||||
}
|
||||
|
||||
async function ensurePrivateDirectory(directory: string): Promise<void> {
|
||||
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
||||
const stat = await fs.lstat(directory);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
||||
throw new TypeError('Local Artifact directory is unsafe');
|
||||
}
|
||||
await fs.chmod(directory, 0o700);
|
||||
}
|
||||
|
||||
export function localArtifactCapacityPolicyForProfile(
|
||||
profile: 'edge' | 'standalone',
|
||||
): Readonly<LocalArtifactCapacityPolicy> {
|
||||
if (profile === 'edge') {
|
||||
return Object.freeze({
|
||||
maximumAttemptBytes: 4 * 1024 * 1024,
|
||||
minimumFreeBytes: 32 * 1024 * 1024,
|
||||
});
|
||||
}
|
||||
if (profile === 'standalone') {
|
||||
return Object.freeze({
|
||||
maximumAttemptBytes: 64 * 1024 * 1024,
|
||||
minimumFreeBytes: 256 * 1024 * 1024,
|
||||
});
|
||||
}
|
||||
throw new TypeError('Local Artifact Profile is invalid');
|
||||
}
|
||||
|
||||
export function localArtifactId(candidate: LocalDispatchCandidate): string {
|
||||
const normalized = normalizeLocalDispatchCandidate(candidate);
|
||||
return `local-${createHash('sha256')
|
||||
.update(normalized.runId, 'utf8')
|
||||
.update('\0', 'utf8')
|
||||
.update(normalized.attemptId, 'utf8')
|
||||
.digest('hex')
|
||||
.slice(0, 30)}`;
|
||||
}
|
||||
|
||||
export class LocalFileArtifactAllocator implements LocalArtifactAllocator {
|
||||
private readonly root: string;
|
||||
private readonly policy: Readonly<LocalArtifactCapacityPolicy>;
|
||||
|
||||
constructor(
|
||||
root: string,
|
||||
policy: LocalArtifactCapacityPolicy,
|
||||
private readonly capacity: LocalArtifactCapacityProbe = new StatFsLocalArtifactCapacityProbe(),
|
||||
) {
|
||||
this.root = assertAbsoluteRoot(root);
|
||||
this.policy = normalizePolicy(policy);
|
||||
}
|
||||
|
||||
async prepare(
|
||||
candidate: LocalDispatchCandidate,
|
||||
): Promise<PreparedLocalArtifact> {
|
||||
const normalized = normalizeLocalDispatchCandidate(candidate);
|
||||
const logArtifactId = localArtifactId(normalized);
|
||||
const shard = logArtifactId.slice(6, 8);
|
||||
const directory = path.join(this.root, shard);
|
||||
await ensurePrivateDirectory(this.root);
|
||||
const availableBytes = await this.capacity.inspect(this.root);
|
||||
const requiredBytes =
|
||||
BigInt(this.policy.minimumFreeBytes) +
|
||||
BigInt(this.policy.maximumAttemptBytes);
|
||||
if (availableBytes < requiredBytes) {
|
||||
throw new LocalArtifactCapacityUnavailableError();
|
||||
}
|
||||
await ensurePrivateDirectory(directory);
|
||||
const filePath = path.join(directory, `${logArtifactId}.log`);
|
||||
let file: fs.FileHandle | undefined;
|
||||
try {
|
||||
file = await fs.open(
|
||||
filePath,
|
||||
constants.O_WRONLY |
|
||||
constants.O_CREAT |
|
||||
constants.O_APPEND |
|
||||
(constants.O_NOFOLLOW ?? 0),
|
||||
0o600,
|
||||
);
|
||||
const stat = await file.stat();
|
||||
if (!stat.isFile() || stat.size !== 0) {
|
||||
throw new LocalArtifactIdentityConflictError();
|
||||
}
|
||||
await file.chmod(0o600);
|
||||
} finally {
|
||||
await file?.close().catch(() => undefined);
|
||||
}
|
||||
return Object.freeze({
|
||||
logArtifactId,
|
||||
output: Object.freeze({
|
||||
filePath,
|
||||
maximumBytes: this.policy.maximumAttemptBytes,
|
||||
logArtifactId,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import {
|
||||
LOCAL_PROCESS_EXECUTOR_TYPE,
|
||||
assertLocalDispatchPageSize,
|
||||
normalizeLocalDispatchCandidate,
|
||||
type LocalDispatchCandidate,
|
||||
type LocalDispatchCandidateCursor,
|
||||
type LocalDispatchCandidateSource,
|
||||
} from '@qinglong/runtime-core/local-dispatch';
|
||||
import {
|
||||
LocalExecutionLaunchError,
|
||||
LocalExecutionRejectedError,
|
||||
type LocalExecutionStartCommand,
|
||||
type LocalExecutionStartResult,
|
||||
} from '../execution/coordinator';
|
||||
import type { LocalDispatchPlanSource } from './materializer';
|
||||
|
||||
export interface LocalDispatchActivator {
|
||||
start(
|
||||
command: LocalExecutionStartCommand,
|
||||
): Promise<LocalExecutionStartResult>;
|
||||
}
|
||||
|
||||
export interface LocalRunDispatcherOptions {
|
||||
readonly pageSize?: number;
|
||||
readonly maxPages?: number;
|
||||
readonly onCompletion?: (attemptId: string) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface LocalRunDispatcherStats {
|
||||
readonly pages: number;
|
||||
readonly candidatesScanned: number;
|
||||
readonly plansUnavailable: number;
|
||||
readonly activationRaces: number;
|
||||
}
|
||||
|
||||
export type LocalRunDispatcherResult =
|
||||
| Readonly<{
|
||||
status: 'activated';
|
||||
runId: string;
|
||||
attemptId: string;
|
||||
stats: LocalRunDispatcherStats;
|
||||
truncated: boolean;
|
||||
}>
|
||||
| Readonly<{
|
||||
status: 'activation_failed';
|
||||
runId: string;
|
||||
attemptId: string;
|
||||
stats: LocalRunDispatcherStats;
|
||||
truncated: boolean;
|
||||
}>
|
||||
| Readonly<{
|
||||
status: 'idle';
|
||||
reason:
|
||||
| 'no_candidates'
|
||||
| 'plans_unavailable'
|
||||
| 'activation_raced'
|
||||
| 'scan_budget_exhausted';
|
||||
stats: LocalRunDispatcherStats;
|
||||
truncated: boolean;
|
||||
}>;
|
||||
|
||||
function cursorOf(
|
||||
candidate: LocalDispatchCandidate,
|
||||
): LocalDispatchCandidateCursor {
|
||||
return Object.freeze({
|
||||
priority: candidate.priority,
|
||||
queuedAtMs: candidate.queuedAtMs,
|
||||
attemptCreatedAtMs: candidate.attemptCreatedAtMs,
|
||||
attemptId: candidate.attemptId,
|
||||
});
|
||||
}
|
||||
|
||||
function advances(
|
||||
previous: LocalDispatchCandidateCursor,
|
||||
next: LocalDispatchCandidateCursor,
|
||||
): boolean {
|
||||
return (
|
||||
next.priority < previous.priority ||
|
||||
(next.priority === previous.priority &&
|
||||
(next.queuedAtMs > previous.queuedAtMs ||
|
||||
(next.queuedAtMs === previous.queuedAtMs &&
|
||||
(next.attemptCreatedAtMs > previous.attemptCreatedAtMs ||
|
||||
(next.attemptCreatedAtMs === previous.attemptCreatedAtMs &&
|
||||
next.attemptId > previous.attemptId)))))
|
||||
);
|
||||
}
|
||||
|
||||
export class LocalRunDispatcher {
|
||||
private readonly pageSize: number;
|
||||
private readonly maxPages: number;
|
||||
private readonly onCompletion?: LocalRunDispatcherOptions['onCompletion'];
|
||||
|
||||
constructor(
|
||||
private readonly candidates: LocalDispatchCandidateSource,
|
||||
private readonly plans: LocalDispatchPlanSource,
|
||||
private readonly activator: LocalDispatchActivator,
|
||||
options: LocalRunDispatcherOptions = {},
|
||||
) {
|
||||
this.pageSize = options.pageSize ?? 8;
|
||||
this.maxPages = options.maxPages ?? 1;
|
||||
this.onCompletion = options.onCompletion;
|
||||
assertLocalDispatchPageSize(this.pageSize);
|
||||
if (
|
||||
!Number.isSafeInteger(this.maxPages) ||
|
||||
this.maxPages < 1 ||
|
||||
this.maxPages > 16
|
||||
) {
|
||||
throw new RangeError('Local dispatch maxPages must be between 1 and 16');
|
||||
}
|
||||
}
|
||||
|
||||
async dispatchOnce(): Promise<LocalRunDispatcherResult> {
|
||||
const stats = {
|
||||
pages: 0,
|
||||
candidatesScanned: 0,
|
||||
plansUnavailable: 0,
|
||||
activationRaces: 0,
|
||||
};
|
||||
const seen = new Set<string>();
|
||||
let after: LocalDispatchCandidateCursor | undefined;
|
||||
let truncated = false;
|
||||
for (let pageIndex = 0; pageIndex < this.maxPages; pageIndex += 1) {
|
||||
const page = await this.candidates.listLocalDispatchCandidates({
|
||||
limit: this.pageSize,
|
||||
...(after === undefined ? {} : { after }),
|
||||
});
|
||||
if (page.candidates.length > this.pageSize) {
|
||||
throw new RangeError('Local dispatch source exceeded its page size');
|
||||
}
|
||||
stats.pages += 1;
|
||||
truncated = page.truncated;
|
||||
let previous = after;
|
||||
for (const value of page.candidates) {
|
||||
const candidate = normalizeLocalDispatchCandidate(value);
|
||||
if (candidate.executorType !== LOCAL_PROCESS_EXECUTOR_TYPE) {
|
||||
throw new TypeError(
|
||||
'Local dispatch source returned another executor',
|
||||
);
|
||||
}
|
||||
const cursor = cursorOf(candidate);
|
||||
if (
|
||||
seen.has(candidate.attemptId) ||
|
||||
(previous !== undefined && !advances(previous, cursor))
|
||||
) {
|
||||
throw new TypeError('Local dispatch page is not strictly ordered');
|
||||
}
|
||||
seen.add(candidate.attemptId);
|
||||
previous = cursor;
|
||||
stats.candidatesScanned += 1;
|
||||
const plan = await this.plans.prepare(candidate);
|
||||
if (!plan) {
|
||||
stats.plansUnavailable += 1;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const active = await this.activator.start(plan.command);
|
||||
void active.handle.completion
|
||||
.then(
|
||||
() => this.onCompletion?.(active.attempt.id),
|
||||
() => this.onCompletion?.(active.attempt.id),
|
||||
)
|
||||
.catch(() => undefined);
|
||||
return Object.freeze({
|
||||
status: 'activated' as const,
|
||||
runId: active.run.id,
|
||||
attemptId: active.attempt.id,
|
||||
stats: Object.freeze({ ...stats }),
|
||||
truncated,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof LocalExecutionRejectedError) {
|
||||
if (
|
||||
error.reason === 'aggregate_mismatch' ||
|
||||
error.reason === 'executor_mismatch'
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
stats.activationRaces += 1;
|
||||
continue;
|
||||
}
|
||||
if (error instanceof LocalExecutionLaunchError) {
|
||||
return Object.freeze({
|
||||
status: 'activation_failed' as const,
|
||||
runId: candidate.runId,
|
||||
attemptId: candidate.attemptId,
|
||||
stats: Object.freeze({ ...stats }),
|
||||
truncated,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
if (!page.truncated) return this.idle(stats, false);
|
||||
const last = page.candidates.at(-1);
|
||||
if (!last) {
|
||||
throw new TypeError(
|
||||
'Local dispatch source reported an empty truncated page',
|
||||
);
|
||||
}
|
||||
after = cursorOf(last);
|
||||
}
|
||||
return Object.freeze({
|
||||
status: 'idle' as const,
|
||||
reason: 'scan_budget_exhausted' as const,
|
||||
stats: Object.freeze({ ...stats }),
|
||||
truncated: true,
|
||||
});
|
||||
}
|
||||
|
||||
private idle(
|
||||
stats: LocalRunDispatcherStats,
|
||||
truncated: boolean,
|
||||
): LocalRunDispatcherResult {
|
||||
const reason =
|
||||
stats.candidatesScanned === 0
|
||||
? 'no_candidates'
|
||||
: stats.plansUnavailable === stats.candidatesScanned
|
||||
? 'plans_unavailable'
|
||||
: 'activation_raced';
|
||||
return Object.freeze({
|
||||
status: 'idle' as const,
|
||||
reason,
|
||||
stats: Object.freeze({ ...stats }),
|
||||
truncated,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export * from './artifact';
|
||||
export * from './materializer';
|
||||
export * from './dispatcher';
|
||||
export type {
|
||||
LocalDispatchDefinitionWriter,
|
||||
LocalDispatchStore,
|
||||
LocalSecretEnvironmentProvider,
|
||||
} from '@qinglong/runtime-core/local-dispatch';
|
||||
@@ -0,0 +1,133 @@
|
||||
import type {
|
||||
LocalDispatchCandidate,
|
||||
LocalDispatchStore,
|
||||
LocalSecretEnvironmentProvider,
|
||||
} from '@qinglong/runtime-core/local-dispatch';
|
||||
import {
|
||||
MAX_LOCAL_DISPATCH_ENVIRONMENT_BYTES,
|
||||
MAX_LOCAL_DISPATCH_SECRET_REFS,
|
||||
normalizeLocalDispatchCandidate,
|
||||
normalizeLocalExecutionContextRecipe,
|
||||
normalizeLocalTaskExecutionRevision,
|
||||
} from '@qinglong/runtime-core/local-dispatch';
|
||||
import type { LocalExecutionStartCommand } from '../execution/coordinator';
|
||||
import type { LocalArtifactAllocator } from './artifact';
|
||||
|
||||
export interface LocalDispatchPlan {
|
||||
readonly command: LocalExecutionStartCommand;
|
||||
}
|
||||
|
||||
export interface LocalDispatchPlanSource {
|
||||
prepare(candidate: LocalDispatchCandidate): Promise<LocalDispatchPlan | null>;
|
||||
}
|
||||
|
||||
function assertEnvironmentValue(value: unknown): asserts value is string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.includes('\0') ||
|
||||
Buffer.byteLength(value, 'utf8') > 16 * 1024
|
||||
) {
|
||||
throw new TypeError('Local dispatch environment value is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalDispatchPlanMaterializer implements LocalDispatchPlanSource {
|
||||
constructor(
|
||||
private readonly definitions: Pick<
|
||||
LocalDispatchStore,
|
||||
'resolveLocalTaskExecutionRevision' | 'resolveLocalExecutionContextRecipe'
|
||||
>,
|
||||
private readonly artifacts: LocalArtifactAllocator,
|
||||
private readonly secrets?: LocalSecretEnvironmentProvider,
|
||||
) {}
|
||||
|
||||
async prepare(
|
||||
candidate: LocalDispatchCandidate,
|
||||
): Promise<LocalDispatchPlan | null> {
|
||||
const normalizedCandidate = normalizeLocalDispatchCandidate(candidate);
|
||||
const revision = await this.definitions.resolveLocalTaskExecutionRevision({
|
||||
projectId: normalizedCandidate.projectId,
|
||||
taskId: normalizedCandidate.taskId,
|
||||
taskRevision: normalizedCandidate.taskRevision,
|
||||
});
|
||||
if (!revision) return null;
|
||||
const normalizedRevision = normalizeLocalTaskExecutionRevision(revision);
|
||||
if (
|
||||
normalizedRevision.projectId !== normalizedCandidate.projectId ||
|
||||
normalizedRevision.taskId !== normalizedCandidate.taskId ||
|
||||
normalizedRevision.taskRevision !== normalizedCandidate.taskRevision ||
|
||||
normalizedRevision.executorType !== normalizedCandidate.executorType
|
||||
) {
|
||||
throw new TypeError('Local Task revision does not match its candidate');
|
||||
}
|
||||
const recipe = await this.definitions.resolveLocalExecutionContextRecipe(
|
||||
normalizedRevision.contextRef,
|
||||
);
|
||||
if (!recipe) return null;
|
||||
const normalizedRecipe = normalizeLocalExecutionContextRecipe(recipe);
|
||||
if (normalizedRecipe.contextRef !== normalizedRevision.contextRef) {
|
||||
throw new TypeError('Local context recipe does not match its revision');
|
||||
}
|
||||
const secretRefs = Object.freeze([
|
||||
...new Set(
|
||||
normalizedRecipe.environment.flatMap((binding) =>
|
||||
binding.kind === 'secret' ? [binding.secretRef] : [],
|
||||
),
|
||||
),
|
||||
]);
|
||||
if (secretRefs.length > MAX_LOCAL_DISPATCH_SECRET_REFS) {
|
||||
throw new RangeError('Local dispatch Secret reference budget exceeded');
|
||||
}
|
||||
let secretValues: readonly string[] = [];
|
||||
if (secretRefs.length > 0) {
|
||||
if (!this.secrets) return null;
|
||||
const resolved = await this.secrets.resolveLocalSecretEnvironment({
|
||||
candidate: normalizedCandidate,
|
||||
secretRefs,
|
||||
});
|
||||
if (!resolved) return null;
|
||||
if (resolved.length !== secretRefs.length) {
|
||||
throw new TypeError('Local Secret provider returned an invalid result');
|
||||
}
|
||||
secretValues = resolved;
|
||||
}
|
||||
const secretByRef = new Map(
|
||||
secretRefs.map((secretRef, index) => [secretRef, secretValues[index]]),
|
||||
);
|
||||
const environment: Record<string, string> = Object.create(null);
|
||||
let environmentBytes = 0;
|
||||
for (const binding of normalizedRecipe.environment) {
|
||||
const value =
|
||||
binding.kind === 'public'
|
||||
? binding.value
|
||||
: secretByRef.get(binding.secretRef);
|
||||
assertEnvironmentValue(value);
|
||||
environmentBytes +=
|
||||
Buffer.byteLength(binding.name, 'utf8') +
|
||||
Buffer.byteLength(value, 'utf8');
|
||||
if (environmentBytes > MAX_LOCAL_DISPATCH_ENVIRONMENT_BYTES) {
|
||||
throw new RangeError('Local dispatch environment byte budget exceeded');
|
||||
}
|
||||
environment[binding.name] = value;
|
||||
}
|
||||
const artifact = await this.artifacts.prepare(normalizedCandidate);
|
||||
return Object.freeze({
|
||||
command: Object.freeze({
|
||||
runId: normalizedCandidate.runId,
|
||||
attemptId: normalizedCandidate.attemptId,
|
||||
...(normalizedCandidate.stepRunId === undefined
|
||||
? {}
|
||||
: { stepRunId: normalizedCandidate.stepRunId }),
|
||||
command: normalizedRevision.command,
|
||||
environment: Object.freeze(environment),
|
||||
...(normalizedRevision.workingDirectory === undefined
|
||||
? {}
|
||||
: { workingDirectory: normalizedRevision.workingDirectory }),
|
||||
...(normalizedRevision.timeoutMs === undefined
|
||||
? {}
|
||||
: { timeoutMs: normalizedRevision.timeoutMs }),
|
||||
output: artifact.output,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,951 @@
|
||||
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
import { parseLocalProcessDurableHandle } from '@qinglong/local-process';
|
||||
import type {
|
||||
LocalProcessCommand,
|
||||
LocalProcessLaunchHandle,
|
||||
LocalProcessLaunchRequest,
|
||||
LocalProcessOutputPlan,
|
||||
LocalProcessStopResult,
|
||||
} from '@qinglong/local-process';
|
||||
import type {
|
||||
RunAttemptRecord,
|
||||
RunEventRecord,
|
||||
RunRecord,
|
||||
RunRepository,
|
||||
RunRepositoryTransaction,
|
||||
} from '@qinglong/runtime-core/run-repository';
|
||||
import type { LocalWorkflowTaskExecutionRepository } from './workflowTaskExecution';
|
||||
|
||||
export const LOCAL_PROCESS_EXECUTOR_TYPE = 'local_process';
|
||||
export const MAX_LOCAL_EXECUTION_TIMEOUT_MS = 365 * 24 * 60 * 60_000;
|
||||
|
||||
export interface LocalExecutionStartCommand {
|
||||
readonly runId: string;
|
||||
readonly attemptId: string;
|
||||
readonly stepRunId?: string;
|
||||
readonly command: LocalProcessCommand;
|
||||
readonly environment?: Readonly<Record<string, string>>;
|
||||
readonly workingDirectory?: string;
|
||||
readonly output?: LocalProcessOutputPlan;
|
||||
readonly timeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface LocalExecutionStartResult {
|
||||
readonly run: RunRecord;
|
||||
readonly attempt: RunAttemptRecord;
|
||||
readonly handle: LocalProcessLaunchHandle;
|
||||
}
|
||||
|
||||
export interface LocalExecutionLauncher {
|
||||
start(request: LocalProcessLaunchRequest): Promise<LocalProcessLaunchHandle>;
|
||||
}
|
||||
|
||||
export interface LocalExecutionController {
|
||||
stop(durableHandle: string): Promise<LocalProcessStopResult>;
|
||||
}
|
||||
|
||||
export interface LocalExecutionCoordinatorOptions {
|
||||
readonly clock?: { now(): number };
|
||||
readonly createEventId?: () => string;
|
||||
readonly createCallbackToken?: () => string;
|
||||
readonly workflowTasks?: LocalWorkflowTaskExecutionRepository;
|
||||
}
|
||||
|
||||
interface AggregateSnapshot {
|
||||
readonly run: RunRecord;
|
||||
readonly attempt: RunAttemptRecord;
|
||||
}
|
||||
|
||||
interface PreparedAggregate extends AggregateSnapshot {
|
||||
readonly callbackSequence: number;
|
||||
readonly callbackTokenHash: string;
|
||||
}
|
||||
|
||||
interface EventDraft {
|
||||
readonly sequence: number;
|
||||
readonly type: string;
|
||||
readonly payload: Readonly<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export class LocalExecutionRejectedError extends Error {
|
||||
readonly code = 'LOCAL_EXECUTION_REJECTED';
|
||||
|
||||
constructor(readonly reason: string) {
|
||||
super(`Local execution rejected: ${reason}`);
|
||||
this.name = 'LocalExecutionRejectedError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalExecutionConcurrentWriteError extends Error {
|
||||
readonly code = 'LOCAL_EXECUTION_CONCURRENT_WRITE';
|
||||
|
||||
constructor() {
|
||||
super('Local execution aggregate changed concurrently');
|
||||
this.name = 'LocalExecutionConcurrentWriteError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalExecutionLaunchError extends Error {
|
||||
readonly code = 'LOCAL_EXECUTION_LAUNCH_FAILED';
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
readonly snapshot: AggregateSnapshot,
|
||||
readonly cause?: unknown,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'LocalExecutionLaunchError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalExecutionOwnershipPersistenceError extends Error {
|
||||
readonly code = 'LOCAL_EXECUTION_OWNERSHIP_PERSISTENCE_FAILED';
|
||||
|
||||
constructor(
|
||||
readonly compensation: LocalProcessStopResult,
|
||||
readonly snapshot: AggregateSnapshot,
|
||||
readonly cause?: unknown,
|
||||
) {
|
||||
super('Local execution could not persist durable process ownership');
|
||||
this.name = 'LocalExecutionOwnershipPersistenceError';
|
||||
}
|
||||
}
|
||||
|
||||
function assertSafeTimestamp(value: number, field: string): void {
|
||||
if (!Number.isSafeInteger(value) || value < 0) {
|
||||
throw new TypeError(`${field} must be a non-negative safe integer`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertStartCommand(command: LocalExecutionStartCommand): void {
|
||||
if (!command || typeof command !== 'object' || Array.isArray(command)) {
|
||||
throw new TypeError('Local execution start command is invalid');
|
||||
}
|
||||
for (const [field, value] of [
|
||||
['runId', command.runId],
|
||||
['attemptId', command.attemptId],
|
||||
] as const) {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length < 1 ||
|
||||
value.length > 128 ||
|
||||
/[\0\r\n]/.test(value)
|
||||
) {
|
||||
throw new TypeError(`Local execution ${field} is invalid`);
|
||||
}
|
||||
}
|
||||
if (
|
||||
command.stepRunId !== undefined &&
|
||||
(typeof command.stepRunId !== 'string' ||
|
||||
command.stepRunId.length < 1 ||
|
||||
command.stepRunId.length > 128 ||
|
||||
/[\0\r\n]/.test(command.stepRunId))
|
||||
) {
|
||||
throw new TypeError('Local execution stepRunId is invalid');
|
||||
}
|
||||
if (
|
||||
command.timeoutMs !== undefined &&
|
||||
(!Number.isSafeInteger(command.timeoutMs) ||
|
||||
command.timeoutMs < 1 ||
|
||||
command.timeoutMs > MAX_LOCAL_EXECUTION_TIMEOUT_MS)
|
||||
) {
|
||||
throw new RangeError('Local execution timeout is invalid');
|
||||
}
|
||||
if (command.output !== undefined) {
|
||||
const output = command.output;
|
||||
if (
|
||||
!output ||
|
||||
typeof output !== 'object' ||
|
||||
Array.isArray(output) ||
|
||||
!path.isAbsolute(output.filePath) ||
|
||||
path.parse(output.filePath).root === output.filePath ||
|
||||
output.filePath.includes('\0') ||
|
||||
Buffer.byteLength(output.filePath, 'utf8') > 4096 ||
|
||||
!/^local-[0-9a-f]{30}$/.test(output.logArtifactId) ||
|
||||
path.basename(output.filePath) !== `${output.logArtifactId}.log` ||
|
||||
!Number.isSafeInteger(output.maximumBytes) ||
|
||||
output.maximumBytes < 64 * 1024 ||
|
||||
output.maximumBytes > 1024 * 1024 * 1024
|
||||
) {
|
||||
throw new TypeError('Local execution output plan is invalid');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function reserveEvent(run: RunRecord): Readonly<{
|
||||
run: RunRecord;
|
||||
sequence: number;
|
||||
}> {
|
||||
const version = run.version + 1;
|
||||
const sequence = run.eventSequence + 1;
|
||||
if (
|
||||
!Number.isSafeInteger(version) ||
|
||||
version < 1 ||
|
||||
!Number.isSafeInteger(sequence) ||
|
||||
sequence < 1
|
||||
) {
|
||||
throw new TypeError('Local execution version or sequence overflowed');
|
||||
}
|
||||
return Object.freeze({
|
||||
run: { ...run, version, eventSequence: sequence },
|
||||
sequence,
|
||||
});
|
||||
}
|
||||
|
||||
function event(
|
||||
id: string,
|
||||
runId: string,
|
||||
attemptId: string,
|
||||
draft: EventDraft,
|
||||
dedupeKey: string,
|
||||
actorType: RunEventRecord['actorType'],
|
||||
actorId: string,
|
||||
atMs: number,
|
||||
): RunEventRecord {
|
||||
if (
|
||||
typeof id !== 'string' ||
|
||||
id.length < 1 ||
|
||||
id.length > 128 ||
|
||||
/[\0\r\n]/.test(id)
|
||||
) {
|
||||
throw new TypeError('Local execution event id is invalid');
|
||||
}
|
||||
return {
|
||||
id,
|
||||
runId,
|
||||
attemptId,
|
||||
sequence: draft.sequence,
|
||||
type: draft.type,
|
||||
dedupeKey,
|
||||
actorType,
|
||||
actorId,
|
||||
payload: draft.payload,
|
||||
createdAtMs: atMs,
|
||||
};
|
||||
}
|
||||
|
||||
function atOrAfter(
|
||||
now: number,
|
||||
run: RunRecord,
|
||||
attempt: RunAttemptRecord,
|
||||
additional?: number,
|
||||
): number {
|
||||
assertSafeTimestamp(now, 'Local execution clock');
|
||||
if (additional !== undefined) {
|
||||
assertSafeTimestamp(additional, 'Local execution additional timestamp');
|
||||
}
|
||||
return Math.max(
|
||||
now,
|
||||
run.createdAtMs,
|
||||
run.startedAtMs ?? 0,
|
||||
attempt.createdAtMs,
|
||||
attempt.startedAtMs ?? 0,
|
||||
additional ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
function assertClaimedAggregate(
|
||||
run: RunRecord | null,
|
||||
attempt: RunAttemptRecord | null,
|
||||
latest: RunAttemptRecord | null,
|
||||
): asserts run is RunRecord & object {
|
||||
if (!run) throw new LocalExecutionRejectedError('run_not_found');
|
||||
if (!attempt) throw new LocalExecutionRejectedError('attempt_not_found');
|
||||
if (
|
||||
attempt.runId !== run.id ||
|
||||
latest?.runId !== run.id ||
|
||||
latest.id !== attempt.id ||
|
||||
run.executionOwner !== 'runtime'
|
||||
) {
|
||||
throw new LocalExecutionRejectedError('aggregate_mismatch');
|
||||
}
|
||||
if (run.status !== 'queued') {
|
||||
throw new LocalExecutionRejectedError('run_not_queued');
|
||||
}
|
||||
if (attempt.status !== 'claimed') {
|
||||
throw new LocalExecutionRejectedError('attempt_not_claimed');
|
||||
}
|
||||
if (attempt.executorType !== LOCAL_PROCESS_EXECUTOR_TYPE) {
|
||||
throw new LocalExecutionRejectedError('executor_mismatch');
|
||||
}
|
||||
if (run.cancelRequestedAtMs !== undefined) {
|
||||
throw new LocalExecutionRejectedError('cancellation_requested');
|
||||
}
|
||||
if (
|
||||
attempt.callbackSequence !== 0 ||
|
||||
attempt.callbackTokenHash !== undefined ||
|
||||
attempt.workerId !== undefined ||
|
||||
attempt.workerSessionId !== undefined ||
|
||||
attempt.workerGeneration !== undefined ||
|
||||
attempt.executorHandle !== undefined ||
|
||||
attempt.pid !== undefined ||
|
||||
attempt.logArtifactId !== undefined ||
|
||||
attempt.leaseToken !== undefined ||
|
||||
attempt.leaseTokenDigest !== undefined ||
|
||||
attempt.leaseGeneration !== undefined ||
|
||||
attempt.leaseVersion !== undefined ||
|
||||
attempt.leaseExpiresAtMs !== undefined ||
|
||||
attempt.offerId !== undefined ||
|
||||
attempt.deadlineAtMs !== undefined ||
|
||||
attempt.startedAtMs !== undefined ||
|
||||
attempt.finishedAtMs !== undefined ||
|
||||
attempt.exitCode !== undefined ||
|
||||
attempt.errorCode !== undefined ||
|
||||
attempt.errorSummary !== undefined
|
||||
) {
|
||||
throw new LocalExecutionRejectedError('stale_execution_authority');
|
||||
}
|
||||
}
|
||||
|
||||
function assertPreparedAggregate(
|
||||
current: AggregateSnapshot,
|
||||
expected: PreparedAggregate,
|
||||
): void {
|
||||
if (
|
||||
current.run.id !== expected.run.id ||
|
||||
current.run.version !== expected.run.version ||
|
||||
current.run.status !== 'dispatching' ||
|
||||
current.run.executionOwner !== 'runtime' ||
|
||||
current.run.cancelRequestedAtMs !== undefined ||
|
||||
current.attempt.id !== expected.attempt.id ||
|
||||
current.attempt.runId !== current.run.id ||
|
||||
current.attempt.status !== 'starting' ||
|
||||
current.attempt.executorType !== LOCAL_PROCESS_EXECUTOR_TYPE ||
|
||||
current.attempt.callbackSequence !== expected.attempt.callbackSequence ||
|
||||
current.attempt.callbackTokenHash !== expected.callbackTokenHash ||
|
||||
current.attempt.deadlineAtMs !== expected.attempt.deadlineAtMs ||
|
||||
current.attempt.logArtifactId !== expected.attempt.logArtifactId ||
|
||||
current.attempt.executorHandle !== undefined ||
|
||||
current.attempt.pid !== undefined ||
|
||||
current.attempt.startedAtMs !== undefined ||
|
||||
current.attempt.finishedAtMs !== undefined
|
||||
) {
|
||||
throw new LocalExecutionConcurrentWriteError();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadExact(
|
||||
transaction: RunRepositoryTransaction,
|
||||
runId: string,
|
||||
attemptId: string,
|
||||
): Promise<AggregateSnapshot> {
|
||||
const [run, attempt, latest] = await Promise.all([
|
||||
transaction.findRunById(runId),
|
||||
transaction.findAttemptById(attemptId),
|
||||
transaction.findLatestAttemptByRunId(runId),
|
||||
]);
|
||||
if (!run || !attempt || latest?.id !== attempt.id) {
|
||||
throw new LocalExecutionConcurrentWriteError();
|
||||
}
|
||||
return { run, attempt };
|
||||
}
|
||||
|
||||
export class LocalExecutionCoordinator {
|
||||
private readonly clock: { now(): number };
|
||||
private readonly createEventId: () => string;
|
||||
private readonly createCallbackToken: () => string;
|
||||
private readonly workflowTasks:
|
||||
| LocalWorkflowTaskExecutionRepository
|
||||
| undefined;
|
||||
|
||||
constructor(
|
||||
private readonly repository: RunRepository,
|
||||
private readonly launcher: LocalExecutionLauncher,
|
||||
private readonly controller: LocalExecutionController,
|
||||
options: LocalExecutionCoordinatorOptions = {},
|
||||
) {
|
||||
this.clock = options.clock ?? { now: Date.now };
|
||||
this.createEventId = options.createEventId ?? randomUUID;
|
||||
this.createCallbackToken =
|
||||
options.createCallbackToken ??
|
||||
(() => randomBytes(32).toString('base64url'));
|
||||
this.workflowTasks = options.workflowTasks;
|
||||
if (
|
||||
this.workflowTasks !== undefined &&
|
||||
(typeof this.workflowTasks.prepare !== 'function' ||
|
||||
typeof this.workflowTasks.recordRunning !== 'function' ||
|
||||
typeof this.workflowTasks.recordStartFailure !== 'function')
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Local Workflow Task execution repository is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async start(
|
||||
command: LocalExecutionStartCommand,
|
||||
): Promise<LocalExecutionStartResult> {
|
||||
assertStartCommand(command);
|
||||
const callbackToken = this.createCallbackToken();
|
||||
if (!/^[A-Za-z0-9_-]{32,128}$/.test(callbackToken)) {
|
||||
throw new TypeError('Local execution callback token factory is invalid');
|
||||
}
|
||||
const callbackTokenHash = createHash('sha256')
|
||||
.update(callbackToken)
|
||||
.digest('hex');
|
||||
const prepared = await this.prepare(command, callbackTokenHash);
|
||||
|
||||
let handle: LocalProcessLaunchHandle;
|
||||
try {
|
||||
handle = await this.launcher.start({
|
||||
runId: prepared.run.id,
|
||||
attemptId: prepared.attempt.id,
|
||||
callbackSequence: prepared.callbackSequence,
|
||||
callbackToken,
|
||||
command: command.command,
|
||||
...(command.environment === undefined
|
||||
? {}
|
||||
: { environment: command.environment }),
|
||||
...(command.workingDirectory === undefined
|
||||
? {}
|
||||
: { workingDirectory: command.workingDirectory }),
|
||||
...(command.output === undefined ? {} : { output: command.output }),
|
||||
});
|
||||
} catch (error) {
|
||||
try {
|
||||
const failed = await this.recordTerminal(
|
||||
prepared,
|
||||
'failed',
|
||||
'EXECUTOR_START_FAILED',
|
||||
'Local process failed before durable ownership was established',
|
||||
);
|
||||
throw new LocalExecutionLaunchError(
|
||||
'Local process could not start',
|
||||
failed,
|
||||
error,
|
||||
);
|
||||
} catch (persistenceError) {
|
||||
if (persistenceError instanceof LocalExecutionLaunchError) {
|
||||
throw persistenceError;
|
||||
}
|
||||
throw new LocalExecutionLaunchError(
|
||||
'Local process could not start and failure state could not be persisted',
|
||||
prepared,
|
||||
new AggregateError([error, persistenceError]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
this.assertHandle(handle, prepared);
|
||||
const running = await this.recordRunning(prepared, handle);
|
||||
return Object.freeze({ ...running, handle });
|
||||
} catch (error) {
|
||||
void handle.completion.catch(() => undefined);
|
||||
let compensation: LocalProcessStopResult;
|
||||
try {
|
||||
compensation = await this.controller.stop(handle.durableHandle);
|
||||
} catch {
|
||||
compensation = Object.freeze({
|
||||
status: 'unknown' as const,
|
||||
reason: 'signal_failed' as const,
|
||||
});
|
||||
}
|
||||
let snapshot: AggregateSnapshot = prepared;
|
||||
if (
|
||||
compensation.status === 'stopped' ||
|
||||
compensation.status === 'already_exited'
|
||||
) {
|
||||
try {
|
||||
snapshot = await this.recordTerminal(
|
||||
prepared,
|
||||
'lost',
|
||||
'EXECUTION_ACTIVATION_PERSISTENCE_FAILED',
|
||||
'Durable process ownership could not be persisted',
|
||||
);
|
||||
} catch {
|
||||
snapshot = await this.loadLatest(prepared);
|
||||
}
|
||||
} else {
|
||||
snapshot = await this.loadLatest(prepared);
|
||||
}
|
||||
throw new LocalExecutionOwnershipPersistenceError(
|
||||
compensation,
|
||||
snapshot,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async prepare(
|
||||
command: LocalExecutionStartCommand,
|
||||
callbackTokenHash: string,
|
||||
): Promise<PreparedAggregate> {
|
||||
if (command.stepRunId !== undefined) {
|
||||
if (!this.workflowTasks) {
|
||||
throw new LocalExecutionRejectedError(
|
||||
'workflow_task_authority_unavailable',
|
||||
);
|
||||
}
|
||||
const observedAtMs = this.clock.now();
|
||||
assertSafeTimestamp(observedAtMs, 'Local execution clock');
|
||||
const deadlineAtMs =
|
||||
command.timeoutMs === undefined
|
||||
? undefined
|
||||
: observedAtMs + command.timeoutMs;
|
||||
if (
|
||||
deadlineAtMs !== undefined &&
|
||||
(!Number.isSafeInteger(deadlineAtMs) ||
|
||||
deadlineAtMs <= observedAtMs)
|
||||
) {
|
||||
throw new RangeError('Local execution deadline overflowed');
|
||||
}
|
||||
const result = await this.workflowTasks.prepare({
|
||||
runId: command.runId,
|
||||
attemptId: command.attemptId,
|
||||
stepRunId: command.stepRunId,
|
||||
callbackTokenHash,
|
||||
...(deadlineAtMs === undefined ? {} : { deadlineAtMs }),
|
||||
...(command.output === undefined
|
||||
? {}
|
||||
: { logArtifactId: command.output.logArtifactId }),
|
||||
atMs: observedAtMs,
|
||||
eventId: this.createEventId(),
|
||||
});
|
||||
if (result.status === 'rejected') {
|
||||
throw new LocalExecutionRejectedError(result.reason);
|
||||
}
|
||||
const current = await this.loadWorkflowTaskSnapshot(
|
||||
command.runId,
|
||||
command.attemptId,
|
||||
result.snapshot,
|
||||
);
|
||||
return Object.freeze({
|
||||
...current,
|
||||
callbackSequence: current.attempt.callbackSequence + 1,
|
||||
callbackTokenHash,
|
||||
});
|
||||
}
|
||||
return this.repository.transaction(async (transaction) => {
|
||||
const [run, attempt, latest] = await Promise.all([
|
||||
transaction.findRunById(command.runId),
|
||||
transaction.findAttemptById(command.attemptId),
|
||||
transaction.findLatestAttemptByRunId(command.runId),
|
||||
]);
|
||||
assertClaimedAggregate(run, attempt, latest);
|
||||
if (!attempt) throw new LocalExecutionRejectedError('attempt_not_found');
|
||||
const atMs = atOrAfter(this.clock.now(), run, attempt);
|
||||
const deadlineAtMs =
|
||||
command.timeoutMs === undefined ? undefined : atMs + command.timeoutMs;
|
||||
if (
|
||||
deadlineAtMs !== undefined &&
|
||||
(!Number.isSafeInteger(deadlineAtMs) || deadlineAtMs <= atMs)
|
||||
) {
|
||||
throw new RangeError('Local execution deadline overflowed');
|
||||
}
|
||||
|
||||
const dispatching = reserveEvent(run);
|
||||
const nextDispatching: RunRecord = {
|
||||
...dispatching.run,
|
||||
status: 'dispatching',
|
||||
};
|
||||
if (!(await transaction.compareAndSetRun(nextDispatching, run.version))) {
|
||||
throw new LocalExecutionConcurrentWriteError();
|
||||
}
|
||||
await transaction.appendEvent(
|
||||
event(
|
||||
this.createEventId(),
|
||||
run.id,
|
||||
attempt.id,
|
||||
{
|
||||
sequence: dispatching.sequence,
|
||||
type: 'run.dispatching',
|
||||
payload: Object.freeze({
|
||||
from_status: run.status,
|
||||
to_status: 'dispatching',
|
||||
version: nextDispatching.version,
|
||||
}),
|
||||
},
|
||||
`local-execution:run:${run.id}:${run.version}:dispatching`,
|
||||
'scheduler',
|
||||
'local-execution',
|
||||
atMs,
|
||||
),
|
||||
);
|
||||
|
||||
const starting = reserveEvent(nextDispatching);
|
||||
const nextStartingRun = starting.run;
|
||||
const nextAttempt: RunAttemptRecord = {
|
||||
...attempt,
|
||||
status: 'starting',
|
||||
callbackTokenHash,
|
||||
...(deadlineAtMs === undefined ? {} : { deadlineAtMs }),
|
||||
...(command.output === undefined
|
||||
? {}
|
||||
: { logArtifactId: command.output.logArtifactId }),
|
||||
};
|
||||
if (
|
||||
!(await transaction.compareAndSetRun(
|
||||
nextStartingRun,
|
||||
nextDispatching.version,
|
||||
)) ||
|
||||
!(await transaction.compareAndSetAttempt(nextAttempt, {
|
||||
status: attempt.status,
|
||||
callbackSequence: attempt.callbackSequence,
|
||||
}))
|
||||
) {
|
||||
throw new LocalExecutionConcurrentWriteError();
|
||||
}
|
||||
await transaction.appendEvent(
|
||||
event(
|
||||
this.createEventId(),
|
||||
run.id,
|
||||
attempt.id,
|
||||
{
|
||||
sequence: starting.sequence,
|
||||
type: 'attempt.starting',
|
||||
payload: Object.freeze({
|
||||
attempt_id: attempt.id,
|
||||
from_status: attempt.status,
|
||||
to_status: 'starting',
|
||||
version: nextStartingRun.version,
|
||||
...(deadlineAtMs === undefined
|
||||
? {}
|
||||
: { deadline_at_ms: deadlineAtMs }),
|
||||
}),
|
||||
},
|
||||
`local-execution:attempt:${attempt.id}:${attempt.callbackSequence}:starting`,
|
||||
'worker',
|
||||
LOCAL_PROCESS_EXECUTOR_TYPE,
|
||||
atMs,
|
||||
),
|
||||
);
|
||||
const callbackSequence = attempt.callbackSequence + 1;
|
||||
if (!Number.isSafeInteger(callbackSequence)) {
|
||||
throw new TypeError('Local execution callback sequence overflowed');
|
||||
}
|
||||
return Object.freeze({
|
||||
run: nextStartingRun,
|
||||
attempt: nextAttempt,
|
||||
callbackSequence,
|
||||
callbackTokenHash,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async recordRunning(
|
||||
expected: PreparedAggregate,
|
||||
handle: LocalProcessLaunchHandle,
|
||||
): Promise<AggregateSnapshot> {
|
||||
if (expected.attempt.stepRunId !== undefined) {
|
||||
if (!this.workflowTasks) {
|
||||
throw new LocalExecutionConcurrentWriteError();
|
||||
}
|
||||
const atMs = atOrAfter(
|
||||
handle.startedAtMs,
|
||||
expected.run,
|
||||
expected.attempt,
|
||||
);
|
||||
const result = await this.workflowTasks.recordRunning({
|
||||
run: expected.run,
|
||||
attempt: expected.attempt,
|
||||
callbackTokenHash: expected.callbackTokenHash,
|
||||
executorHandle: handle.durableHandle,
|
||||
pid: handle.pid,
|
||||
startedAtMs: atMs,
|
||||
attemptEventId: this.createEventId(),
|
||||
stepMutationId: this.createEventId(),
|
||||
});
|
||||
if (result.status === 'rejected') {
|
||||
throw new LocalExecutionConcurrentWriteError();
|
||||
}
|
||||
return this.loadWorkflowTaskSnapshot(
|
||||
expected.run.id,
|
||||
expected.attempt.id,
|
||||
result.snapshot,
|
||||
);
|
||||
}
|
||||
return this.repository.transaction(async (transaction) => {
|
||||
const current = await loadExact(
|
||||
transaction,
|
||||
expected.run.id,
|
||||
expected.attempt.id,
|
||||
);
|
||||
assertPreparedAggregate(current, expected);
|
||||
const atMs = atOrAfter(
|
||||
handle.startedAtMs,
|
||||
current.run,
|
||||
current.attempt,
|
||||
);
|
||||
const attemptRunning = reserveEvent(current.run);
|
||||
const nextAttempt: RunAttemptRecord = {
|
||||
...current.attempt,
|
||||
status: 'running',
|
||||
executorHandle: handle.durableHandle,
|
||||
pid: handle.pid,
|
||||
startedAtMs: atMs,
|
||||
};
|
||||
if (
|
||||
!(await transaction.compareAndSetRun(
|
||||
attemptRunning.run,
|
||||
current.run.version,
|
||||
)) ||
|
||||
!(await transaction.compareAndSetAttempt(nextAttempt, {
|
||||
status: current.attempt.status,
|
||||
callbackSequence: current.attempt.callbackSequence,
|
||||
}))
|
||||
) {
|
||||
throw new LocalExecutionConcurrentWriteError();
|
||||
}
|
||||
await transaction.appendEvent(
|
||||
event(
|
||||
this.createEventId(),
|
||||
current.run.id,
|
||||
current.attempt.id,
|
||||
{
|
||||
sequence: attemptRunning.sequence,
|
||||
type: 'attempt.running',
|
||||
payload: Object.freeze({
|
||||
attempt_id: current.attempt.id,
|
||||
from_status: current.attempt.status,
|
||||
to_status: 'running',
|
||||
pid: handle.pid,
|
||||
version: attemptRunning.run.version,
|
||||
}),
|
||||
},
|
||||
`local-execution:attempt:${current.attempt.id}:${current.attempt.callbackSequence}:running`,
|
||||
'executor',
|
||||
LOCAL_PROCESS_EXECUTOR_TYPE,
|
||||
atMs,
|
||||
),
|
||||
);
|
||||
|
||||
const runRunning = reserveEvent(attemptRunning.run);
|
||||
const nextRun: RunRecord = {
|
||||
...runRunning.run,
|
||||
status: 'running',
|
||||
startedAtMs: atMs,
|
||||
};
|
||||
if (
|
||||
!(await transaction.compareAndSetRun(
|
||||
nextRun,
|
||||
attemptRunning.run.version,
|
||||
))
|
||||
) {
|
||||
throw new LocalExecutionConcurrentWriteError();
|
||||
}
|
||||
await transaction.appendEvent(
|
||||
event(
|
||||
this.createEventId(),
|
||||
current.run.id,
|
||||
current.attempt.id,
|
||||
{
|
||||
sequence: runRunning.sequence,
|
||||
type: 'run.running',
|
||||
payload: Object.freeze({
|
||||
from_status: current.run.status,
|
||||
to_status: 'running',
|
||||
version: nextRun.version,
|
||||
}),
|
||||
},
|
||||
`local-execution:run:${current.run.id}:${current.run.version}:running`,
|
||||
'executor',
|
||||
LOCAL_PROCESS_EXECUTOR_TYPE,
|
||||
atMs,
|
||||
),
|
||||
);
|
||||
return Object.freeze({ run: nextRun, attempt: nextAttempt });
|
||||
});
|
||||
}
|
||||
|
||||
private async recordTerminal(
|
||||
expected: PreparedAggregate,
|
||||
status: 'failed' | 'lost',
|
||||
errorCode: string,
|
||||
errorSummary: string,
|
||||
): Promise<AggregateSnapshot> {
|
||||
if (expected.attempt.stepRunId !== undefined) {
|
||||
if (!this.workflowTasks) {
|
||||
throw new LocalExecutionConcurrentWriteError();
|
||||
}
|
||||
const atMs = atOrAfter(
|
||||
this.clock.now(),
|
||||
expected.run,
|
||||
expected.attempt,
|
||||
);
|
||||
const result = await this.workflowTasks.recordStartFailure({
|
||||
run: expected.run,
|
||||
attempt: expected.attempt,
|
||||
callbackTokenHash: expected.callbackTokenHash,
|
||||
status: 'failed',
|
||||
errorCode,
|
||||
errorSummary,
|
||||
finishedAtMs: atMs,
|
||||
attemptEventId: this.createEventId(),
|
||||
stepMutationId: this.createEventId(),
|
||||
});
|
||||
if (result.status === 'rejected') {
|
||||
throw new LocalExecutionConcurrentWriteError();
|
||||
}
|
||||
return this.loadWorkflowTaskSnapshot(
|
||||
expected.run.id,
|
||||
expected.attempt.id,
|
||||
result.snapshot,
|
||||
);
|
||||
}
|
||||
return this.repository.transaction(async (transaction) => {
|
||||
const current = await loadExact(
|
||||
transaction,
|
||||
expected.run.id,
|
||||
expected.attempt.id,
|
||||
);
|
||||
assertPreparedAggregate(current, expected);
|
||||
const atMs = atOrAfter(this.clock.now(), current.run, current.attempt);
|
||||
const attemptTerminal = reserveEvent(current.run);
|
||||
const nextAttempt: RunAttemptRecord = {
|
||||
...current.attempt,
|
||||
status,
|
||||
finishedAtMs: atMs,
|
||||
errorCode,
|
||||
errorSummary,
|
||||
};
|
||||
if (
|
||||
!(await transaction.compareAndSetRun(
|
||||
attemptTerminal.run,
|
||||
current.run.version,
|
||||
)) ||
|
||||
!(await transaction.compareAndSetAttempt(nextAttempt, {
|
||||
status: current.attempt.status,
|
||||
callbackSequence: current.attempt.callbackSequence,
|
||||
}))
|
||||
) {
|
||||
throw new LocalExecutionConcurrentWriteError();
|
||||
}
|
||||
await transaction.appendEvent(
|
||||
event(
|
||||
this.createEventId(),
|
||||
current.run.id,
|
||||
current.attempt.id,
|
||||
{
|
||||
sequence: attemptTerminal.sequence,
|
||||
type: `attempt.${status}`,
|
||||
payload: Object.freeze({
|
||||
attempt_id: current.attempt.id,
|
||||
from_status: current.attempt.status,
|
||||
to_status: status,
|
||||
error_code: errorCode,
|
||||
version: attemptTerminal.run.version,
|
||||
}),
|
||||
},
|
||||
`local-execution:attempt:${current.attempt.id}:${current.attempt.callbackSequence}:${status}`,
|
||||
status === 'lost' ? 'reconciler' : 'executor',
|
||||
status === 'lost' ? 'local-execution' : LOCAL_PROCESS_EXECUTOR_TYPE,
|
||||
atMs,
|
||||
),
|
||||
);
|
||||
|
||||
const runTerminal = reserveEvent(attemptTerminal.run);
|
||||
const nextRun: RunRecord = {
|
||||
...runTerminal.run,
|
||||
status,
|
||||
...(status === 'failed' ? { finishedAtMs: atMs } : {}),
|
||||
errorCode,
|
||||
errorSummary,
|
||||
};
|
||||
if (
|
||||
!(await transaction.compareAndSetRun(
|
||||
nextRun,
|
||||
attemptTerminal.run.version,
|
||||
))
|
||||
) {
|
||||
throw new LocalExecutionConcurrentWriteError();
|
||||
}
|
||||
await transaction.appendEvent(
|
||||
event(
|
||||
this.createEventId(),
|
||||
current.run.id,
|
||||
current.attempt.id,
|
||||
{
|
||||
sequence: runTerminal.sequence,
|
||||
type: `run.${status}`,
|
||||
payload: Object.freeze({
|
||||
from_status: current.run.status,
|
||||
to_status: status,
|
||||
error_code: errorCode,
|
||||
version: nextRun.version,
|
||||
}),
|
||||
},
|
||||
`local-execution:run:${current.run.id}:${current.run.version}:${status}`,
|
||||
status === 'lost' ? 'reconciler' : 'executor',
|
||||
status === 'lost' ? 'local-execution' : LOCAL_PROCESS_EXECUTOR_TYPE,
|
||||
atMs,
|
||||
),
|
||||
);
|
||||
return Object.freeze({ run: nextRun, attempt: nextAttempt });
|
||||
});
|
||||
}
|
||||
|
||||
private assertHandle(
|
||||
handle: LocalProcessLaunchHandle,
|
||||
expected: PreparedAggregate,
|
||||
): void {
|
||||
const parsed =
|
||||
handle &&
|
||||
typeof handle === 'object' &&
|
||||
typeof handle.durableHandle === 'string'
|
||||
? parseLocalProcessDurableHandle(handle.durableHandle)
|
||||
: null;
|
||||
if (
|
||||
!handle ||
|
||||
typeof handle !== 'object' ||
|
||||
!Number.isSafeInteger(handle.pid) ||
|
||||
handle.pid < 1 ||
|
||||
!Number.isSafeInteger(handle.startedAtMs) ||
|
||||
handle.startedAtMs < 0 ||
|
||||
typeof handle.handleId !== 'string' ||
|
||||
handle.handleId.length < 1 ||
|
||||
typeof handle.durableHandle !== 'string' ||
|
||||
handle.durableHandle.length < 1 ||
|
||||
typeof handle.completion?.then !== 'function' ||
|
||||
!parsed ||
|
||||
parsed.handleId !== handle.handleId ||
|
||||
parsed.identity.pid !== handle.pid ||
|
||||
expected.run.id !== expected.attempt.runId
|
||||
) {
|
||||
throw new TypeError('Local process launcher returned an invalid handle');
|
||||
}
|
||||
}
|
||||
|
||||
private async loadLatest(
|
||||
fallback: AggregateSnapshot,
|
||||
): Promise<AggregateSnapshot> {
|
||||
const [run, attempt] = await Promise.all([
|
||||
this.repository.findRunById(fallback.run.id),
|
||||
this.repository.findAttemptById(fallback.attempt.id),
|
||||
]);
|
||||
return Object.freeze({
|
||||
run: run ?? fallback.run,
|
||||
attempt: attempt ?? fallback.attempt,
|
||||
});
|
||||
}
|
||||
|
||||
private async loadWorkflowTaskSnapshot(
|
||||
runId: string,
|
||||
attemptId: string,
|
||||
expected: Readonly<{
|
||||
runVersion: number;
|
||||
runEventSequence: number;
|
||||
attemptStatus: RunAttemptRecord['status'];
|
||||
callbackSequence: number;
|
||||
}>,
|
||||
): Promise<AggregateSnapshot> {
|
||||
const [run, attempt] = await Promise.all([
|
||||
this.repository.findRunById(runId),
|
||||
this.repository.findAttemptById(attemptId),
|
||||
]);
|
||||
if (
|
||||
!run ||
|
||||
!attempt ||
|
||||
attempt.runId !== run.id ||
|
||||
attempt.stepRunId === undefined ||
|
||||
run.version !== expected.runVersion ||
|
||||
run.eventSequence !== expected.runEventSequence ||
|
||||
attempt.status !== expected.attemptStatus ||
|
||||
attempt.callbackSequence !== expected.callbackSequence
|
||||
) {
|
||||
throw new LocalExecutionConcurrentWriteError();
|
||||
}
|
||||
return Object.freeze({ run, attempt });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './coordinator';
|
||||
export * from './workflowTaskExecution';
|
||||
@@ -0,0 +1,98 @@
|
||||
import type {
|
||||
RunAttemptRecord,
|
||||
RunRecord,
|
||||
} from '@qinglong/runtime-core/run-repository';
|
||||
|
||||
export type LocalWorkflowTaskTerminalStatus =
|
||||
| 'succeeded'
|
||||
| 'failed'
|
||||
| 'cancelled'
|
||||
| 'timed_out';
|
||||
|
||||
export interface LocalWorkflowTaskExecutionSnapshot {
|
||||
readonly runVersion: number;
|
||||
readonly runEventSequence: number;
|
||||
readonly attemptStatus: RunAttemptRecord['status'];
|
||||
readonly callbackSequence: number;
|
||||
}
|
||||
|
||||
export type LocalWorkflowTaskExecutionMutationResult =
|
||||
| Readonly<{
|
||||
status: 'applied';
|
||||
snapshot: Readonly<LocalWorkflowTaskExecutionSnapshot>;
|
||||
}>
|
||||
| Readonly<{
|
||||
status: 'rejected';
|
||||
reason:
|
||||
| 'aggregate_mismatch'
|
||||
| 'run_not_running'
|
||||
| 'attempt_not_claimed'
|
||||
| 'attempt_not_starting'
|
||||
| 'cancellation_requested'
|
||||
| 'stale_execution_authority';
|
||||
}>;
|
||||
|
||||
export interface LocalWorkflowTaskExecutionRepository {
|
||||
prepare(command: Readonly<{
|
||||
runId: string;
|
||||
attemptId: string;
|
||||
stepRunId: string;
|
||||
callbackTokenHash: string;
|
||||
deadlineAtMs?: number;
|
||||
logArtifactId?: string;
|
||||
atMs: number;
|
||||
eventId: string;
|
||||
}>): Promise<Readonly<LocalWorkflowTaskExecutionMutationResult>>;
|
||||
recordRunning(command: Readonly<{
|
||||
run: Readonly<RunRecord>;
|
||||
attempt: Readonly<RunAttemptRecord>;
|
||||
callbackTokenHash: string;
|
||||
executorHandle: string;
|
||||
pid: number;
|
||||
startedAtMs: number;
|
||||
attemptEventId: string;
|
||||
stepMutationId: string;
|
||||
}>): Promise<Readonly<LocalWorkflowTaskExecutionMutationResult>>;
|
||||
recordStartFailure(command: Readonly<{
|
||||
run: Readonly<RunRecord>;
|
||||
attempt: Readonly<RunAttemptRecord>;
|
||||
callbackTokenHash: string;
|
||||
status: 'failed';
|
||||
errorCode: string;
|
||||
errorSummary: string;
|
||||
finishedAtMs: number;
|
||||
attemptEventId: string;
|
||||
stepMutationId: string;
|
||||
}>): Promise<Readonly<LocalWorkflowTaskExecutionMutationResult>>;
|
||||
complete(command: Readonly<{
|
||||
run: Readonly<RunRecord>;
|
||||
attempt: Readonly<RunAttemptRecord>;
|
||||
callbackSequence: number;
|
||||
startedAtMs: number;
|
||||
finishedAtMs: number;
|
||||
exitCode: number;
|
||||
terminalStatus: LocalWorkflowTaskTerminalStatus;
|
||||
errorCode?: string;
|
||||
errorSummary?: string;
|
||||
attemptEventId: string;
|
||||
syntheticStartMutationId: string;
|
||||
terminalStepMutationId: string;
|
||||
}>): Promise<'completed' | 'already_terminal' | 'stale'>;
|
||||
requestTimeout(command: Readonly<{
|
||||
run: Readonly<RunRecord>;
|
||||
attempt: Readonly<RunAttemptRecord>;
|
||||
dueAtMs: number;
|
||||
eventId: string;
|
||||
}>): Promise<'requested' | 'existing' | 'stale'>;
|
||||
recordControlTerminal(command: Readonly<{
|
||||
run: Readonly<RunRecord>;
|
||||
attempt: Readonly<RunAttemptRecord>;
|
||||
reason: 'user' | 'policy' | 'shutdown' | 'reconcile' | 'timeout';
|
||||
terminalStatus: 'cancelled' | 'timed_out';
|
||||
errorCode: string;
|
||||
errorSummary: string;
|
||||
finishedAtMs: number;
|
||||
attemptEventId: string;
|
||||
stepMutationId: string;
|
||||
}>): Promise<'terminal' | 'already_terminal' | 'stale'>;
|
||||
}
|
||||
@@ -0,0 +1,848 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import type {
|
||||
LocalRunStartupRecoveryCandidate,
|
||||
LocalRunStartupRecoveryPage,
|
||||
LocalRunStartupRecoverySource,
|
||||
} from '@qinglong/runtime-core/local-startup-recovery';
|
||||
import type {
|
||||
RunAttemptRecord,
|
||||
RunEventRecord,
|
||||
RunRecord,
|
||||
RunRepository,
|
||||
RunRepositoryTransaction,
|
||||
} from '@qinglong/runtime-core/run-repository';
|
||||
import type { LocalCompletionReceiptJournal } from '@qinglong/runtime-core/local-completion-receipt-journal';
|
||||
import { LocalCompletionReceiptProcessor } from '../control/completion';
|
||||
import {
|
||||
type CompletionReceiptStore,
|
||||
type LocalPersistedExecutionInspection,
|
||||
type LocalPersistedExecutionInspector,
|
||||
} from '@qinglong/local-process';
|
||||
|
||||
export const MAX_LOCAL_RUN_RECOVERY_ITEMS = 256;
|
||||
export const MAX_LOCAL_RUN_RECEIPT_GRACE_MS = 5_000;
|
||||
export const MAX_LOCAL_RUN_QUARANTINE_RETENTION_MS = 24 * 60 * 60_000;
|
||||
|
||||
const ACTIVE_ATTEMPT_STATUSES = new Set(['claimed', 'starting', 'running']);
|
||||
const PAGE_KEYS = Object.freeze(['candidates', 'truncated']);
|
||||
const CANDIDATE_KEYS = Object.freeze([
|
||||
'activeAttemptCount',
|
||||
'runId',
|
||||
'runStatus',
|
||||
]);
|
||||
|
||||
export interface LocalRunStartupRecoverySummary {
|
||||
readonly safe: boolean;
|
||||
readonly scanned: number;
|
||||
readonly recovered: number;
|
||||
readonly remaining: number;
|
||||
readonly failed: number;
|
||||
readonly truncated: boolean;
|
||||
}
|
||||
|
||||
export interface LocalRunStartupRecoveryCoordinatorOptions {
|
||||
readonly receiptPublishGraceMs?: number;
|
||||
readonly clock?: { now(): number };
|
||||
readonly wait?: (delayMs: number) => Promise<void>;
|
||||
readonly createEventId?: () => string;
|
||||
readonly journal?: Pick<
|
||||
LocalCompletionReceiptJournal,
|
||||
'markQuarantined' | 'resolve'
|
||||
>;
|
||||
readonly quarantineRetentionMs?: number;
|
||||
readonly completionProcessor?: Pick<
|
||||
LocalCompletionReceiptProcessor,
|
||||
'process'
|
||||
>;
|
||||
readonly onDiagnostic?: (
|
||||
record: Readonly<{
|
||||
kind:
|
||||
| 'receipt_cleanup_failed'
|
||||
| 'receipt_quarantined'
|
||||
| 'journal_cleanup_failed';
|
||||
runId: string;
|
||||
attemptId: string;
|
||||
}>,
|
||||
) => void | Promise<void>;
|
||||
}
|
||||
|
||||
type CandidateDisposition =
|
||||
| Readonly<{ status: 'recovered' }>
|
||||
| Readonly<{ status: 'verified'; fingerprint: VerifiedFingerprint }>
|
||||
| Readonly<{ status: 'remaining' }>
|
||||
| Readonly<{ status: 'failed' }>;
|
||||
|
||||
interface AggregateSnapshot {
|
||||
readonly run: RunRecord;
|
||||
readonly attempt: RunAttemptRecord | null;
|
||||
}
|
||||
|
||||
interface VerifiedFingerprint {
|
||||
readonly runId: string;
|
||||
readonly runStatus: 'running';
|
||||
readonly runVersion: number;
|
||||
readonly attemptId: string;
|
||||
readonly attemptStatus: 'running';
|
||||
readonly callbackSequence: number;
|
||||
readonly executorHandle: string;
|
||||
readonly pid: number;
|
||||
}
|
||||
|
||||
interface EventDraft {
|
||||
readonly sequence: number;
|
||||
readonly type: string;
|
||||
readonly payload: Readonly<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
class LocalRunRecoveryConcurrentWriteError extends Error {}
|
||||
|
||||
function exactKeys(
|
||||
value: Readonly<Record<string, unknown>>,
|
||||
expected: readonly string[],
|
||||
): boolean {
|
||||
return (
|
||||
JSON.stringify(Object.keys(value).sort()) ===
|
||||
JSON.stringify([...expected].sort())
|
||||
);
|
||||
}
|
||||
|
||||
function assertSafeTimestamp(value: number, name: string): void {
|
||||
if (!Number.isSafeInteger(value) || value < 0) {
|
||||
throw new TypeError(`${name} must be a non-negative safe integer`);
|
||||
}
|
||||
}
|
||||
|
||||
function validatePage(value: unknown): LocalRunStartupRecoveryPage {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new TypeError('Local Run recovery page is invalid');
|
||||
}
|
||||
const page = value as unknown as Record<string, unknown>;
|
||||
if (
|
||||
!exactKeys(page, PAGE_KEYS) ||
|
||||
!Array.isArray(page.candidates) ||
|
||||
page.candidates.length > MAX_LOCAL_RUN_RECOVERY_ITEMS ||
|
||||
typeof page.truncated !== 'boolean'
|
||||
) {
|
||||
throw new TypeError('Local Run recovery page shape or bounds are invalid');
|
||||
}
|
||||
const candidates: LocalRunStartupRecoveryCandidate[] = [];
|
||||
const runIds = new Set<string>();
|
||||
for (const value of page.candidates) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new TypeError('Local Run recovery candidate is invalid');
|
||||
}
|
||||
const candidate = value as Record<string, unknown>;
|
||||
if (
|
||||
!exactKeys(candidate, CANDIDATE_KEYS) ||
|
||||
typeof candidate.runId !== 'string' ||
|
||||
candidate.runId.length < 1 ||
|
||||
candidate.runId.length > 128 ||
|
||||
/[\0\r\n]/.test(candidate.runId) ||
|
||||
(candidate.runStatus !== 'dispatching' &&
|
||||
candidate.runStatus !== 'running') ||
|
||||
!Number.isSafeInteger(candidate.activeAttemptCount) ||
|
||||
(candidate.activeAttemptCount as number) < 0 ||
|
||||
(candidate.activeAttemptCount as number) > MAX_LOCAL_RUN_RECOVERY_ITEMS ||
|
||||
runIds.has(candidate.runId)
|
||||
) {
|
||||
throw new TypeError('Local Run recovery candidate fields are invalid');
|
||||
}
|
||||
runIds.add(candidate.runId);
|
||||
candidates.push(
|
||||
Object.freeze({
|
||||
runId: candidate.runId,
|
||||
runStatus: candidate.runStatus,
|
||||
activeAttemptCount: candidate.activeAttemptCount as number,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
candidates: Object.freeze(candidates),
|
||||
truncated: page.truncated,
|
||||
});
|
||||
}
|
||||
|
||||
function reserveEvent(run: RunRecord): Readonly<{
|
||||
run: RunRecord;
|
||||
sequence: number;
|
||||
}> {
|
||||
const version = run.version + 1;
|
||||
const sequence = run.eventSequence + 1;
|
||||
if (
|
||||
!Number.isSafeInteger(version) ||
|
||||
version < 1 ||
|
||||
!Number.isSafeInteger(sequence) ||
|
||||
sequence < 1
|
||||
) {
|
||||
throw new TypeError('Local Run recovery version or sequence overflowed');
|
||||
}
|
||||
return Object.freeze({
|
||||
run: { ...run, version, eventSequence: sequence },
|
||||
sequence,
|
||||
});
|
||||
}
|
||||
|
||||
function event(
|
||||
id: string,
|
||||
runId: string,
|
||||
attemptId: string | undefined,
|
||||
draft: EventDraft,
|
||||
dedupeKey: string,
|
||||
actorType: RunEventRecord['actorType'],
|
||||
actorId: string,
|
||||
atMs: number,
|
||||
): RunEventRecord {
|
||||
return {
|
||||
id,
|
||||
runId,
|
||||
sequence: draft.sequence,
|
||||
type: draft.type,
|
||||
dedupeKey,
|
||||
actorType,
|
||||
actorId,
|
||||
...(attemptId === undefined ? {} : { attemptId }),
|
||||
payload: draft.payload,
|
||||
createdAtMs: atMs,
|
||||
};
|
||||
}
|
||||
|
||||
function atOrAfter(
|
||||
now: number,
|
||||
run: RunRecord,
|
||||
attempt?: RunAttemptRecord,
|
||||
): number {
|
||||
assertSafeTimestamp(now, 'Local Run recovery observation');
|
||||
return Math.max(
|
||||
now,
|
||||
run.createdAtMs,
|
||||
run.startedAtMs ?? 0,
|
||||
attempt?.createdAtMs ?? 0,
|
||||
attempt?.startedAtMs ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
function fingerprint(snapshot: AggregateSnapshot): VerifiedFingerprint {
|
||||
const { run, attempt } = snapshot;
|
||||
if (
|
||||
run.status !== 'running' ||
|
||||
!attempt ||
|
||||
attempt.status !== 'running' ||
|
||||
attempt.executorType !== 'local_process' ||
|
||||
!attempt.executorHandle ||
|
||||
!Number.isSafeInteger(attempt.pid) ||
|
||||
(attempt.pid as number) < 1
|
||||
) {
|
||||
throw new TypeError('Verified local Run snapshot is not running');
|
||||
}
|
||||
return Object.freeze({
|
||||
runId: run.id,
|
||||
runStatus: 'running',
|
||||
runVersion: run.version,
|
||||
attemptId: attempt.id,
|
||||
attemptStatus: 'running',
|
||||
callbackSequence: attempt.callbackSequence,
|
||||
executorHandle: attempt.executorHandle,
|
||||
pid: attempt.pid as number,
|
||||
});
|
||||
}
|
||||
|
||||
function sameFingerprint(
|
||||
expected: VerifiedFingerprint,
|
||||
snapshot: AggregateSnapshot,
|
||||
): boolean {
|
||||
const attempt = snapshot.attempt;
|
||||
return (
|
||||
snapshot.run.id === expected.runId &&
|
||||
snapshot.run.status === expected.runStatus &&
|
||||
snapshot.run.version === expected.runVersion &&
|
||||
attempt?.id === expected.attemptId &&
|
||||
attempt.status === expected.attemptStatus &&
|
||||
attempt.callbackSequence === expected.callbackSequence &&
|
||||
attempt.executorHandle === expected.executorHandle &&
|
||||
attempt.pid === expected.pid
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One bounded startup coordinator. It never scans receipt directories, starts
|
||||
* an Executor, or treats missing/invalid evidence as proof of process exit.
|
||||
*/
|
||||
export class LocalRunStartupRecoveryCoordinator {
|
||||
private readonly clock: { now(): number };
|
||||
private readonly wait: (delayMs: number) => Promise<void>;
|
||||
private readonly receiptPublishGraceMs: number;
|
||||
private readonly createEventId: () => string;
|
||||
private readonly journal?: LocalRunStartupRecoveryCoordinatorOptions['journal'];
|
||||
private readonly quarantineRetentionMs: number;
|
||||
private readonly onDiagnostic?: LocalRunStartupRecoveryCoordinatorOptions['onDiagnostic'];
|
||||
private readonly completionProcessor: Pick<
|
||||
LocalCompletionReceiptProcessor,
|
||||
'process'
|
||||
>;
|
||||
|
||||
constructor(
|
||||
private readonly repository: RunRepository,
|
||||
private readonly source: LocalRunStartupRecoverySource,
|
||||
receipts: CompletionReceiptStore,
|
||||
private readonly inspector: LocalPersistedExecutionInspector,
|
||||
options: LocalRunStartupRecoveryCoordinatorOptions = {},
|
||||
) {
|
||||
this.clock = options.clock ?? { now: Date.now };
|
||||
this.wait =
|
||||
options.wait ??
|
||||
((delayMs) =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(resolve, delayMs);
|
||||
}));
|
||||
this.receiptPublishGraceMs = options.receiptPublishGraceMs ?? 0;
|
||||
if (
|
||||
!Number.isSafeInteger(this.receiptPublishGraceMs) ||
|
||||
this.receiptPublishGraceMs < 0 ||
|
||||
this.receiptPublishGraceMs > MAX_LOCAL_RUN_RECEIPT_GRACE_MS
|
||||
) {
|
||||
throw new RangeError(
|
||||
`receiptPublishGraceMs must be between 0 and ${MAX_LOCAL_RUN_RECEIPT_GRACE_MS}`,
|
||||
);
|
||||
}
|
||||
if (inspector.executorType !== 'local_process') {
|
||||
throw new TypeError('Local Run recovery inspector type is invalid');
|
||||
}
|
||||
this.createEventId = options.createEventId ?? randomUUID;
|
||||
this.journal = options.journal;
|
||||
this.quarantineRetentionMs = options.quarantineRetentionMs ?? 60 * 60_000;
|
||||
if (
|
||||
!Number.isSafeInteger(this.quarantineRetentionMs) ||
|
||||
this.quarantineRetentionMs < 0 ||
|
||||
this.quarantineRetentionMs > MAX_LOCAL_RUN_QUARANTINE_RETENTION_MS
|
||||
) {
|
||||
throw new RangeError(
|
||||
`quarantineRetentionMs must be between 0 and ${MAX_LOCAL_RUN_QUARANTINE_RETENTION_MS}`,
|
||||
);
|
||||
}
|
||||
this.onDiagnostic = options.onDiagnostic;
|
||||
this.completionProcessor =
|
||||
options.completionProcessor ??
|
||||
new LocalCompletionReceiptProcessor(repository, receipts, {
|
||||
clock: this.clock,
|
||||
createEventId: this.createEventId,
|
||||
...(this.journal === undefined ? {} : { journal: this.journal }),
|
||||
quarantineRetentionMs: this.quarantineRetentionMs,
|
||||
...(this.onDiagnostic === undefined
|
||||
? {}
|
||||
: { onDiagnostic: this.onDiagnostic }),
|
||||
});
|
||||
}
|
||||
|
||||
async recover(): Promise<LocalRunStartupRecoverySummary> {
|
||||
const initial = validatePage(
|
||||
await this.source.inspectCandidates({
|
||||
limit: MAX_LOCAL_RUN_RECOVERY_ITEMS,
|
||||
}),
|
||||
);
|
||||
if (initial.truncated) {
|
||||
return Object.freeze({
|
||||
safe: false,
|
||||
scanned: initial.candidates.length,
|
||||
recovered: 0,
|
||||
remaining: initial.candidates.length,
|
||||
failed: 0,
|
||||
truncated: true,
|
||||
});
|
||||
}
|
||||
if (initial.candidates.length === 0) {
|
||||
return Object.freeze({
|
||||
safe: true,
|
||||
scanned: 0,
|
||||
recovered: 0,
|
||||
remaining: 0,
|
||||
failed: 0,
|
||||
truncated: false,
|
||||
});
|
||||
}
|
||||
|
||||
let recovered = 0;
|
||||
let remaining = 0;
|
||||
let failed = 0;
|
||||
const verified = new Map<string, VerifiedFingerprint>();
|
||||
for (const candidate of initial.candidates) {
|
||||
const disposition = await this.reconcile(candidate);
|
||||
if (disposition.status === 'recovered') recovered += 1;
|
||||
if (disposition.status === 'verified') {
|
||||
recovered += 1;
|
||||
verified.set(candidate.runId, disposition.fingerprint);
|
||||
}
|
||||
if (disposition.status === 'remaining') remaining += 1;
|
||||
if (disposition.status === 'failed') failed += 1;
|
||||
}
|
||||
|
||||
if (remaining === 0 && failed === 0) {
|
||||
const final = await this.verify(verified);
|
||||
if (final.remaining > 0 || final.failed > 0) {
|
||||
const unresolved = Math.min(
|
||||
initial.candidates.length,
|
||||
Math.max(1, final.remaining + final.failed),
|
||||
);
|
||||
recovered = Math.max(0, recovered - unresolved);
|
||||
failed = Math.min(unresolved, final.failed);
|
||||
remaining = unresolved - failed;
|
||||
}
|
||||
}
|
||||
return Object.freeze({
|
||||
safe: remaining === 0 && failed === 0,
|
||||
scanned: initial.candidates.length,
|
||||
recovered,
|
||||
remaining,
|
||||
failed,
|
||||
truncated: false,
|
||||
});
|
||||
}
|
||||
|
||||
private async reconcile(
|
||||
candidate: LocalRunStartupRecoveryCandidate,
|
||||
): Promise<CandidateDisposition> {
|
||||
try {
|
||||
if (candidate.activeAttemptCount > 1) {
|
||||
return Object.freeze({ status: 'remaining' });
|
||||
}
|
||||
let snapshot = await this.load(candidate.runId);
|
||||
if (
|
||||
!snapshot ||
|
||||
snapshot.run.executionOwner !== 'runtime' ||
|
||||
snapshot.run.status !== candidate.runStatus
|
||||
) {
|
||||
return Object.freeze({ status: 'remaining' });
|
||||
}
|
||||
const attempt = snapshot.attempt;
|
||||
if (candidate.activeAttemptCount === 0) {
|
||||
if (snapshot.run.status !== 'dispatching' || attempt !== null) {
|
||||
return Object.freeze({ status: 'remaining' });
|
||||
}
|
||||
return (await this.markRunLostWithoutAttempt(snapshot.run))
|
||||
? Object.freeze({ status: 'recovered' })
|
||||
: Object.freeze({ status: 'remaining' });
|
||||
}
|
||||
if (
|
||||
!attempt ||
|
||||
!ACTIVE_ATTEMPT_STATUSES.has(attempt.status) ||
|
||||
attempt.runId !== snapshot.run.id
|
||||
) {
|
||||
return Object.freeze({ status: 'remaining' });
|
||||
}
|
||||
|
||||
const completion = await this.completionProcessor.process(attempt.id);
|
||||
if (completion === 'completed' || completion === 'already_terminal') {
|
||||
return Object.freeze({ status: 'recovered' });
|
||||
}
|
||||
if (completion === 'invalid')
|
||||
return Object.freeze({ status: 'remaining' });
|
||||
if (attempt.status === 'claimed') {
|
||||
return (await this.markAggregateLost(snapshot, 'unstarted_claim'))
|
||||
? Object.freeze({ status: 'recovered' })
|
||||
: Object.freeze({ status: 'remaining' });
|
||||
}
|
||||
if (
|
||||
attempt.executorType !== 'local_process' ||
|
||||
!attempt.executorHandle ||
|
||||
!Number.isSafeInteger(attempt.pid) ||
|
||||
(attempt.pid as number) < 1
|
||||
) {
|
||||
return Object.freeze({ status: 'remaining' });
|
||||
}
|
||||
|
||||
const inspection = await this.inspector.inspect(attempt.executorHandle);
|
||||
if (inspection.status === 'unknown') {
|
||||
return inspection.reason === 'provider_unavailable'
|
||||
? Object.freeze({ status: 'failed' })
|
||||
: Object.freeze({ status: 'remaining' });
|
||||
}
|
||||
if (inspection.identityPid !== attempt.pid) {
|
||||
return Object.freeze({ status: 'remaining' });
|
||||
}
|
||||
if (inspection.status === 'running') {
|
||||
snapshot = await this.markObservedRunning(snapshot);
|
||||
return snapshot
|
||||
? Object.freeze({
|
||||
status: 'verified',
|
||||
fingerprint: fingerprint(snapshot),
|
||||
})
|
||||
: Object.freeze({ status: 'remaining' });
|
||||
}
|
||||
|
||||
if (this.receiptPublishGraceMs > 0) {
|
||||
await this.wait(this.receiptPublishGraceMs);
|
||||
const delayed = await this.load(candidate.runId);
|
||||
if (!delayed || !delayed.attempt || delayed.attempt.id !== attempt.id) {
|
||||
return Object.freeze({ status: 'remaining' });
|
||||
}
|
||||
const lateCompletion = await this.completionProcessor.process(
|
||||
attempt.id,
|
||||
);
|
||||
if (lateCompletion === 'invalid') {
|
||||
return Object.freeze({ status: 'remaining' });
|
||||
}
|
||||
if (
|
||||
lateCompletion === 'completed' ||
|
||||
lateCompletion === 'already_terminal'
|
||||
) {
|
||||
return Object.freeze({ status: 'recovered' });
|
||||
}
|
||||
snapshot = delayed;
|
||||
}
|
||||
return (await this.markAggregateLost(snapshot, 'process_not_running'))
|
||||
? Object.freeze({ status: 'recovered' })
|
||||
: Object.freeze({ status: 'remaining' });
|
||||
} catch {
|
||||
return Object.freeze({ status: 'failed' });
|
||||
}
|
||||
}
|
||||
|
||||
private async verify(
|
||||
verified: ReadonlyMap<string, VerifiedFingerprint>,
|
||||
): Promise<Readonly<{ remaining: number; failed: number }>> {
|
||||
let remaining = 0;
|
||||
let failed = 0;
|
||||
let page: LocalRunStartupRecoveryPage;
|
||||
try {
|
||||
page = validatePage(
|
||||
await this.source.inspectCandidates({
|
||||
limit: MAX_LOCAL_RUN_RECOVERY_ITEMS,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
return Object.freeze({ remaining: 0, failed: 1 });
|
||||
}
|
||||
if (page.truncated) {
|
||||
return Object.freeze({
|
||||
remaining: page.candidates.length,
|
||||
failed: 0,
|
||||
});
|
||||
}
|
||||
for (const candidate of page.candidates) {
|
||||
const expected = verified.get(candidate.runId);
|
||||
if (!expected || candidate.activeAttemptCount !== 1) {
|
||||
remaining += 1;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const completion = await this.completionProcessor.process(
|
||||
expected.attemptId,
|
||||
);
|
||||
if (completion === 'completed' || completion === 'already_terminal') {
|
||||
continue;
|
||||
}
|
||||
if (completion === 'invalid') {
|
||||
remaining += 1;
|
||||
continue;
|
||||
}
|
||||
const snapshot = await this.load(candidate.runId);
|
||||
if (!snapshot || !sameFingerprint(expected, snapshot)) {
|
||||
remaining += 1;
|
||||
continue;
|
||||
}
|
||||
const inspection = await this.inspector.inspect(
|
||||
expected.executorHandle,
|
||||
);
|
||||
if (
|
||||
inspection.status !== 'running' ||
|
||||
inspection.identityPid !== expected.pid
|
||||
) {
|
||||
remaining += 1;
|
||||
}
|
||||
} catch {
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
return Object.freeze({ remaining, failed });
|
||||
}
|
||||
|
||||
private load(runId: string): Promise<AggregateSnapshot | null> {
|
||||
return this.repository.transaction(async (transaction) => {
|
||||
const run = await transaction.findRunById(runId);
|
||||
if (!run) return null;
|
||||
const attempt = await transaction.findLatestAttemptByRunId(runId);
|
||||
return Object.freeze({ run, attempt });
|
||||
});
|
||||
}
|
||||
|
||||
private async markRunLostWithoutAttempt(run: RunRecord): Promise<boolean> {
|
||||
return this.repository.transaction(async (transaction) => {
|
||||
const current = await transaction.findRunById(run.id);
|
||||
if (
|
||||
!current ||
|
||||
current.version !== run.version ||
|
||||
current.status !== 'dispatching' ||
|
||||
current.executionOwner !== 'runtime' ||
|
||||
(await transaction.findLatestAttemptByRunId(run.id)) !== null
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const atMs = atOrAfter(this.clock.now(), current);
|
||||
const reserved = reserveEvent(current);
|
||||
const next: RunRecord = {
|
||||
...reserved.run,
|
||||
status: 'lost',
|
||||
errorCode: 'RECOVERY_ATTEMPT_MISSING_BEFORE_START',
|
||||
errorSummary: 'Dispatching Run has no durable Attempt',
|
||||
};
|
||||
if (!(await transaction.compareAndSetRun(next, current.version))) {
|
||||
throw new LocalRunRecoveryConcurrentWriteError();
|
||||
}
|
||||
await transaction.appendEvent(
|
||||
event(
|
||||
this.createEventId(),
|
||||
current.id,
|
||||
undefined,
|
||||
{
|
||||
sequence: reserved.sequence,
|
||||
type: 'run.lost',
|
||||
payload: Object.freeze({
|
||||
from_status: current.status,
|
||||
to_status: 'lost',
|
||||
error_code: next.errorCode,
|
||||
version: next.version,
|
||||
}),
|
||||
},
|
||||
`local-recovery:run:${current.id}:${current.version}:missing-attempt`,
|
||||
'reconciler',
|
||||
'local-startup',
|
||||
atMs,
|
||||
),
|
||||
);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
private async markAggregateLost(
|
||||
expected: AggregateSnapshot,
|
||||
reason: 'unstarted_claim' | 'process_not_running',
|
||||
): Promise<boolean> {
|
||||
return this.repository.transaction(async (transaction) => {
|
||||
const current = await this.reloadExact(transaction, expected);
|
||||
if (!current || !current.attempt) return false;
|
||||
const { run, attempt } = current;
|
||||
if (
|
||||
run.cancelRequestedAtMs !== undefined ||
|
||||
!ACTIVE_ATTEMPT_STATUSES.has(attempt.status) ||
|
||||
(reason === 'unstarted_claim' && attempt.status !== 'claimed') ||
|
||||
(reason === 'process_not_running' &&
|
||||
attempt.status !== 'starting' &&
|
||||
attempt.status !== 'running')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const errorCode =
|
||||
reason === 'unstarted_claim'
|
||||
? 'RECOVERY_UNSTARTED_CLAIM'
|
||||
: 'RECOVERY_PROCESS_NOT_RUNNING';
|
||||
const errorSummary =
|
||||
reason === 'unstarted_claim'
|
||||
? 'Claimed Attempt never crossed the start barrier'
|
||||
: 'Durable process identity proves the execution is not running';
|
||||
const atMs = atOrAfter(this.clock.now(), run, attempt);
|
||||
const attemptReserved = reserveEvent(run);
|
||||
const nextAttempt: RunAttemptRecord = {
|
||||
...attempt,
|
||||
status: 'lost',
|
||||
finishedAtMs: atMs,
|
||||
errorCode,
|
||||
errorSummary,
|
||||
};
|
||||
await this.persistRunAndAttempt(
|
||||
transaction,
|
||||
run,
|
||||
attempt,
|
||||
attemptReserved.run,
|
||||
nextAttempt,
|
||||
);
|
||||
await transaction.appendEvent(
|
||||
event(
|
||||
this.createEventId(),
|
||||
run.id,
|
||||
attempt.id,
|
||||
{
|
||||
sequence: attemptReserved.sequence,
|
||||
type: 'attempt.lost',
|
||||
payload: Object.freeze({
|
||||
attempt_id: attempt.id,
|
||||
from_status: attempt.status,
|
||||
to_status: 'lost',
|
||||
error_code: errorCode,
|
||||
version: attemptReserved.run.version,
|
||||
}),
|
||||
},
|
||||
`local-recovery:attempt:${attempt.id}:${attempt.callbackSequence}:${reason}`,
|
||||
'reconciler',
|
||||
'local-startup',
|
||||
atMs,
|
||||
),
|
||||
);
|
||||
const runReserved = reserveEvent(attemptReserved.run);
|
||||
const nextRun: RunRecord = {
|
||||
...runReserved.run,
|
||||
status: 'lost',
|
||||
errorCode,
|
||||
errorSummary,
|
||||
};
|
||||
if (
|
||||
!(await transaction.compareAndSetRun(
|
||||
nextRun,
|
||||
attemptReserved.run.version,
|
||||
))
|
||||
) {
|
||||
throw new LocalRunRecoveryConcurrentWriteError();
|
||||
}
|
||||
await transaction.appendEvent(
|
||||
event(
|
||||
this.createEventId(),
|
||||
run.id,
|
||||
attempt.id,
|
||||
{
|
||||
sequence: runReserved.sequence,
|
||||
type: 'run.lost',
|
||||
payload: Object.freeze({
|
||||
from_status: run.status,
|
||||
to_status: 'lost',
|
||||
error_code: errorCode,
|
||||
version: nextRun.version,
|
||||
}),
|
||||
},
|
||||
`local-recovery:run:${run.id}:${attempt.id}:${run.version}:${reason}`,
|
||||
'reconciler',
|
||||
'local-startup',
|
||||
atMs,
|
||||
),
|
||||
);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
private async markObservedRunning(
|
||||
expected: AggregateSnapshot,
|
||||
): Promise<AggregateSnapshot | null> {
|
||||
return this.repository.transaction(async (transaction) => {
|
||||
const current = await this.reloadExact(transaction, expected);
|
||||
if (!current || !current.attempt) return null;
|
||||
let { run, attempt } = current;
|
||||
const originalRunStatus = run.status;
|
||||
const atMs = atOrAfter(this.clock.now(), run, attempt);
|
||||
if (attempt.status === 'starting') {
|
||||
const reserved = reserveEvent(run);
|
||||
const nextAttempt: RunAttemptRecord = {
|
||||
...attempt,
|
||||
status: 'running',
|
||||
startedAtMs: attempt.startedAtMs ?? atMs,
|
||||
};
|
||||
await this.persistRunAndAttempt(
|
||||
transaction,
|
||||
run,
|
||||
attempt,
|
||||
reserved.run,
|
||||
nextAttempt,
|
||||
);
|
||||
await transaction.appendEvent(
|
||||
event(
|
||||
this.createEventId(),
|
||||
run.id,
|
||||
attempt.id,
|
||||
{
|
||||
sequence: reserved.sequence,
|
||||
type: 'attempt.running',
|
||||
payload: Object.freeze({
|
||||
attempt_id: attempt.id,
|
||||
from_status: attempt.status,
|
||||
to_status: 'running',
|
||||
evidence: 'durable_process_identity',
|
||||
version: reserved.run.version,
|
||||
}),
|
||||
},
|
||||
`local-recovery:attempt:${attempt.id}:${attempt.callbackSequence}:running`,
|
||||
'reconciler',
|
||||
'local-startup',
|
||||
atMs,
|
||||
),
|
||||
);
|
||||
run = reserved.run;
|
||||
attempt = nextAttempt;
|
||||
}
|
||||
if (run.status === 'dispatching') {
|
||||
const reserved = reserveEvent(run);
|
||||
const nextRun: RunRecord = {
|
||||
...reserved.run,
|
||||
status: 'running',
|
||||
startedAtMs: run.startedAtMs ?? atMs,
|
||||
};
|
||||
if (!(await transaction.compareAndSetRun(nextRun, run.version))) {
|
||||
throw new LocalRunRecoveryConcurrentWriteError();
|
||||
}
|
||||
await transaction.appendEvent(
|
||||
event(
|
||||
this.createEventId(),
|
||||
run.id,
|
||||
attempt.id,
|
||||
{
|
||||
sequence: reserved.sequence,
|
||||
type: 'run.running',
|
||||
payload: Object.freeze({
|
||||
from_status: originalRunStatus,
|
||||
to_status: 'running',
|
||||
evidence: 'durable_process_identity',
|
||||
version: nextRun.version,
|
||||
}),
|
||||
},
|
||||
`local-recovery:run:${run.id}:${attempt.id}:${run.version}:running`,
|
||||
'reconciler',
|
||||
'local-startup',
|
||||
atMs,
|
||||
),
|
||||
);
|
||||
run = nextRun;
|
||||
}
|
||||
if (run.status !== 'running' || attempt.status !== 'running') return null;
|
||||
return Object.freeze({ run, attempt });
|
||||
});
|
||||
}
|
||||
|
||||
private async reloadExact(
|
||||
transaction: RunRepositoryTransaction,
|
||||
expected: AggregateSnapshot,
|
||||
): Promise<AggregateSnapshot | null> {
|
||||
const run = await transaction.findRunById(expected.run.id);
|
||||
const expectedAttempt = expected.attempt;
|
||||
if (!run || run.version !== expected.run.version) return null;
|
||||
if (!expectedAttempt) {
|
||||
return Object.freeze({ run, attempt: null });
|
||||
}
|
||||
const attempt = await transaction.findAttemptById(expectedAttempt.id);
|
||||
if (
|
||||
!attempt ||
|
||||
attempt.runId !== run.id ||
|
||||
attempt.status !== expectedAttempt.status ||
|
||||
attempt.callbackSequence !== expectedAttempt.callbackSequence ||
|
||||
attempt.executorHandle !== expectedAttempt.executorHandle ||
|
||||
attempt.pid !== expectedAttempt.pid
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return Object.freeze({ run, attempt });
|
||||
}
|
||||
|
||||
private async persistRunAndAttempt(
|
||||
transaction: RunRepositoryTransaction,
|
||||
run: RunRecord,
|
||||
attempt: RunAttemptRecord,
|
||||
nextRun: RunRecord,
|
||||
nextAttempt: RunAttemptRecord,
|
||||
): Promise<void> {
|
||||
if (!(await transaction.compareAndSetRun(nextRun, run.version))) {
|
||||
throw new LocalRunRecoveryConcurrentWriteError();
|
||||
}
|
||||
if (
|
||||
!(await transaction.compareAndSetAttempt(nextAttempt, {
|
||||
status: attempt.status,
|
||||
callbackSequence: attempt.callbackSequence,
|
||||
}))
|
||||
) {
|
||||
throw new LocalRunRecoveryConcurrentWriteError();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './coordinator';
|
||||
export * from './workflowTask';
|
||||
@@ -0,0 +1,461 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import type {
|
||||
RunAttemptRecord,
|
||||
RunRecord,
|
||||
RunRepository,
|
||||
} from '@qinglong/runtime-core/run-repository';
|
||||
import type {
|
||||
LocalPersistedExecutionInspector,
|
||||
} from '@qinglong/local-process';
|
||||
|
||||
import type { LocalCompletionReceiptProcessor } from '../control/completion';
|
||||
import type { LocalWorkflowTaskExecutionRepository } from '../execution/workflowTaskExecution';
|
||||
|
||||
export const MAX_LOCAL_WORKFLOW_TASK_RECOVERY_ITEMS = 64;
|
||||
|
||||
export interface LocalWorkflowTaskRecoveryCandidate {
|
||||
readonly runId: string;
|
||||
readonly attemptId: string;
|
||||
readonly attemptCreatedAtMs: number;
|
||||
}
|
||||
|
||||
export interface LocalWorkflowTaskRecoveryPage {
|
||||
readonly candidates: readonly Readonly<LocalWorkflowTaskRecoveryCandidate>[];
|
||||
readonly truncated: boolean;
|
||||
}
|
||||
|
||||
export interface LocalWorkflowTaskRecoveryRepository {
|
||||
listRecoveryCandidates(command: Readonly<{
|
||||
limit: number;
|
||||
}>): Promise<Readonly<LocalWorkflowTaskRecoveryPage>>;
|
||||
recover(command: Readonly<{
|
||||
run: Readonly<RunRecord>;
|
||||
attempt: Readonly<RunAttemptRecord>;
|
||||
reason: 'unstarted_claim_expired' | 'execution_not_running';
|
||||
observedAtMs: number;
|
||||
}>): Promise<'requeued' | 'failed' | 'already_recovered' | 'stale'>;
|
||||
}
|
||||
|
||||
export interface LocalWorkflowTaskStartupRecoveryOptions {
|
||||
readonly receiptPublishGraceMs?: number;
|
||||
readonly clock?: { now(): number };
|
||||
readonly wait?: (delayMs: number) => Promise<void>;
|
||||
readonly createEventId?: () => string;
|
||||
}
|
||||
|
||||
export interface LocalWorkflowTaskStartupRecoverySummary {
|
||||
readonly safe: boolean;
|
||||
readonly scanned: number;
|
||||
readonly recovered: number;
|
||||
readonly verified: number;
|
||||
readonly remaining: number;
|
||||
readonly failed: number;
|
||||
readonly truncated: boolean;
|
||||
}
|
||||
|
||||
interface ActiveSnapshot {
|
||||
readonly run: Readonly<RunRecord>;
|
||||
readonly attempt: Readonly<RunAttemptRecord>;
|
||||
}
|
||||
|
||||
interface VerifiedFingerprint {
|
||||
readonly runId: string;
|
||||
readonly runVersion: number;
|
||||
readonly attemptId: string;
|
||||
readonly callbackSequence: number;
|
||||
readonly executorHandle: string;
|
||||
readonly pid: number;
|
||||
}
|
||||
|
||||
function timestamp(value: unknown, label: string): number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
||||
throw new TypeError(`${label} is invalid`);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function validatePage(
|
||||
value: Readonly<LocalWorkflowTaskRecoveryPage>,
|
||||
): Readonly<LocalWorkflowTaskRecoveryPage> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
!Array.isArray(value.candidates) ||
|
||||
value.candidates.length > MAX_LOCAL_WORKFLOW_TASK_RECOVERY_ITEMS ||
|
||||
typeof value.truncated !== 'boolean'
|
||||
) {
|
||||
throw new TypeError('Local Workflow Task recovery page is invalid');
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
let previous:
|
||||
| Readonly<LocalWorkflowTaskRecoveryCandidate>
|
||||
| undefined;
|
||||
const candidates = value.candidates.map((candidate) => {
|
||||
if (
|
||||
!candidate ||
|
||||
typeof candidate !== 'object' ||
|
||||
Array.isArray(candidate) ||
|
||||
typeof candidate.runId !== 'string' ||
|
||||
candidate.runId.length < 1 ||
|
||||
typeof candidate.attemptId !== 'string' ||
|
||||
candidate.attemptId.length < 1 ||
|
||||
seen.has(candidate.attemptId)
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Local Workflow Task recovery candidate is invalid',
|
||||
);
|
||||
}
|
||||
const attemptCreatedAtMs = timestamp(
|
||||
candidate.attemptCreatedAtMs,
|
||||
'Local Workflow Task recovery candidate timestamp',
|
||||
);
|
||||
const normalized = Object.freeze({
|
||||
runId: candidate.runId,
|
||||
attemptId: candidate.attemptId,
|
||||
attemptCreatedAtMs,
|
||||
});
|
||||
if (
|
||||
previous &&
|
||||
(normalized.attemptCreatedAtMs < previous.attemptCreatedAtMs ||
|
||||
(normalized.attemptCreatedAtMs === previous.attemptCreatedAtMs &&
|
||||
normalized.attemptId <= previous.attemptId))
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Local Workflow Task recovery candidates are not ordered',
|
||||
);
|
||||
}
|
||||
seen.add(normalized.attemptId);
|
||||
previous = normalized;
|
||||
return normalized;
|
||||
});
|
||||
return Object.freeze({
|
||||
candidates: Object.freeze(candidates),
|
||||
truncated: value.truncated,
|
||||
});
|
||||
}
|
||||
|
||||
function fingerprint(snapshot: ActiveSnapshot): VerifiedFingerprint {
|
||||
if (
|
||||
snapshot.run.status !== 'running' ||
|
||||
snapshot.attempt.status !== 'running' ||
|
||||
!snapshot.attempt.stepRunId ||
|
||||
!snapshot.attempt.executorHandle ||
|
||||
!Number.isSafeInteger(snapshot.attempt.pid) ||
|
||||
snapshot.attempt.pid! < 1
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Local Workflow Task recovery fingerprint is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
runId: snapshot.run.id,
|
||||
runVersion: snapshot.run.version,
|
||||
attemptId: snapshot.attempt.id,
|
||||
callbackSequence: snapshot.attempt.callbackSequence,
|
||||
executorHandle: snapshot.attempt.executorHandle,
|
||||
pid: snapshot.attempt.pid!,
|
||||
});
|
||||
}
|
||||
|
||||
export class LocalWorkflowTaskStartupRecoveryCoordinator {
|
||||
private readonly clock: { now(): number };
|
||||
private readonly wait: (delayMs: number) => Promise<void>;
|
||||
private readonly receiptPublishGraceMs: number;
|
||||
private readonly createEventId: () => string;
|
||||
|
||||
constructor(
|
||||
private readonly runs: RunRepository,
|
||||
private readonly recovery: LocalWorkflowTaskRecoveryRepository,
|
||||
private readonly workflowTasks: LocalWorkflowTaskExecutionRepository,
|
||||
private readonly completions: Pick<
|
||||
LocalCompletionReceiptProcessor,
|
||||
'process'
|
||||
>,
|
||||
private readonly inspector: LocalPersistedExecutionInspector,
|
||||
options: LocalWorkflowTaskStartupRecoveryOptions = {},
|
||||
) {
|
||||
if (
|
||||
typeof recovery?.listRecoveryCandidates !== 'function' ||
|
||||
typeof recovery?.recover !== 'function' ||
|
||||
typeof workflowTasks?.recordRunning !== 'function' ||
|
||||
typeof completions?.process !== 'function' ||
|
||||
inspector?.executorType !== 'local_process'
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Local Workflow Task startup recovery dependencies are invalid',
|
||||
);
|
||||
}
|
||||
this.clock = options.clock ?? { now: Date.now };
|
||||
this.wait =
|
||||
options.wait ??
|
||||
((delayMs) =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(resolve, delayMs);
|
||||
}));
|
||||
this.receiptPublishGraceMs = options.receiptPublishGraceMs ?? 0;
|
||||
if (
|
||||
!Number.isSafeInteger(this.receiptPublishGraceMs) ||
|
||||
this.receiptPublishGraceMs < 0 ||
|
||||
this.receiptPublishGraceMs > 5_000
|
||||
) {
|
||||
throw new RangeError(
|
||||
'Local Workflow Task receipt grace is invalid',
|
||||
);
|
||||
}
|
||||
this.createEventId = options.createEventId ?? randomUUID;
|
||||
}
|
||||
|
||||
async recover(): Promise<Readonly<LocalWorkflowTaskStartupRecoverySummary>> {
|
||||
const initial = validatePage(
|
||||
await this.recovery.listRecoveryCandidates({
|
||||
limit: MAX_LOCAL_WORKFLOW_TASK_RECOVERY_ITEMS,
|
||||
}),
|
||||
);
|
||||
if (initial.truncated) {
|
||||
return Object.freeze({
|
||||
safe: false,
|
||||
scanned: initial.candidates.length,
|
||||
recovered: 0,
|
||||
verified: 0,
|
||||
remaining: initial.candidates.length,
|
||||
failed: 0,
|
||||
truncated: true,
|
||||
});
|
||||
}
|
||||
let recovered = 0;
|
||||
let remaining = 0;
|
||||
let failed = 0;
|
||||
const verified = new Map<string, VerifiedFingerprint>();
|
||||
for (const candidate of initial.candidates) {
|
||||
try {
|
||||
const result = await this.reconcile(candidate);
|
||||
if (result.status === 'recovered') recovered += 1;
|
||||
else if (result.status === 'verified') {
|
||||
verified.set(candidate.attemptId, result.fingerprint);
|
||||
} else if (result.status === 'remaining') remaining += 1;
|
||||
else failed += 1;
|
||||
} catch {
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
if (remaining === 0 && failed === 0) {
|
||||
const final = await this.verify(verified);
|
||||
remaining += final.remaining;
|
||||
failed += final.failed;
|
||||
}
|
||||
return Object.freeze({
|
||||
safe: remaining === 0 && failed === 0,
|
||||
scanned: initial.candidates.length,
|
||||
recovered,
|
||||
verified: verified.size,
|
||||
remaining,
|
||||
failed,
|
||||
truncated: false,
|
||||
});
|
||||
}
|
||||
|
||||
private async reconcile(
|
||||
candidate: Readonly<LocalWorkflowTaskRecoveryCandidate>,
|
||||
): Promise<
|
||||
| Readonly<{ status: 'recovered' }>
|
||||
| Readonly<{
|
||||
status: 'verified';
|
||||
fingerprint: VerifiedFingerprint;
|
||||
}>
|
||||
| Readonly<{ status: 'remaining' }>
|
||||
| Readonly<{ status: 'failed' }>
|
||||
> {
|
||||
let snapshot = await this.load(candidate);
|
||||
if (!snapshot) return Object.freeze({ status: 'remaining' as const });
|
||||
const completion = await this.completions.process(candidate.attemptId);
|
||||
if (completion === 'completed' || completion === 'already_terminal') {
|
||||
return Object.freeze({ status: 'recovered' as const });
|
||||
}
|
||||
if (completion === 'invalid') {
|
||||
return Object.freeze({ status: 'remaining' as const });
|
||||
}
|
||||
if (snapshot.attempt.status === 'claimed') {
|
||||
const result = await this.recovery.recover({
|
||||
...snapshot,
|
||||
reason: 'unstarted_claim_expired',
|
||||
observedAtMs: Math.max(
|
||||
timestamp(
|
||||
this.clock.now(),
|
||||
'Local Workflow Task recovery clock',
|
||||
),
|
||||
snapshot.attempt.createdAtMs,
|
||||
),
|
||||
});
|
||||
return result === 'requeued' || result === 'already_recovered'
|
||||
? Object.freeze({ status: 'recovered' as const })
|
||||
: Object.freeze({ status: 'remaining' as const });
|
||||
}
|
||||
if (
|
||||
(snapshot.attempt.status !== 'starting' &&
|
||||
snapshot.attempt.status !== 'running') ||
|
||||
!snapshot.attempt.executorHandle ||
|
||||
!Number.isSafeInteger(snapshot.attempt.pid) ||
|
||||
snapshot.attempt.pid! < 1
|
||||
) {
|
||||
return Object.freeze({ status: 'remaining' as const });
|
||||
}
|
||||
const inspection = await this.inspector.inspect(
|
||||
snapshot.attempt.executorHandle,
|
||||
);
|
||||
if (inspection.status === 'unknown') {
|
||||
return inspection.reason === 'provider_unavailable'
|
||||
? Object.freeze({ status: 'failed' as const })
|
||||
: Object.freeze({ status: 'remaining' as const });
|
||||
}
|
||||
if (inspection.identityPid !== snapshot.attempt.pid) {
|
||||
return Object.freeze({ status: 'remaining' as const });
|
||||
}
|
||||
if (inspection.status === 'running') {
|
||||
if (snapshot.attempt.status === 'starting') {
|
||||
if (!snapshot.attempt.callbackTokenHash) {
|
||||
return Object.freeze({ status: 'remaining' as const });
|
||||
}
|
||||
const marked = await this.workflowTasks.recordRunning({
|
||||
run: snapshot.run,
|
||||
attempt: snapshot.attempt,
|
||||
callbackTokenHash: snapshot.attempt.callbackTokenHash,
|
||||
executorHandle: snapshot.attempt.executorHandle,
|
||||
pid: snapshot.attempt.pid,
|
||||
startedAtMs: Math.max(
|
||||
timestamp(
|
||||
this.clock.now(),
|
||||
'Local Workflow Task recovery clock',
|
||||
),
|
||||
snapshot.attempt.createdAtMs,
|
||||
),
|
||||
attemptEventId: this.createEventId(),
|
||||
stepMutationId: this.createEventId(),
|
||||
});
|
||||
if (marked.status !== 'applied') {
|
||||
return Object.freeze({ status: 'remaining' as const });
|
||||
}
|
||||
snapshot = await this.load(candidate);
|
||||
if (!snapshot || snapshot.attempt.status !== 'running') {
|
||||
return Object.freeze({ status: 'remaining' as const });
|
||||
}
|
||||
}
|
||||
return Object.freeze({
|
||||
status: 'verified' as const,
|
||||
fingerprint: fingerprint(snapshot),
|
||||
});
|
||||
}
|
||||
if (this.receiptPublishGraceMs > 0) {
|
||||
await this.wait(this.receiptPublishGraceMs);
|
||||
const late = await this.completions.process(candidate.attemptId);
|
||||
if (late === 'completed' || late === 'already_terminal') {
|
||||
return Object.freeze({ status: 'recovered' as const });
|
||||
}
|
||||
if (late === 'invalid') {
|
||||
return Object.freeze({ status: 'remaining' as const });
|
||||
}
|
||||
snapshot = await this.load(candidate);
|
||||
if (!snapshot) return Object.freeze({ status: 'recovered' as const });
|
||||
}
|
||||
const result = await this.recovery.recover({
|
||||
...snapshot,
|
||||
reason: 'execution_not_running',
|
||||
observedAtMs: Math.max(
|
||||
timestamp(
|
||||
this.clock.now(),
|
||||
'Local Workflow Task recovery clock',
|
||||
),
|
||||
snapshot.attempt.createdAtMs,
|
||||
snapshot.attempt.startedAtMs ?? 0,
|
||||
),
|
||||
});
|
||||
return result === 'failed' || result === 'already_recovered'
|
||||
? Object.freeze({ status: 'recovered' as const })
|
||||
: Object.freeze({ status: 'remaining' as const });
|
||||
}
|
||||
|
||||
private async verify(
|
||||
expected: ReadonlyMap<string, VerifiedFingerprint>,
|
||||
): Promise<Readonly<{ remaining: number; failed: number }>> {
|
||||
let page: Readonly<LocalWorkflowTaskRecoveryPage>;
|
||||
try {
|
||||
page = validatePage(
|
||||
await this.recovery.listRecoveryCandidates({
|
||||
limit: MAX_LOCAL_WORKFLOW_TASK_RECOVERY_ITEMS,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
return Object.freeze({ remaining: 0, failed: 1 });
|
||||
}
|
||||
if (page.truncated) {
|
||||
return Object.freeze({
|
||||
remaining: page.candidates.length,
|
||||
failed: 0,
|
||||
});
|
||||
}
|
||||
let remaining = 0;
|
||||
let failed = 0;
|
||||
for (const candidate of page.candidates) {
|
||||
const fingerprintValue = expected.get(candidate.attemptId);
|
||||
if (!fingerprintValue) {
|
||||
remaining += 1;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const snapshot = await this.load(candidate);
|
||||
if (
|
||||
!snapshot ||
|
||||
snapshot.run.version !== fingerprintValue.runVersion ||
|
||||
snapshot.attempt.status !== 'running' ||
|
||||
snapshot.attempt.callbackSequence !==
|
||||
fingerprintValue.callbackSequence ||
|
||||
snapshot.attempt.executorHandle !==
|
||||
fingerprintValue.executorHandle ||
|
||||
snapshot.attempt.pid !== fingerprintValue.pid
|
||||
) {
|
||||
remaining += 1;
|
||||
continue;
|
||||
}
|
||||
const inspection = await this.inspector.inspect(
|
||||
fingerprintValue.executorHandle,
|
||||
);
|
||||
if (
|
||||
inspection.status !== 'running' ||
|
||||
inspection.identityPid !== fingerprintValue.pid
|
||||
) {
|
||||
remaining += 1;
|
||||
}
|
||||
} catch {
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
return Object.freeze({ remaining, failed });
|
||||
}
|
||||
|
||||
private load(
|
||||
candidate: Readonly<LocalWorkflowTaskRecoveryCandidate>,
|
||||
): Promise<ActiveSnapshot | null> {
|
||||
return this.runs.transaction(async (transaction) => {
|
||||
const [run, attempt] = await Promise.all([
|
||||
transaction.findRunById(candidate.runId),
|
||||
transaction.findAttemptById(candidate.attemptId),
|
||||
]);
|
||||
if (
|
||||
!run ||
|
||||
!attempt ||
|
||||
run.status !== 'running' ||
|
||||
run.executionOwner !== 'runtime' ||
|
||||
run.cancelRequestedAtMs !== undefined ||
|
||||
attempt.runId !== run.id ||
|
||||
!attempt.stepRunId ||
|
||||
attempt.executorType !== 'local_process' ||
|
||||
!['claimed', 'starting', 'running'].includes(attempt.status) ||
|
||||
attempt.createdAtMs !== candidate.attemptCreatedAtMs
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return Object.freeze({ run, attempt });
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import {
|
||||
assertLocalSchedulePageSize,
|
||||
resolveLocalScheduleDecision,
|
||||
type LocalCronNextOccurrence,
|
||||
type LocalScheduleStore,
|
||||
} from '@qinglong/runtime-core/local-scheduler';
|
||||
import { cronerLocalNextOccurrence } from './croner';
|
||||
|
||||
export interface LocalSchedulerCoordinatorOptions {
|
||||
readonly pageSize?: number;
|
||||
readonly misfireGraceMs?: number;
|
||||
readonly clock?: () => number;
|
||||
readonly createId?: () => string;
|
||||
readonly nextOccurrence?: LocalCronNextOccurrence;
|
||||
readonly onAdmitted?: (
|
||||
runId: string,
|
||||
attemptId: string,
|
||||
) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface LocalSchedulerCycleSummary {
|
||||
readonly observedAtMs: number;
|
||||
readonly scanned: number;
|
||||
readonly initialized: number;
|
||||
readonly skipped: number;
|
||||
readonly admitted: number;
|
||||
readonly raced: number;
|
||||
readonly truncated: boolean;
|
||||
}
|
||||
|
||||
export class LocalSchedulerCoordinator {
|
||||
private readonly pageSize: number;
|
||||
private readonly misfireGraceMs: number;
|
||||
private readonly clock: () => number;
|
||||
private readonly createId: () => string;
|
||||
private readonly nextOccurrence: LocalCronNextOccurrence;
|
||||
private readonly onAdmitted?: LocalSchedulerCoordinatorOptions['onAdmitted'];
|
||||
|
||||
constructor(
|
||||
private readonly schedules: LocalScheduleStore,
|
||||
options: LocalSchedulerCoordinatorOptions = {},
|
||||
) {
|
||||
this.pageSize = options.pageSize ?? 8;
|
||||
this.misfireGraceMs = options.misfireGraceMs ?? 30_000;
|
||||
this.clock = options.clock ?? Date.now;
|
||||
this.createId = options.createId ?? randomUUID;
|
||||
this.nextOccurrence = options.nextOccurrence ?? cronerLocalNextOccurrence;
|
||||
this.onAdmitted = options.onAdmitted;
|
||||
assertLocalSchedulePageSize(this.pageSize);
|
||||
if (
|
||||
!Number.isSafeInteger(this.misfireGraceMs) ||
|
||||
this.misfireGraceMs < 0 ||
|
||||
this.misfireGraceMs > 5 * 60_000 ||
|
||||
typeof this.clock !== 'function' ||
|
||||
typeof this.createId !== 'function' ||
|
||||
typeof this.nextOccurrence !== 'function' ||
|
||||
(this.onAdmitted !== undefined && typeof this.onAdmitted !== 'function')
|
||||
) {
|
||||
throw new TypeError('Local scheduler options are invalid');
|
||||
}
|
||||
}
|
||||
|
||||
async scheduleOnce(): Promise<LocalSchedulerCycleSummary> {
|
||||
const observedAtMs = this.clock();
|
||||
if (!Number.isSafeInteger(observedAtMs) || observedAtMs < 0) {
|
||||
throw new TypeError('Local scheduler clock is invalid');
|
||||
}
|
||||
const page = await this.schedules.listLocalScheduleCandidates({
|
||||
observedAtMs,
|
||||
limit: this.pageSize,
|
||||
});
|
||||
if (page.candidates.length > this.pageSize) {
|
||||
throw new RangeError('Local scheduler source exceeded its page size');
|
||||
}
|
||||
const stats = {
|
||||
observedAtMs,
|
||||
scanned: 0,
|
||||
initialized: 0,
|
||||
skipped: 0,
|
||||
admitted: 0,
|
||||
raced: 0,
|
||||
truncated: page.truncated,
|
||||
};
|
||||
for (const candidate of page.candidates) {
|
||||
stats.scanned += 1;
|
||||
const decision = resolveLocalScheduleDecision(
|
||||
candidate,
|
||||
observedAtMs,
|
||||
this.misfireGraceMs,
|
||||
this.nextOccurrence,
|
||||
);
|
||||
const admitted = decision.disposition === 'admit';
|
||||
const result = await this.schedules.commitLocalScheduleDecision({
|
||||
decision,
|
||||
...(admitted
|
||||
? {
|
||||
runId: this.createId(),
|
||||
attemptId: this.createId(),
|
||||
createdEventId: this.createId(),
|
||||
queuedEventId: this.createId(),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
if (result.status === 'raced') {
|
||||
stats.raced += 1;
|
||||
continue;
|
||||
}
|
||||
if (result.disposition === 'initialize') stats.initialized += 1;
|
||||
if (result.disposition === 'skip') stats.skipped += 1;
|
||||
if (result.status === 'admitted') {
|
||||
stats.admitted += 1;
|
||||
await this.onAdmitted?.(result.runId, result.attemptId);
|
||||
}
|
||||
}
|
||||
return Object.freeze(stats);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type {
|
||||
LocalCronNextOccurrence,
|
||||
LocalCronSchedule,
|
||||
} from '@qinglong/runtime-core/local-scheduler';
|
||||
|
||||
interface CronerJob {
|
||||
nextRun(after: Date): Date | null;
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
interface CronerConstructor {
|
||||
new (
|
||||
expression: string,
|
||||
options: Readonly<{
|
||||
timezone: string;
|
||||
paused: true;
|
||||
unref: true;
|
||||
}>,
|
||||
): CronerJob;
|
||||
}
|
||||
|
||||
export const cronerLocalNextOccurrence: LocalCronNextOccurrence = (
|
||||
schedule: LocalCronSchedule,
|
||||
afterMs: number,
|
||||
): number => {
|
||||
let job: CronerJob | undefined;
|
||||
try {
|
||||
const { Cron } = require('croner') as Readonly<{
|
||||
Cron: CronerConstructor;
|
||||
}>;
|
||||
job = new Cron(schedule.expression, {
|
||||
timezone: schedule.timezone,
|
||||
paused: true,
|
||||
unref: true,
|
||||
});
|
||||
const next = job.nextRun(new Date(afterMs));
|
||||
if (!(next instanceof Date)) {
|
||||
throw new Error('cron has no next occurrence');
|
||||
}
|
||||
return next.getTime();
|
||||
} finally {
|
||||
job?.stop();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
export {
|
||||
LocalSchedulerCoordinator,
|
||||
type LocalSchedulerCoordinatorOptions,
|
||||
type LocalSchedulerCycleSummary,
|
||||
} from './coordinator';
|
||||
export {
|
||||
LocalSchedulerLifecycle,
|
||||
type LocalSchedulerLifecycleOptions,
|
||||
type LocalSchedulerLifecycleStopSummary,
|
||||
} from './lifecycle';
|
||||
export { cronerLocalNextOccurrence } from './croner';
|
||||
export {
|
||||
LocalWorkflowSchedulerCoordinator,
|
||||
type LocalWorkflowSchedulerCoordinatorOptions,
|
||||
type LocalWorkflowSchedulerCycleSummary,
|
||||
} from './workflowCoordinator';
|
||||
@@ -0,0 +1,128 @@
|
||||
import type {
|
||||
LocalSchedulerCoordinator,
|
||||
LocalSchedulerCycleSummary,
|
||||
} from './coordinator';
|
||||
|
||||
export interface LocalSchedulerLifecycleOptions {
|
||||
readonly intervalMs: number;
|
||||
readonly stopTimeoutMs: number;
|
||||
readonly onDiagnostic?: (
|
||||
error: unknown,
|
||||
summary?: LocalSchedulerCycleSummary,
|
||||
) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface LocalSchedulerLifecycleStopSummary {
|
||||
readonly status: 'stopped' | 'timed_out';
|
||||
}
|
||||
|
||||
export class LocalSchedulerLifecycle {
|
||||
private timer: NodeJS.Timeout | undefined;
|
||||
private inFlight: Promise<LocalSchedulerCycleSummary> | undefined;
|
||||
private stopPromise: Promise<LocalSchedulerLifecycleStopSummary> | undefined;
|
||||
private running = false;
|
||||
private stopping = false;
|
||||
|
||||
constructor(
|
||||
private readonly scheduler: Pick<LocalSchedulerCoordinator, 'scheduleOnce'>,
|
||||
private readonly options: LocalSchedulerLifecycleOptions,
|
||||
) {
|
||||
if (
|
||||
!scheduler ||
|
||||
typeof scheduler.scheduleOnce !== 'function' ||
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
!Number.isSafeInteger(options.intervalMs) ||
|
||||
options.intervalMs < 250 ||
|
||||
options.intervalMs > 60 * 60_000 ||
|
||||
!Number.isSafeInteger(options.stopTimeoutMs) ||
|
||||
options.stopTimeoutMs < 100 ||
|
||||
options.stopTimeoutMs > 30_000 ||
|
||||
(options.onDiagnostic !== undefined &&
|
||||
typeof options.onDiagnostic !== 'function')
|
||||
) {
|
||||
throw new TypeError('Local scheduler lifecycle options are invalid');
|
||||
}
|
||||
}
|
||||
|
||||
start(): 'started' {
|
||||
if (!this.running && !this.stopping) {
|
||||
this.running = true;
|
||||
this.schedule();
|
||||
}
|
||||
return 'started';
|
||||
}
|
||||
|
||||
runOnce(): Promise<LocalSchedulerCycleSummary> {
|
||||
if (this.stopping) {
|
||||
return Promise.reject(
|
||||
new Error('Local scheduler lifecycle is stopping'),
|
||||
);
|
||||
}
|
||||
if (this.inFlight) return this.inFlight;
|
||||
const work = this.scheduler.scheduleOnce().finally(() => {
|
||||
if (this.inFlight === work) this.inFlight = undefined;
|
||||
});
|
||||
this.inFlight = work;
|
||||
return work;
|
||||
}
|
||||
|
||||
stopAndDrain(): Promise<LocalSchedulerLifecycleStopSummary> {
|
||||
if (this.stopPromise) return this.stopPromise;
|
||||
this.stopping = true;
|
||||
this.running = false;
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.timer = undefined;
|
||||
|
||||
this.stopPromise = (async () => {
|
||||
const work = this.inFlight;
|
||||
if (!work) return Object.freeze({ status: 'stopped' as const });
|
||||
let timeout: NodeJS.Timeout | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
work.then(
|
||||
() => Object.freeze({ status: 'stopped' as const }),
|
||||
() => Object.freeze({ status: 'stopped' as const }),
|
||||
),
|
||||
new Promise<LocalSchedulerLifecycleStopSummary>((resolve) => {
|
||||
timeout = setTimeout(
|
||||
() =>
|
||||
resolve(Object.freeze({ status: 'timed_out' as const })),
|
||||
this.options.stopTimeoutMs,
|
||||
);
|
||||
timeout.unref?.();
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
}
|
||||
})();
|
||||
return this.stopPromise;
|
||||
}
|
||||
|
||||
private schedule(): void {
|
||||
if (!this.running || this.timer) return;
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = undefined;
|
||||
if (!this.running) return;
|
||||
void this.runOnce()
|
||||
.then((summary) => this.diagnostic(undefined, summary))
|
||||
.catch((error) => this.diagnostic(error))
|
||||
.finally(() => this.schedule());
|
||||
}, this.options.intervalMs);
|
||||
this.timer.unref?.();
|
||||
}
|
||||
|
||||
private async diagnostic(
|
||||
error: unknown,
|
||||
summary?: LocalSchedulerCycleSummary,
|
||||
): Promise<void> {
|
||||
if (this.stopping) return;
|
||||
try {
|
||||
await this.options.onDiagnostic?.(error, summary);
|
||||
} catch {
|
||||
// Diagnostics cannot own or stop scheduling.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
import {
|
||||
ClusterRunCancellationConvergenceCoordinator,
|
||||
type ClusterRunCancellationConvergenceCycleResult,
|
||||
type ClusterRunCancellationConvergenceRepository,
|
||||
} from '@qinglong/runtime-core/cluster-run-cancellation-convergence';
|
||||
import type {
|
||||
PluginPackageWorkflowFrontierCursor,
|
||||
PluginPackageWorkflowFrontierRepository,
|
||||
} from '@qinglong/runtime-core/plugin-package-workflow-frontier';
|
||||
import type {
|
||||
PluginPackageWorkflowTaskAttemptAdmissionCursor,
|
||||
PluginPackageWorkflowTaskAttemptAdmissionRepository,
|
||||
} from '@qinglong/runtime-core/plugin-package-workflow-task-attempt-admission';
|
||||
|
||||
import type {
|
||||
LocalRunDispatcher,
|
||||
LocalRunDispatcherResult,
|
||||
} from '../dispatch/dispatcher';
|
||||
import type {
|
||||
LocalSchedulerCoordinator,
|
||||
LocalSchedulerCycleSummary,
|
||||
} from './coordinator';
|
||||
|
||||
export interface LocalWorkflowSchedulerCoordinatorOptions {
|
||||
readonly cancellationPageSize: number;
|
||||
readonly cancellationMaxPages: number;
|
||||
readonly frontierPageSize: number;
|
||||
readonly frontierMaxPages: number;
|
||||
readonly taskAttemptPageSize: number;
|
||||
readonly taskAttemptMaxPages: number;
|
||||
readonly maxDispatches: number;
|
||||
}
|
||||
|
||||
export interface LocalWorkflowSchedulerCycleSummary {
|
||||
readonly cancellation: Readonly<ClusterRunCancellationConvergenceCycleResult>;
|
||||
readonly frontierPages: number;
|
||||
readonly frontierScanned: number;
|
||||
readonly frontierAdvanced: number;
|
||||
readonly frontierTruncated: boolean;
|
||||
readonly taskAttemptPages: number;
|
||||
readonly taskAttemptsScanned: number;
|
||||
readonly taskAttemptsCreated: number;
|
||||
readonly taskAttemptsExisting: number;
|
||||
readonly taskAttemptsTruncated: boolean;
|
||||
readonly dispatches: number;
|
||||
readonly activated: number;
|
||||
readonly activationFailed: number;
|
||||
readonly dispatchIdle: boolean;
|
||||
}
|
||||
|
||||
function bounded(
|
||||
value: number,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
label: string,
|
||||
): number {
|
||||
if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
|
||||
throw new RangeError(`${label} must be between ${minimum} and ${maximum}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function nextFrontierCursor(
|
||||
current: PluginPackageWorkflowFrontierCursor | undefined,
|
||||
next: PluginPackageWorkflowFrontierCursor | undefined,
|
||||
): PluginPackageWorkflowFrontierCursor {
|
||||
if (
|
||||
!next ||
|
||||
(current !== undefined &&
|
||||
(next.admittedAtMs < current.admittedAtMs ||
|
||||
(next.admittedAtMs === current.admittedAtMs &&
|
||||
next.planDigest <= current.planDigest)))
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Local Workflow frontier continuation did not advance',
|
||||
);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function nextTaskAttemptCursor(
|
||||
current: PluginPackageWorkflowTaskAttemptAdmissionCursor | undefined,
|
||||
next: PluginPackageWorkflowTaskAttemptAdmissionCursor | undefined,
|
||||
): PluginPackageWorkflowTaskAttemptAdmissionCursor {
|
||||
if (
|
||||
!next ||
|
||||
(current !== undefined &&
|
||||
(next.readyAtMs < current.readyAtMs ||
|
||||
(next.readyAtMs === current.readyAtMs &&
|
||||
next.stepRunId <= current.stepRunId)))
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Local Workflow Task Attempt continuation did not advance',
|
||||
);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reuses the existing Local scheduler cadence. It owns no timer, connection,
|
||||
* watcher, or per-Workflow state.
|
||||
*/
|
||||
export class LocalWorkflowSchedulerCoordinator {
|
||||
private readonly cancellation: ClusterRunCancellationConvergenceCoordinator;
|
||||
private readonly frontierPageSize: number;
|
||||
private readonly frontierMaxPages: number;
|
||||
private readonly taskAttemptPageSize: number;
|
||||
private readonly taskAttemptMaxPages: number;
|
||||
private readonly maxDispatches: number;
|
||||
private inFlight:
|
||||
| Promise<Readonly<LocalSchedulerCycleSummary>>
|
||||
| undefined;
|
||||
private latestWorkflow:
|
||||
| Readonly<LocalWorkflowSchedulerCycleSummary>
|
||||
| undefined;
|
||||
|
||||
constructor(
|
||||
private readonly scheduler: Pick<LocalSchedulerCoordinator, 'scheduleOnce'>,
|
||||
cancellation: ClusterRunCancellationConvergenceRepository,
|
||||
private readonly frontier: PluginPackageWorkflowFrontierRepository,
|
||||
private readonly taskAttempts: PluginPackageWorkflowTaskAttemptAdmissionRepository,
|
||||
private readonly dispatcher: Pick<LocalRunDispatcher, 'dispatchOnce'>,
|
||||
options: LocalWorkflowSchedulerCoordinatorOptions,
|
||||
) {
|
||||
if (
|
||||
typeof scheduler?.scheduleOnce !== 'function' ||
|
||||
typeof cancellation?.convergePage !== 'function' ||
|
||||
typeof frontier?.listCandidates !== 'function' ||
|
||||
typeof frontier?.advance !== 'function' ||
|
||||
typeof taskAttempts?.listCandidates !== 'function' ||
|
||||
typeof taskAttempts?.admit !== 'function' ||
|
||||
typeof dispatcher?.dispatchOnce !== 'function' ||
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options)
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Local Workflow scheduler coordinator is invalid',
|
||||
);
|
||||
}
|
||||
this.cancellation = new ClusterRunCancellationConvergenceCoordinator(
|
||||
cancellation,
|
||||
{
|
||||
pageSize: options.cancellationPageSize,
|
||||
maxPages: options.cancellationMaxPages,
|
||||
},
|
||||
);
|
||||
this.frontierPageSize = bounded(
|
||||
options.frontierPageSize,
|
||||
1,
|
||||
64,
|
||||
'Local Workflow frontier page size',
|
||||
);
|
||||
this.frontierMaxPages = bounded(
|
||||
options.frontierMaxPages,
|
||||
1,
|
||||
16,
|
||||
'Local Workflow frontier page limit',
|
||||
);
|
||||
this.taskAttemptPageSize = bounded(
|
||||
options.taskAttemptPageSize,
|
||||
1,
|
||||
64,
|
||||
'Local Workflow Task Attempt page size',
|
||||
);
|
||||
this.taskAttemptMaxPages = bounded(
|
||||
options.taskAttemptMaxPages,
|
||||
1,
|
||||
16,
|
||||
'Local Workflow Task Attempt page limit',
|
||||
);
|
||||
this.maxDispatches = bounded(
|
||||
options.maxDispatches,
|
||||
1,
|
||||
16,
|
||||
'Local Workflow dispatch limit',
|
||||
);
|
||||
}
|
||||
|
||||
scheduleOnce(): Promise<Readonly<LocalSchedulerCycleSummary>> {
|
||||
if (this.inFlight) return this.inFlight;
|
||||
const work = this.runCycle().finally(() => {
|
||||
if (this.inFlight === work) this.inFlight = undefined;
|
||||
});
|
||||
this.inFlight = work;
|
||||
return work;
|
||||
}
|
||||
|
||||
latestWorkflowSummary():
|
||||
| Readonly<LocalWorkflowSchedulerCycleSummary>
|
||||
| undefined {
|
||||
return this.latestWorkflow;
|
||||
}
|
||||
|
||||
private async runCycle(): Promise<Readonly<LocalSchedulerCycleSummary>> {
|
||||
const cancellation = await this.cancellation.reconcile();
|
||||
const scheduler = await this.scheduler.scheduleOnce();
|
||||
const frontier = await this.advanceFrontier();
|
||||
const taskAttempts = await this.admitTaskAttempts();
|
||||
const dispatch = await this.dispatch();
|
||||
this.latestWorkflow = Object.freeze({
|
||||
cancellation,
|
||||
...frontier,
|
||||
...taskAttempts,
|
||||
...dispatch,
|
||||
});
|
||||
return scheduler;
|
||||
}
|
||||
|
||||
private async advanceFrontier(): Promise<Readonly<{
|
||||
frontierPages: number;
|
||||
frontierScanned: number;
|
||||
frontierAdvanced: number;
|
||||
frontierTruncated: boolean;
|
||||
}>> {
|
||||
let frontierPages = 0;
|
||||
let frontierScanned = 0;
|
||||
let frontierAdvanced = 0;
|
||||
let frontierTruncated = false;
|
||||
let after: PluginPackageWorkflowFrontierCursor | undefined;
|
||||
for (
|
||||
let index = 0;
|
||||
index < this.frontierMaxPages;
|
||||
index += 1
|
||||
) {
|
||||
const page = await this.frontier.listCandidates({
|
||||
limit: this.frontierPageSize,
|
||||
...(after === undefined ? {} : { after }),
|
||||
});
|
||||
if (page.candidates.length > this.frontierPageSize) {
|
||||
throw new RangeError(
|
||||
'Local Workflow frontier exceeded its page size',
|
||||
);
|
||||
}
|
||||
frontierPages += 1;
|
||||
frontierScanned += page.candidates.length;
|
||||
for (const candidate of page.candidates) {
|
||||
await this.frontier.advance(candidate.runId);
|
||||
frontierAdvanced += 1;
|
||||
}
|
||||
frontierTruncated = page.truncated;
|
||||
if (!page.truncated) break;
|
||||
after = nextFrontierCursor(after, page.next);
|
||||
}
|
||||
return Object.freeze({
|
||||
frontierPages,
|
||||
frontierScanned,
|
||||
frontierAdvanced,
|
||||
frontierTruncated,
|
||||
});
|
||||
}
|
||||
|
||||
private async admitTaskAttempts(): Promise<Readonly<{
|
||||
taskAttemptPages: number;
|
||||
taskAttemptsScanned: number;
|
||||
taskAttemptsCreated: number;
|
||||
taskAttemptsExisting: number;
|
||||
taskAttemptsTruncated: boolean;
|
||||
}>> {
|
||||
let taskAttemptPages = 0;
|
||||
let taskAttemptsScanned = 0;
|
||||
let taskAttemptsCreated = 0;
|
||||
let taskAttemptsExisting = 0;
|
||||
let taskAttemptsTruncated = false;
|
||||
let after:
|
||||
| PluginPackageWorkflowTaskAttemptAdmissionCursor
|
||||
| undefined;
|
||||
for (
|
||||
let index = 0;
|
||||
index < this.taskAttemptMaxPages;
|
||||
index += 1
|
||||
) {
|
||||
const page = await this.taskAttempts.listCandidates({
|
||||
limit: this.taskAttemptPageSize,
|
||||
...(after === undefined ? {} : { after }),
|
||||
});
|
||||
if (page.candidates.length > this.taskAttemptPageSize) {
|
||||
throw new RangeError(
|
||||
'Local Workflow Task Attempt source exceeded its page size',
|
||||
);
|
||||
}
|
||||
taskAttemptPages += 1;
|
||||
taskAttemptsScanned += page.candidates.length;
|
||||
for (const candidate of page.candidates) {
|
||||
const admitted = await this.taskAttempts.admit(
|
||||
candidate.runId,
|
||||
candidate.stepRunId,
|
||||
);
|
||||
if (admitted.status === 'created') taskAttemptsCreated += 1;
|
||||
else taskAttemptsExisting += 1;
|
||||
}
|
||||
taskAttemptsTruncated = page.truncated;
|
||||
if (!page.truncated) break;
|
||||
after = nextTaskAttemptCursor(after, page.next);
|
||||
}
|
||||
return Object.freeze({
|
||||
taskAttemptPages,
|
||||
taskAttemptsScanned,
|
||||
taskAttemptsCreated,
|
||||
taskAttemptsExisting,
|
||||
taskAttemptsTruncated,
|
||||
});
|
||||
}
|
||||
|
||||
private async dispatch(): Promise<Readonly<{
|
||||
dispatches: number;
|
||||
activated: number;
|
||||
activationFailed: number;
|
||||
dispatchIdle: boolean;
|
||||
}>> {
|
||||
let dispatches = 0;
|
||||
let activated = 0;
|
||||
let activationFailed = 0;
|
||||
let dispatchIdle = false;
|
||||
for (let index = 0; index < this.maxDispatches; index += 1) {
|
||||
const result: LocalRunDispatcherResult =
|
||||
await this.dispatcher.dispatchOnce();
|
||||
dispatches += 1;
|
||||
if (result.status === 'activated') activated += 1;
|
||||
else if (result.status === 'activation_failed') {
|
||||
activationFailed += 1;
|
||||
} else {
|
||||
dispatchIdle = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return Object.freeze({
|
||||
dispatches,
|
||||
activated,
|
||||
activationFailed,
|
||||
dispatchIdle,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { createHash } = require('node:crypto');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
migrateLocalSqlitePath,
|
||||
openLocalSqliteRuntimeDatabase,
|
||||
} = require('@qinglong/local-sqlite');
|
||||
const {
|
||||
LocalCompletionReceiptProcessor,
|
||||
LocalExecutionControlCoordinator,
|
||||
LocalExecutionControlLifecycle,
|
||||
LocalExecutionControlScanner,
|
||||
} = require('../dist/control');
|
||||
|
||||
const IDS = Object.freeze({
|
||||
completionRun: '019f7130-0000-7000-8000-000000000001',
|
||||
completionAttempt: '019f7130-0000-7000-8000-000000000002',
|
||||
deadlineRun: '019f7130-0000-7000-8000-000000000003',
|
||||
deadlineAttempt: '019f7130-0000-7000-8000-000000000004',
|
||||
cancelRun: '019f7130-0000-7000-8000-000000000005',
|
||||
cancelAttempt: '019f7130-0000-7000-8000-000000000006',
|
||||
shutdownRun: '019f7130-0000-7000-8000-000000000007',
|
||||
shutdownAttempt: '019f7130-0000-7000-8000-000000000008',
|
||||
});
|
||||
const TOKEN = 'A'.repeat(32);
|
||||
|
||||
function eventIds() {
|
||||
let value = 0;
|
||||
return () => `control-event-${++value}`;
|
||||
}
|
||||
|
||||
function receiptStore() {
|
||||
const receipts = new Map();
|
||||
return {
|
||||
receipts,
|
||||
removed: [],
|
||||
async publish(receipt) {
|
||||
receipts.set(receipt.attemptId, receipt);
|
||||
},
|
||||
async read(attemptId) {
|
||||
return receipts.get(attemptId);
|
||||
},
|
||||
async remove(attemptId) {
|
||||
this.removed.push(attemptId);
|
||||
return receipts.delete(attemptId);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function fixture(t) {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-control-'));
|
||||
const databasePath = path.join(directory, 'qinglong3.sqlite');
|
||||
await migrateLocalSqlitePath({ databasePath, profile: 'edge' });
|
||||
const runtime = await openLocalSqliteRuntimeDatabase({
|
||||
databasePath,
|
||||
profile: 'edge',
|
||||
});
|
||||
t.after(async () => {
|
||||
await runtime.close();
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
return runtime;
|
||||
}
|
||||
|
||||
async function insertActive(runtime, options) {
|
||||
await runtime.runRepository.transaction(async (transaction) => {
|
||||
await transaction.insertRun({
|
||||
id: options.runId,
|
||||
projectId: 'default',
|
||||
taskId: 'task-1',
|
||||
taskRevision: 'revision-1',
|
||||
triggerType: 'manual',
|
||||
executionOrigin: 'manual',
|
||||
executionOwner: 'runtime',
|
||||
status: options.runStatus || 'running',
|
||||
version: 0,
|
||||
eventSequence: 0,
|
||||
priority: 0,
|
||||
createdAtMs: 1,
|
||||
startedAtMs: 2,
|
||||
...(options.cancelRequestedAtMs === undefined
|
||||
? {}
|
||||
: {
|
||||
cancelRequestedAtMs: options.cancelRequestedAtMs,
|
||||
cancelReason: options.cancelReason,
|
||||
}),
|
||||
});
|
||||
await transaction.insertAttempt({
|
||||
id: options.attemptId,
|
||||
runId: options.runId,
|
||||
attempt: 1,
|
||||
status: options.attemptStatus || 'running',
|
||||
executorType: 'local_process',
|
||||
executorHandle: `handle:${options.attemptId}`,
|
||||
pid: 1234,
|
||||
callbackTokenHash: createHash('sha256').update(TOKEN).digest('hex'),
|
||||
callbackSequence: options.callbackSequence || 0,
|
||||
createdAtMs: 1,
|
||||
startedAtMs: 2,
|
||||
...(options.deadlineAtMs === undefined
|
||||
? {}
|
||||
: { deadlineAtMs: options.deadlineAtMs }),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('authenticates one receipt and commits Attempt then Run terminal facts', async (t) => {
|
||||
const runtime = await fixture(t);
|
||||
const store = receiptStore();
|
||||
await insertActive(runtime, {
|
||||
runId: IDS.completionRun,
|
||||
attemptId: IDS.completionAttempt,
|
||||
});
|
||||
store.receipts.set(IDS.completionAttempt, {
|
||||
schemaVersion: 1,
|
||||
runId: IDS.completionRun,
|
||||
attemptId: IDS.completionAttempt,
|
||||
callbackSequence: 1,
|
||||
token: TOKEN,
|
||||
startedAtMs: 2,
|
||||
finishedAtMs: 10,
|
||||
exitCode: 0,
|
||||
});
|
||||
const processor = new LocalCompletionReceiptProcessor(
|
||||
runtime.runRepository,
|
||||
store,
|
||||
{ clock: { now: () => 10 }, createEventId: eventIds() },
|
||||
);
|
||||
assert.equal(await processor.process(IDS.completionAttempt), 'completed');
|
||||
assert.equal(
|
||||
(await runtime.runRepository.findRunById(IDS.completionRun)).status,
|
||||
'succeeded',
|
||||
);
|
||||
assert.equal(
|
||||
(await runtime.runRepository.findAttemptById(IDS.completionAttempt)).status,
|
||||
'succeeded',
|
||||
);
|
||||
assert.deepEqual(
|
||||
(await runtime.runRepository.listEvents(IDS.completionRun)).map(
|
||||
(event) => event.type,
|
||||
),
|
||||
['attempt.succeeded', 'run.succeeded'],
|
||||
);
|
||||
assert.deepEqual(store.removed, [IDS.completionAttempt]);
|
||||
});
|
||||
|
||||
test('discovers due deadlines and cancellation intents through stable SQLite pages', async (t) => {
|
||||
const runtime = await fixture(t);
|
||||
await insertActive(runtime, {
|
||||
runId: IDS.deadlineRun,
|
||||
attemptId: IDS.deadlineAttempt,
|
||||
deadlineAtMs: 50,
|
||||
});
|
||||
await insertActive(runtime, {
|
||||
runId: IDS.cancelRun,
|
||||
attemptId: IDS.cancelAttempt,
|
||||
cancelRequestedAtMs: 40,
|
||||
cancelReason: 'user',
|
||||
deadlineAtMs: 30,
|
||||
});
|
||||
const first =
|
||||
await runtime.executionControl.listLocalExecutionControlCandidates({
|
||||
observedAtMs: 100,
|
||||
limit: 1,
|
||||
});
|
||||
assert.equal(first.truncated, true);
|
||||
assert.deepEqual(first.candidates[0], {
|
||||
kind: 'cancellation',
|
||||
runId: IDS.cancelRun,
|
||||
attemptId: IDS.cancelAttempt,
|
||||
dueAtMs: 40,
|
||||
cancelReason: 'user',
|
||||
});
|
||||
const second =
|
||||
await runtime.executionControl.listLocalExecutionControlCandidates({
|
||||
observedAtMs: 100,
|
||||
limit: 1,
|
||||
after: first.nextCursor,
|
||||
});
|
||||
assert.equal(second.truncated, false);
|
||||
assert.deepEqual(second.candidates[0], {
|
||||
kind: 'deadline',
|
||||
runId: IDS.deadlineRun,
|
||||
attemptId: IDS.deadlineAttempt,
|
||||
dueAtMs: 50,
|
||||
});
|
||||
});
|
||||
|
||||
test('turns deadline and user cancellation into exact terminal aggregates', async (t) => {
|
||||
const runtime = await fixture(t);
|
||||
await insertActive(runtime, {
|
||||
runId: IDS.deadlineRun,
|
||||
attemptId: IDS.deadlineAttempt,
|
||||
deadlineAtMs: 50,
|
||||
});
|
||||
await insertActive(runtime, {
|
||||
runId: IDS.cancelRun,
|
||||
attemptId: IDS.cancelAttempt,
|
||||
cancelRequestedAtMs: 40,
|
||||
cancelReason: 'user',
|
||||
});
|
||||
const stopped = [];
|
||||
const store = receiptStore();
|
||||
const completion = new LocalCompletionReceiptProcessor(
|
||||
runtime.runRepository,
|
||||
store,
|
||||
{ clock: { now: () => 100 }, createEventId: eventIds() },
|
||||
);
|
||||
const coordinator = new LocalExecutionControlCoordinator(
|
||||
runtime.runRepository,
|
||||
completion,
|
||||
{
|
||||
async stop(handle) {
|
||||
stopped.push(handle);
|
||||
return { status: 'stopped', signal: 'SIGTERM' };
|
||||
},
|
||||
},
|
||||
{ clock: { now: () => 100 }, createEventId: eventIds() },
|
||||
);
|
||||
const scanner = new LocalExecutionControlScanner(
|
||||
runtime.executionControl,
|
||||
coordinator,
|
||||
{ now: () => 100 },
|
||||
);
|
||||
const summary = await scanner.scan({ limit: 8 });
|
||||
assert.equal(summary.scanned, 2);
|
||||
assert.equal(summary.terminal, 2);
|
||||
assert.equal(
|
||||
(await runtime.runRepository.findRunById(IDS.deadlineRun)).status,
|
||||
'timed_out',
|
||||
);
|
||||
assert.equal(
|
||||
(await runtime.runRepository.findRunById(IDS.cancelRun)).status,
|
||||
'cancelled',
|
||||
);
|
||||
assert.equal(stopped.length, 2);
|
||||
});
|
||||
|
||||
test('routes a Workflow Task deadline to Step scope without cancelling its parent', async () => {
|
||||
const run = {
|
||||
id: 'workflow-run',
|
||||
projectId: 'default',
|
||||
taskId: 'workflow',
|
||||
taskRevision: 'revision-1',
|
||||
triggerType: 'system',
|
||||
executionOrigin: 'system',
|
||||
executionOwner: 'runtime',
|
||||
status: 'running',
|
||||
version: 7,
|
||||
eventSequence: 7,
|
||||
priority: 0,
|
||||
createdAtMs: 1,
|
||||
startedAtMs: 2,
|
||||
};
|
||||
const attempt = {
|
||||
id: 'workflow-attempt',
|
||||
runId: run.id,
|
||||
stepRunId: 'workflow-step',
|
||||
attempt: 1,
|
||||
status: 'running',
|
||||
executorType: 'local_process',
|
||||
executorHandle: 'workflow-handle',
|
||||
pid: 321,
|
||||
callbackTokenHash: 'a'.repeat(64),
|
||||
callbackSequence: 0,
|
||||
deadlineAtMs: 50,
|
||||
createdAtMs: 1,
|
||||
startedAtMs: 2,
|
||||
};
|
||||
let latestReads = 0;
|
||||
const repository = {
|
||||
transaction(work) {
|
||||
return work({
|
||||
findRunById: async (runId) => (runId === run.id ? run : null),
|
||||
findAttemptById: async (attemptId) =>
|
||||
attemptId === attempt.id ? attempt : null,
|
||||
findLatestAttemptByRunId: async () => {
|
||||
latestReads += 1;
|
||||
return attempt;
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
const workflowCalls = [];
|
||||
const coordinator = new LocalExecutionControlCoordinator(
|
||||
repository,
|
||||
{ process: async () => 'missing' },
|
||||
{ stop: async () => ({ status: 'stopped', signal: 'SIGTERM' }) },
|
||||
{
|
||||
clock: { now: () => 100 },
|
||||
createEventId: eventIds(),
|
||||
workflowTasks: {
|
||||
async requestTimeout(command) {
|
||||
workflowCalls.push(['timeout', command]);
|
||||
return 'requested';
|
||||
},
|
||||
async recordControlTerminal(command) {
|
||||
workflowCalls.push(['terminal', command]);
|
||||
return 'terminal';
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
await coordinator.process({
|
||||
kind: 'deadline',
|
||||
runId: run.id,
|
||||
attemptId: attempt.id,
|
||||
dueAtMs: 50,
|
||||
}),
|
||||
'terminal',
|
||||
);
|
||||
assert.equal(latestReads, 0);
|
||||
assert.equal(workflowCalls[0][0], 'timeout');
|
||||
assert.equal(workflowCalls[1][0], 'terminal');
|
||||
assert.equal(workflowCalls[1][1].terminalStatus, 'timed_out');
|
||||
assert.equal(run.cancelRequestedAtMs, undefined);
|
||||
});
|
||||
|
||||
test('shutdown drain requests shutdown cancellation before stopping work', async (t) => {
|
||||
const runtime = await fixture(t);
|
||||
await insertActive(runtime, {
|
||||
runId: IDS.shutdownRun,
|
||||
attemptId: IDS.shutdownAttempt,
|
||||
});
|
||||
const completion = new LocalCompletionReceiptProcessor(
|
||||
runtime.runRepository,
|
||||
receiptStore(),
|
||||
{ clock: { now: () => 100 }, createEventId: eventIds() },
|
||||
);
|
||||
const coordinator = new LocalExecutionControlCoordinator(
|
||||
runtime.runRepository,
|
||||
completion,
|
||||
{ stop: async () => ({ status: 'already_exited' }) },
|
||||
{ clock: { now: () => 100 }, createEventId: eventIds() },
|
||||
);
|
||||
const scanner = new LocalExecutionControlScanner(
|
||||
runtime.executionControl,
|
||||
coordinator,
|
||||
{ now: () => 100 },
|
||||
);
|
||||
const summary = await scanner.drain({ limit: 4, maxPages: 2 });
|
||||
assert.deepEqual(summary, {
|
||||
scanned: 1,
|
||||
terminal: 1,
|
||||
remaining: 0,
|
||||
failed: 0,
|
||||
truncated: false,
|
||||
});
|
||||
const run = await runtime.runRepository.findRunById(IDS.shutdownRun);
|
||||
assert.equal(run.status, 'cancelled');
|
||||
assert.equal(run.cancelReason, 'shutdown');
|
||||
assert.deepEqual(
|
||||
(await runtime.runRepository.listEvents(IDS.shutdownRun)).map(
|
||||
(event) => event.type,
|
||||
),
|
||||
['run.cancel_requested', 'attempt.cancelled', 'run.cancelled'],
|
||||
);
|
||||
});
|
||||
|
||||
test('coalesces completion notifications and owns one idempotent shutdown drain', async () => {
|
||||
const completions = [];
|
||||
let scans = 0;
|
||||
let drains = 0;
|
||||
let cleanups = 0;
|
||||
const lifecycle = new LocalExecutionControlLifecycle(
|
||||
{
|
||||
async process(attemptId) {
|
||||
completions.push(attemptId);
|
||||
return 'missing';
|
||||
},
|
||||
},
|
||||
{
|
||||
async scan() {
|
||||
scans += 1;
|
||||
return {
|
||||
scanned: 0,
|
||||
terminal: 0,
|
||||
cancelRequested: 0,
|
||||
stale: 0,
|
||||
remaining: 0,
|
||||
failed: 0,
|
||||
truncated: false,
|
||||
};
|
||||
},
|
||||
async drain() {
|
||||
drains += 1;
|
||||
return {
|
||||
scanned: 0,
|
||||
terminal: 0,
|
||||
remaining: 0,
|
||||
failed: 0,
|
||||
truncated: false,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
async scan() {
|
||||
cleanups += 1;
|
||||
return {
|
||||
scanned: 0,
|
||||
removed: 0,
|
||||
expiredMissing: 0,
|
||||
purgedQuarantines: 0,
|
||||
remaining: 0,
|
||||
failed: 0,
|
||||
truncated: false,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
intervalMs: 60_000,
|
||||
pageSize: 4,
|
||||
cleanupIntervalMs: 60_000,
|
||||
cleanupPageSize: 4,
|
||||
stopTimeoutMs: 1_000,
|
||||
maxDrainPages: 1,
|
||||
clock: { now: () => 100 },
|
||||
},
|
||||
);
|
||||
assert.equal(lifecycle.notifyCompletion(IDS.completionAttempt), true);
|
||||
assert.equal(lifecycle.notifyCompletion(IDS.completionAttempt), true);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(completions, [IDS.completionAttempt]);
|
||||
assert.equal(scans, 1);
|
||||
assert.equal(cleanups, 1);
|
||||
const first = lifecycle.stopAndDrain();
|
||||
const second = lifecycle.stopAndDrain();
|
||||
assert.equal(first, second);
|
||||
assert.equal((await first).status, 'stopped');
|
||||
assert.equal(drains, 1);
|
||||
assert.equal(cleanups, 2);
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
createLocalExecutionContextRecipe,
|
||||
createLocalTaskExecutionRevision,
|
||||
} = require('@qinglong/runtime-core/local-dispatch');
|
||||
const {
|
||||
migrateLocalSqlitePath,
|
||||
openLocalSqliteRuntimeDatabase,
|
||||
} = require('@qinglong/local-sqlite');
|
||||
const { LocalExecutionCoordinator } = require('../dist/execution');
|
||||
const { createLocalProcessDurableHandle } = require('@qinglong/local-process');
|
||||
const {
|
||||
LocalArtifactCapacityUnavailableError,
|
||||
LocalDispatchPlanMaterializer,
|
||||
LocalFileArtifactAllocator,
|
||||
LocalRunDispatcher,
|
||||
localArtifactCapacityPolicyForProfile,
|
||||
} = require('../dist/dispatch');
|
||||
|
||||
const RUN_ID = '019f7120-0000-7000-8000-000000000001';
|
||||
const ATTEMPT_ID = '019f7120-0000-7000-8000-000000000002';
|
||||
|
||||
function eventIdFactory() {
|
||||
let value = 0;
|
||||
return () => `dispatch-event-${++value}`;
|
||||
}
|
||||
|
||||
function processHandle() {
|
||||
const identity = {
|
||||
platform: 'linux',
|
||||
bootId: '11111111-2222-3333-4444-555555555555',
|
||||
pid: 4321,
|
||||
processGroupId: 4321,
|
||||
startTimeTicks: '123456',
|
||||
};
|
||||
return Object.freeze({
|
||||
handleId: 'dispatch-handle',
|
||||
pid: 4321,
|
||||
durableHandle: createLocalProcessDurableHandle('dispatch-handle', identity),
|
||||
startedAtMs: 10,
|
||||
completion: Promise.resolve({ exitCode: 0, signal: null }),
|
||||
});
|
||||
}
|
||||
|
||||
async function fixture(t) {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-dispatch-'));
|
||||
const databasePath = path.join(directory, 'qinglong3.sqlite');
|
||||
const artifactRoot = path.join(directory, 'artifacts');
|
||||
await migrateLocalSqlitePath({ databasePath, profile: 'edge' });
|
||||
const runtime = await openLocalSqliteRuntimeDatabase({
|
||||
databasePath,
|
||||
profile: 'edge',
|
||||
});
|
||||
t.after(async () => {
|
||||
await runtime.close();
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
const recipe = createLocalExecutionContextRecipe({
|
||||
environment: [
|
||||
{ name: 'PUBLIC_VALUE', kind: 'public', value: 'public-value' },
|
||||
{ name: 'SECRET_VALUE', kind: 'secret', secretRef: 'secret-ref-1' },
|
||||
],
|
||||
createdAtMs: 1,
|
||||
});
|
||||
assert.equal(
|
||||
await runtime.localDispatch.appendLocalExecutionContextRecipe(recipe),
|
||||
'inserted',
|
||||
);
|
||||
assert.equal(
|
||||
await runtime.localDispatch.appendLocalExecutionContextRecipe(recipe),
|
||||
'existing',
|
||||
);
|
||||
const revision = createLocalTaskExecutionRevision({
|
||||
projectId: 'default',
|
||||
taskId: 'task-1',
|
||||
taskRevision: 'revision-1',
|
||||
executorType: 'local_process',
|
||||
command: { kind: 'argv', file: '/bin/echo', args: ['hello'] },
|
||||
timeoutMs: 1_000,
|
||||
contextRef: recipe.contextRef,
|
||||
createdAtMs: 1,
|
||||
});
|
||||
assert.equal(
|
||||
await runtime.localDispatch.appendLocalTaskExecutionRevision(revision),
|
||||
'inserted',
|
||||
);
|
||||
assert.equal(
|
||||
await runtime.localDispatch.appendLocalTaskExecutionRevision(revision),
|
||||
'existing',
|
||||
);
|
||||
await runtime.runRepository.transaction(async (transaction) => {
|
||||
await transaction.insertRun({
|
||||
id: RUN_ID,
|
||||
projectId: 'default',
|
||||
taskId: 'task-1',
|
||||
taskRevision: 'revision-1',
|
||||
triggerType: 'manual',
|
||||
executionOrigin: 'manual',
|
||||
executionOwner: 'runtime',
|
||||
status: 'queued',
|
||||
version: 0,
|
||||
eventSequence: 0,
|
||||
priority: 10,
|
||||
createdAtMs: 1,
|
||||
queuedAtMs: 1,
|
||||
});
|
||||
await transaction.insertAttempt({
|
||||
id: ATTEMPT_ID,
|
||||
runId: RUN_ID,
|
||||
attempt: 1,
|
||||
status: 'claimed',
|
||||
executorType: 'local_process',
|
||||
callbackSequence: 0,
|
||||
createdAtMs: 1,
|
||||
});
|
||||
});
|
||||
return { directory, databasePath, artifactRoot, runtime };
|
||||
}
|
||||
|
||||
test('materializes pinned context and atomically admits one real SQLite candidate', async (t) => {
|
||||
const value = await fixture(t);
|
||||
let launchRequest;
|
||||
const completionNotifications = [];
|
||||
const execution = new LocalExecutionCoordinator(
|
||||
value.runtime.runRepository,
|
||||
{
|
||||
async start(request) {
|
||||
launchRequest = request;
|
||||
assert.equal(request.environment.PUBLIC_VALUE, 'public-value');
|
||||
assert.equal(request.environment.SECRET_VALUE, 'top-secret');
|
||||
assert.equal(request.output.maximumBytes, 4 * 1024 * 1024);
|
||||
assert.match(request.output.logArtifactId, /^local-[0-9a-f]{30}$/);
|
||||
return processHandle();
|
||||
},
|
||||
},
|
||||
{ stop: async () => assert.fail('valid launch must not be stopped') },
|
||||
{
|
||||
clock: { now: () => 10 },
|
||||
createEventId: eventIdFactory(),
|
||||
createCallbackToken: () => 'A'.repeat(32),
|
||||
},
|
||||
);
|
||||
const dispatcher = new LocalRunDispatcher(
|
||||
value.runtime.localDispatch,
|
||||
new LocalDispatchPlanMaterializer(
|
||||
value.runtime.localDispatch,
|
||||
new LocalFileArtifactAllocator(
|
||||
value.artifactRoot,
|
||||
localArtifactCapacityPolicyForProfile('edge'),
|
||||
),
|
||||
{
|
||||
async resolveLocalSecretEnvironment(request) {
|
||||
assert.deepEqual(request.secretRefs, ['secret-ref-1']);
|
||||
assert.equal(request.candidate.projectId, 'default');
|
||||
return ['top-secret'];
|
||||
},
|
||||
},
|
||||
),
|
||||
execution,
|
||||
{
|
||||
pageSize: 4,
|
||||
maxPages: 1,
|
||||
onCompletion: (attemptId) => completionNotifications.push(attemptId),
|
||||
},
|
||||
);
|
||||
|
||||
const result = await dispatcher.dispatchOnce();
|
||||
assert.equal(result.status, 'activated');
|
||||
assert.equal(result.runId, RUN_ID);
|
||||
assert.equal(result.attemptId, ATTEMPT_ID);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(completionNotifications, [ATTEMPT_ID]);
|
||||
assert.equal(result.stats.candidatesScanned, 1);
|
||||
assert.equal(
|
||||
(await value.runtime.runRepository.findRunById(RUN_ID)).status,
|
||||
'running',
|
||||
);
|
||||
const attempt = await value.runtime.runRepository.findAttemptById(ATTEMPT_ID);
|
||||
assert.equal(attempt.status, 'running');
|
||||
assert.equal(attempt.logArtifactId, launchRequest.output.logArtifactId);
|
||||
assert.equal(fs.statSync(launchRequest.output.filePath).mode & 0o777, 0o600);
|
||||
assert.equal(fs.statSync(launchRequest.output.filePath).size, 0);
|
||||
assert.equal(
|
||||
fs.readFileSync(value.databasePath).includes(Buffer.from('top-secret')),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('missing Secret capability returns unavailable before Artifact allocation', async (t) => {
|
||||
const value = await fixture(t);
|
||||
const dispatcher = new LocalRunDispatcher(
|
||||
value.runtime.localDispatch,
|
||||
new LocalDispatchPlanMaterializer(
|
||||
value.runtime.localDispatch,
|
||||
new LocalFileArtifactAllocator(
|
||||
value.artifactRoot,
|
||||
localArtifactCapacityPolicyForProfile('edge'),
|
||||
),
|
||||
),
|
||||
{ start: async () => assert.fail('unavailable plan must not activate') },
|
||||
);
|
||||
const result = await dispatcher.dispatchOnce();
|
||||
assert.equal(result.status, 'idle');
|
||||
assert.equal(result.reason, 'plans_unavailable');
|
||||
assert.equal(result.stats.plansUnavailable, 1);
|
||||
assert.equal(fs.existsSync(value.artifactRoot), false);
|
||||
assert.equal(
|
||||
(await value.runtime.runRepository.findRunById(RUN_ID)).status,
|
||||
'queued',
|
||||
);
|
||||
});
|
||||
|
||||
test('capacity admission fails before creating an Artifact file', async (t) => {
|
||||
const value = await fixture(t);
|
||||
const [candidate] = (
|
||||
await value.runtime.localDispatch.listLocalDispatchCandidates({ limit: 1 })
|
||||
).candidates;
|
||||
const allocator = new LocalFileArtifactAllocator(
|
||||
value.artifactRoot,
|
||||
localArtifactCapacityPolicyForProfile('edge'),
|
||||
{ inspect: async () => 0n },
|
||||
);
|
||||
await assert.rejects(
|
||||
allocator.prepare(candidate),
|
||||
LocalArtifactCapacityUnavailableError,
|
||||
);
|
||||
assert.deepEqual(fs.readdirSync(value.artifactRoot), []);
|
||||
});
|
||||
@@ -0,0 +1,264 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { createHash } = require('node:crypto');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
migrateLocalSqlitePath,
|
||||
openLocalSqliteRuntimeDatabase,
|
||||
} = require('@qinglong/local-sqlite');
|
||||
const {
|
||||
LocalExecutionCoordinator,
|
||||
LocalExecutionLaunchError,
|
||||
LocalExecutionOwnershipPersistenceError,
|
||||
LocalExecutionRejectedError,
|
||||
} = require('../dist/execution');
|
||||
const { createLocalProcessDurableHandle } = require('@qinglong/local-process');
|
||||
|
||||
const RUN_ID = '019f70d0-0000-7000-8000-000000000001';
|
||||
const ATTEMPT_ID = '019f70d0-0000-7000-8000-000000000002';
|
||||
const CALLBACK_TOKEN = 'A'.repeat(32);
|
||||
|
||||
async function fixture(t) {
|
||||
const directory = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'ql3-local-execution-'),
|
||||
);
|
||||
const options = {
|
||||
databasePath: path.join(directory, 'qinglong3.sqlite'),
|
||||
profile: 'edge',
|
||||
};
|
||||
await migrateLocalSqlitePath(options);
|
||||
const runtime = await openLocalSqliteRuntimeDatabase(options);
|
||||
t.after(async () => {
|
||||
await runtime.close();
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
await runtime.runRepository.transaction(async (transaction) => {
|
||||
await transaction.insertRun({
|
||||
id: RUN_ID,
|
||||
projectId: 'default',
|
||||
taskId: 'task-1',
|
||||
taskRevision: 'revision-1',
|
||||
triggerType: 'manual',
|
||||
executionOrigin: 'manual',
|
||||
executionOwner: 'runtime',
|
||||
status: 'queued',
|
||||
version: 0,
|
||||
eventSequence: 0,
|
||||
priority: 0,
|
||||
createdAtMs: 1,
|
||||
queuedAtMs: 1,
|
||||
});
|
||||
await transaction.insertAttempt({
|
||||
id: ATTEMPT_ID,
|
||||
runId: RUN_ID,
|
||||
attempt: 1,
|
||||
status: 'claimed',
|
||||
executorType: 'local_process',
|
||||
callbackSequence: 0,
|
||||
createdAtMs: 1,
|
||||
});
|
||||
});
|
||||
return runtime.runRepository;
|
||||
}
|
||||
|
||||
function eventIdFactory() {
|
||||
let value = 0;
|
||||
return () => `event-${++value}`;
|
||||
}
|
||||
|
||||
function handle() {
|
||||
const identity = {
|
||||
platform: 'linux',
|
||||
bootId: '11111111-2222-3333-4444-555555555555',
|
||||
pid: 1234,
|
||||
processGroupId: 1234,
|
||||
startTimeTicks: '987654',
|
||||
};
|
||||
return Object.freeze({
|
||||
handleId: 'handle-1',
|
||||
pid: 1234,
|
||||
durableHandle: createLocalProcessDurableHandle('handle-1', identity),
|
||||
startedAtMs: 10,
|
||||
completion: Promise.resolve({ exitCode: 0, signal: null }),
|
||||
});
|
||||
}
|
||||
|
||||
function command() {
|
||||
return {
|
||||
runId: RUN_ID,
|
||||
attemptId: ATTEMPT_ID,
|
||||
command: { kind: 'argv', file: '/bin/echo', args: ['hello'] },
|
||||
timeoutMs: 1_000,
|
||||
};
|
||||
}
|
||||
|
||||
function repositoryFailingTransaction(repository, transactionNumber) {
|
||||
let transactions = 0;
|
||||
return {
|
||||
findRunById: (...args) => repository.findRunById(...args),
|
||||
findAttemptById: (...args) => repository.findAttemptById(...args),
|
||||
findLatestAttemptByRunId: (...args) =>
|
||||
repository.findLatestAttemptByRunId(...args),
|
||||
findRetryPolicyByRunId: (...args) =>
|
||||
repository.findRetryPolicyByRunId(...args),
|
||||
listEvents: (...args) => repository.listEvents(...args),
|
||||
listCancellationRequested: (...args) =>
|
||||
repository.listCancellationRequested(...args),
|
||||
transaction(work) {
|
||||
transactions += 1;
|
||||
if (transactions === transactionNumber) {
|
||||
return Promise.reject(new Error('injected transaction failure'));
|
||||
}
|
||||
return repository.transaction(work);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('atomically persists claimed -> starting -> running around launch', async (t) => {
|
||||
const repository = await fixture(t);
|
||||
let launchRequest;
|
||||
let clockReads = 0;
|
||||
const coordinator = new LocalExecutionCoordinator(
|
||||
repository,
|
||||
{
|
||||
async start(request) {
|
||||
launchRequest = request;
|
||||
const [run, attempt] = await Promise.all([
|
||||
repository.findRunById(RUN_ID),
|
||||
repository.findAttemptById(ATTEMPT_ID),
|
||||
]);
|
||||
assert.equal(run.status, 'dispatching');
|
||||
assert.equal(run.version, 2);
|
||||
assert.equal(attempt.status, 'starting');
|
||||
assert.equal(
|
||||
attempt.callbackTokenHash,
|
||||
createHash('sha256').update(CALLBACK_TOKEN).digest('hex'),
|
||||
);
|
||||
return handle();
|
||||
},
|
||||
},
|
||||
{
|
||||
stop: async () => assert.fail('controller must not stop a valid launch'),
|
||||
},
|
||||
{
|
||||
clock: { now: () => (clockReads++ === 0 ? 5 : 20) },
|
||||
createEventId: eventIdFactory(),
|
||||
createCallbackToken: () => CALLBACK_TOKEN,
|
||||
},
|
||||
);
|
||||
|
||||
const result = await coordinator.start(command());
|
||||
assert.equal(launchRequest.callbackToken, CALLBACK_TOKEN);
|
||||
assert.equal(launchRequest.callbackSequence, 1);
|
||||
assert.equal(result.run.status, 'running');
|
||||
assert.equal(result.run.version, 4);
|
||||
assert.equal(result.attempt.status, 'running');
|
||||
assert.equal(result.attempt.executorHandle, handle().durableHandle);
|
||||
assert.equal(result.attempt.pid, 1234);
|
||||
assert.equal(result.attempt.startedAtMs, handle().startedAtMs);
|
||||
assert.equal(result.attempt.callbackSequence, 0);
|
||||
assert.equal('callbackToken' in result, false);
|
||||
assert.deepEqual(
|
||||
(await repository.listEvents(RUN_ID)).map((event) => event.type),
|
||||
['run.dispatching', 'attempt.starting', 'attempt.running', 'run.running'],
|
||||
);
|
||||
});
|
||||
|
||||
test('records a pre-ownership launch failure as failed', async (t) => {
|
||||
const repository = await fixture(t);
|
||||
const coordinator = new LocalExecutionCoordinator(
|
||||
repository,
|
||||
{ start: async () => Promise.reject(new Error('spawn failed')) },
|
||||
{ stop: async () => assert.fail('no durable handle exists') },
|
||||
{
|
||||
clock: { now: () => 10 },
|
||||
createEventId: eventIdFactory(),
|
||||
createCallbackToken: () => CALLBACK_TOKEN,
|
||||
},
|
||||
);
|
||||
await assert.rejects(coordinator.start(command()), LocalExecutionLaunchError);
|
||||
assert.equal((await repository.findRunById(RUN_ID)).status, 'failed');
|
||||
assert.equal(
|
||||
(await repository.findAttemptById(ATTEMPT_ID)).errorCode,
|
||||
'EXECUTOR_START_FAILED',
|
||||
);
|
||||
});
|
||||
|
||||
test('stops exact process then records lost when running persistence fails', async (t) => {
|
||||
const repository = await fixture(t);
|
||||
const failing = repositoryFailingTransaction(repository, 2);
|
||||
const stopped = [];
|
||||
const coordinator = new LocalExecutionCoordinator(
|
||||
failing,
|
||||
{ start: async () => handle() },
|
||||
{
|
||||
async stop(durableHandle) {
|
||||
stopped.push(durableHandle);
|
||||
return { status: 'stopped', signal: 'SIGTERM' };
|
||||
},
|
||||
},
|
||||
{
|
||||
clock: { now: () => 10 },
|
||||
createEventId: eventIdFactory(),
|
||||
createCallbackToken: () => CALLBACK_TOKEN,
|
||||
},
|
||||
);
|
||||
await assert.rejects(coordinator.start(command()), (error) => {
|
||||
assert.ok(error instanceof LocalExecutionOwnershipPersistenceError);
|
||||
assert.equal(error.compensation.status, 'stopped');
|
||||
return true;
|
||||
});
|
||||
assert.deepEqual(stopped, [handle().durableHandle]);
|
||||
assert.equal((await repository.findRunById(RUN_ID)).status, 'lost');
|
||||
assert.equal((await repository.findAttemptById(ATTEMPT_ID)).status, 'lost');
|
||||
});
|
||||
|
||||
test('keeps starting authority for recovery when exact stop is inconclusive', async (t) => {
|
||||
const repository = await fixture(t);
|
||||
const failing = repositoryFailingTransaction(repository, 2);
|
||||
const coordinator = new LocalExecutionCoordinator(
|
||||
failing,
|
||||
{ start: async () => handle() },
|
||||
{
|
||||
stop: async () => ({
|
||||
status: 'unknown',
|
||||
reason: 'provider_unavailable',
|
||||
}),
|
||||
},
|
||||
{
|
||||
clock: { now: () => 10 },
|
||||
createEventId: eventIdFactory(),
|
||||
createCallbackToken: () => CALLBACK_TOKEN,
|
||||
},
|
||||
);
|
||||
await assert.rejects(
|
||||
coordinator.start(command()),
|
||||
LocalExecutionOwnershipPersistenceError,
|
||||
);
|
||||
assert.equal((await repository.findRunById(RUN_ID)).status, 'dispatching');
|
||||
assert.equal(
|
||||
(await repository.findAttemptById(ATTEMPT_ID)).status,
|
||||
'starting',
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects replay after execution authority has moved', async (t) => {
|
||||
const repository = await fixture(t);
|
||||
const coordinator = new LocalExecutionCoordinator(
|
||||
repository,
|
||||
{ start: async () => handle() },
|
||||
{ stop: async () => ({ status: 'already_exited' }) },
|
||||
{
|
||||
clock: { now: () => 10 },
|
||||
createEventId: eventIdFactory(),
|
||||
createCallbackToken: () => CALLBACK_TOKEN,
|
||||
},
|
||||
);
|
||||
await coordinator.start(command());
|
||||
await assert.rejects(
|
||||
coordinator.start(command()),
|
||||
LocalExecutionRejectedError,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,554 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { createHash } = require('node:crypto');
|
||||
const { test } = require('node:test');
|
||||
const { InvalidCompletionReceiptError } = require('@qinglong/local-process');
|
||||
const {
|
||||
LocalRunStartupRecoveryCoordinator,
|
||||
LocalWorkflowTaskStartupRecoveryCoordinator,
|
||||
} = require('../dist/recovery');
|
||||
|
||||
const RUN_ID = '019f70c0-0000-7000-8000-000000000001';
|
||||
const ATTEMPT_ID = '019f70c0-0000-7000-8000-000000000002';
|
||||
const TOKEN = 'A'.repeat(32);
|
||||
|
||||
function run(overrides = {}) {
|
||||
return {
|
||||
id: RUN_ID,
|
||||
projectId: 'default',
|
||||
taskId: 'task-1',
|
||||
taskRevision: 'revision-1',
|
||||
triggerType: 'manual',
|
||||
executionOrigin: 'manual',
|
||||
executionOwner: 'runtime',
|
||||
status: 'dispatching',
|
||||
version: 0,
|
||||
eventSequence: 0,
|
||||
priority: 0,
|
||||
createdAtMs: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function attempt(overrides = {}) {
|
||||
return {
|
||||
id: ATTEMPT_ID,
|
||||
runId: RUN_ID,
|
||||
attempt: 1,
|
||||
status: 'claimed',
|
||||
executorType: 'local_process',
|
||||
callbackSequence: 0,
|
||||
createdAtMs: 2,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
class MemoryRepository {
|
||||
constructor(initialRun, initialAttempt) {
|
||||
this.runs = new Map(initialRun ? [[initialRun.id, initialRun]] : []);
|
||||
this.attempts = new Map(
|
||||
initialAttempt ? [[initialAttempt.id, initialAttempt]] : [],
|
||||
);
|
||||
this.events = [];
|
||||
}
|
||||
|
||||
async transaction(work) {
|
||||
const previousRuns = structuredClone(this.runs);
|
||||
const previousAttempts = structuredClone(this.attempts);
|
||||
const previousEvents = structuredClone(this.events);
|
||||
const transaction = {
|
||||
findRunById: async (id) => structuredClone(this.runs.get(id) ?? null),
|
||||
findAttemptById: async (id) =>
|
||||
structuredClone(this.attempts.get(id) ?? null),
|
||||
findLatestAttemptByRunId: async (runId) => {
|
||||
const values = [...this.attempts.values()]
|
||||
.filter((item) => item.runId === runId)
|
||||
.sort((left, right) => right.attempt - left.attempt);
|
||||
return structuredClone(values[0] ?? null);
|
||||
},
|
||||
compareAndSetRun: async (value, expectedVersion) => {
|
||||
const current = this.runs.get(value.id);
|
||||
if (!current || current.version !== expectedVersion) return false;
|
||||
this.runs.set(value.id, structuredClone(value));
|
||||
return true;
|
||||
},
|
||||
compareAndSetAttempt: async (value, expected) => {
|
||||
const current = this.attempts.get(value.id);
|
||||
if (
|
||||
!current ||
|
||||
current.status !== expected.status ||
|
||||
current.callbackSequence !== expected.callbackSequence
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
this.attempts.set(value.id, structuredClone(value));
|
||||
return true;
|
||||
},
|
||||
appendEvent: async (value) => this.events.push(structuredClone(value)),
|
||||
};
|
||||
try {
|
||||
return await work(transaction);
|
||||
} catch (error) {
|
||||
this.runs = previousRuns;
|
||||
this.attempts = previousAttempts;
|
||||
this.events = previousEvents;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function source(repository, overrides = {}) {
|
||||
let calls = 0;
|
||||
return {
|
||||
get calls() {
|
||||
return calls;
|
||||
},
|
||||
async inspectCandidates() {
|
||||
calls += 1;
|
||||
if (overrides.page) return overrides.page;
|
||||
const candidates = [];
|
||||
for (const value of [...repository.runs.values()].sort((a, b) =>
|
||||
a.id.localeCompare(b.id),
|
||||
)) {
|
||||
if (
|
||||
value.executionOwner !== 'runtime' ||
|
||||
!['dispatching', 'running'].includes(value.status)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
candidates.push({
|
||||
runId: value.id,
|
||||
runStatus: value.status,
|
||||
activeAttemptCount: [...repository.attempts.values()].filter(
|
||||
(item) =>
|
||||
item.runId === value.id &&
|
||||
['claimed', 'starting', 'running'].includes(item.status),
|
||||
).length,
|
||||
});
|
||||
}
|
||||
return { candidates, truncated: false };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function receipts(value) {
|
||||
let readCount = 0;
|
||||
let removed = 0;
|
||||
return {
|
||||
get readCount() {
|
||||
return readCount;
|
||||
},
|
||||
get removed() {
|
||||
return removed;
|
||||
},
|
||||
async read() {
|
||||
readCount += 1;
|
||||
return value;
|
||||
},
|
||||
async remove() {
|
||||
removed += 1;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function coordinator(
|
||||
repository,
|
||||
candidateSource,
|
||||
receiptStore,
|
||||
inspect,
|
||||
options = {},
|
||||
) {
|
||||
return new LocalRunStartupRecoveryCoordinator(
|
||||
repository,
|
||||
candidateSource,
|
||||
receiptStore,
|
||||
{ executorType: 'local_process', inspect },
|
||||
{
|
||||
clock: { now: () => 10 },
|
||||
createEventId: (() => {
|
||||
let value = 0;
|
||||
return () => `event-${++value}`;
|
||||
})(),
|
||||
...options,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test('zero candidates pay one durable read and no receipt or process work', async () => {
|
||||
const repository = new MemoryRepository();
|
||||
const candidateSource = source(repository);
|
||||
const receiptStore = receipts(undefined);
|
||||
let inspections = 0;
|
||||
const summary = await coordinator(
|
||||
repository,
|
||||
candidateSource,
|
||||
receiptStore,
|
||||
async () => {
|
||||
inspections += 1;
|
||||
return { status: 'running', identityPid: 1 };
|
||||
},
|
||||
).recover();
|
||||
|
||||
assert.deepEqual(summary, {
|
||||
safe: true,
|
||||
scanned: 0,
|
||||
recovered: 0,
|
||||
remaining: 0,
|
||||
failed: 0,
|
||||
truncated: false,
|
||||
});
|
||||
assert.equal(candidateSource.calls, 1);
|
||||
assert.equal(receiptStore.readCount, 0);
|
||||
assert.equal(inspections, 0);
|
||||
});
|
||||
|
||||
test('truncation fails before any partial mutation or evidence read', async () => {
|
||||
const repository = new MemoryRepository(run(), attempt());
|
||||
const candidateSource = source(repository, {
|
||||
page: {
|
||||
candidates: [
|
||||
{
|
||||
runId: RUN_ID,
|
||||
runStatus: 'dispatching',
|
||||
activeAttemptCount: 1,
|
||||
},
|
||||
],
|
||||
truncated: true,
|
||||
},
|
||||
});
|
||||
const receiptStore = receipts(undefined);
|
||||
const summary = await coordinator(
|
||||
repository,
|
||||
candidateSource,
|
||||
receiptStore,
|
||||
async () => {
|
||||
throw new Error('must not inspect');
|
||||
},
|
||||
).recover();
|
||||
|
||||
assert.equal(summary.safe, false);
|
||||
assert.equal(summary.truncated, true);
|
||||
assert.equal(repository.runs.get(RUN_ID).status, 'dispatching');
|
||||
assert.equal(repository.attempts.get(ATTEMPT_ID).status, 'claimed');
|
||||
assert.equal(repository.events.length, 0);
|
||||
assert.equal(receiptStore.readCount, 0);
|
||||
});
|
||||
|
||||
test('an unstarted claimed Attempt is atomically marked lost without probing', async () => {
|
||||
const repository = new MemoryRepository(run(), attempt());
|
||||
const candidateSource = source(repository);
|
||||
const receiptStore = receipts(undefined);
|
||||
let inspections = 0;
|
||||
const summary = await coordinator(
|
||||
repository,
|
||||
candidateSource,
|
||||
receiptStore,
|
||||
async () => {
|
||||
inspections += 1;
|
||||
return { status: 'running', identityPid: 1 };
|
||||
},
|
||||
).recover();
|
||||
|
||||
assert.equal(summary.safe, true);
|
||||
assert.equal(summary.recovered, 1);
|
||||
assert.equal(repository.runs.get(RUN_ID).status, 'lost');
|
||||
assert.equal(repository.attempts.get(ATTEMPT_ID).status, 'lost');
|
||||
assert.deepEqual(
|
||||
repository.events.map((item) => item.type),
|
||||
['attempt.lost', 'run.lost'],
|
||||
);
|
||||
assert.equal(inspections, 0);
|
||||
});
|
||||
|
||||
test('a trusted completion receipt wins before process inspection', async () => {
|
||||
const callbackTokenHash = createHash('sha256').update(TOKEN).digest('hex');
|
||||
const repository = new MemoryRepository(
|
||||
run({ status: 'running', startedAtMs: 3 }),
|
||||
attempt({
|
||||
status: 'running',
|
||||
startedAtMs: 3,
|
||||
callbackTokenHash,
|
||||
executorHandle: 'unused',
|
||||
pid: 10,
|
||||
}),
|
||||
);
|
||||
const candidateSource = source(repository);
|
||||
const receiptStore = receipts({
|
||||
schemaVersion: 1,
|
||||
runId: RUN_ID,
|
||||
attemptId: ATTEMPT_ID,
|
||||
callbackSequence: 1,
|
||||
token: TOKEN,
|
||||
startedAtMs: 3,
|
||||
finishedAtMs: 8,
|
||||
exitCode: 0,
|
||||
});
|
||||
let inspections = 0;
|
||||
const resolved = [];
|
||||
const summary = await coordinator(
|
||||
repository,
|
||||
candidateSource,
|
||||
receiptStore,
|
||||
async () => {
|
||||
inspections += 1;
|
||||
return { status: 'running', identityPid: 10 };
|
||||
},
|
||||
{
|
||||
journal: {
|
||||
async markQuarantined() {
|
||||
throw new Error('must not quarantine a trusted receipt');
|
||||
},
|
||||
async resolve(attemptId) {
|
||||
resolved.push(attemptId);
|
||||
},
|
||||
},
|
||||
},
|
||||
).recover();
|
||||
|
||||
assert.equal(summary.safe, true);
|
||||
assert.equal(repository.runs.get(RUN_ID).status, 'succeeded');
|
||||
assert.equal(repository.attempts.get(ATTEMPT_ID).status, 'succeeded');
|
||||
assert.equal(repository.attempts.get(ATTEMPT_ID).callbackSequence, 1);
|
||||
assert.deepEqual(
|
||||
repository.events.map((item) => item.type),
|
||||
['attempt.succeeded', 'run.succeeded'],
|
||||
);
|
||||
assert.equal(receiptStore.removed, 1);
|
||||
assert.deepEqual(resolved, [ATTEMPT_ID]);
|
||||
assert.equal(inspections, 0);
|
||||
});
|
||||
|
||||
test('an invalid receipt records durable quarantine intent before moving the file', async () => {
|
||||
const repository = new MemoryRepository(
|
||||
run({ status: 'running', startedAtMs: 3 }),
|
||||
attempt({
|
||||
status: 'running',
|
||||
startedAtMs: 3,
|
||||
executorHandle: 'handle-1',
|
||||
pid: 10,
|
||||
}),
|
||||
);
|
||||
const operations = [];
|
||||
const receiptStore = {
|
||||
async read() {
|
||||
throw new InvalidCompletionReceiptError('invalid test receipt');
|
||||
},
|
||||
async remove() {
|
||||
throw new Error('must not remove an invalid receipt');
|
||||
},
|
||||
async publish() {
|
||||
throw new Error('must not publish during recovery');
|
||||
},
|
||||
quarantineReference(attemptId) {
|
||||
return `.quarantine/${attemptId}.json`;
|
||||
},
|
||||
async quarantine(attemptId) {
|
||||
operations.push(`file:${attemptId}`);
|
||||
return `.quarantine/${attemptId}.json`;
|
||||
},
|
||||
};
|
||||
|
||||
const summary = await coordinator(
|
||||
repository,
|
||||
source(repository),
|
||||
receiptStore,
|
||||
async () => {
|
||||
throw new Error('invalid receipt must block process inspection');
|
||||
},
|
||||
{
|
||||
quarantineRetentionMs: 100,
|
||||
journal: {
|
||||
async markQuarantined(record) {
|
||||
operations.push(`journal:${record.attemptId}`);
|
||||
assert.deepEqual(record, {
|
||||
attemptId: ATTEMPT_ID,
|
||||
quarantineRef: `.quarantine/${ATTEMPT_ID}.json`,
|
||||
updatedAtMs: 10,
|
||||
purgeAfterMs: 110,
|
||||
});
|
||||
},
|
||||
async resolve() {
|
||||
throw new Error('must not resolve invalid receipt intent');
|
||||
},
|
||||
},
|
||||
},
|
||||
).recover();
|
||||
|
||||
assert.equal(summary.safe, false);
|
||||
assert.equal(summary.remaining, 1);
|
||||
assert.deepEqual(operations, [`journal:${ATTEMPT_ID}`, `file:${ATTEMPT_ID}`]);
|
||||
});
|
||||
|
||||
test('a live exact process is verified twice without terminalizing the Run', async () => {
|
||||
const repository = new MemoryRepository(
|
||||
run({ status: 'running', startedAtMs: 3 }),
|
||||
attempt({
|
||||
status: 'running',
|
||||
startedAtMs: 3,
|
||||
executorHandle: 'handle-1',
|
||||
pid: 10,
|
||||
}),
|
||||
);
|
||||
const candidateSource = source(repository);
|
||||
const receiptStore = receipts(undefined);
|
||||
let inspections = 0;
|
||||
const summary = await coordinator(
|
||||
repository,
|
||||
candidateSource,
|
||||
receiptStore,
|
||||
async () => {
|
||||
inspections += 1;
|
||||
return { status: 'running', identityPid: 10 };
|
||||
},
|
||||
).recover();
|
||||
|
||||
assert.equal(summary.safe, true);
|
||||
assert.equal(summary.recovered, 1);
|
||||
assert.equal(repository.runs.get(RUN_ID).status, 'running');
|
||||
assert.equal(repository.attempts.get(ATTEMPT_ID).status, 'running');
|
||||
assert.equal(repository.events.length, 0);
|
||||
assert.equal(inspections, 2);
|
||||
assert.equal(receiptStore.readCount, 2);
|
||||
});
|
||||
|
||||
test('a process change during final verification revokes startup safety', async () => {
|
||||
const repository = new MemoryRepository(
|
||||
run({ status: 'running', startedAtMs: 3 }),
|
||||
attempt({
|
||||
status: 'running',
|
||||
startedAtMs: 3,
|
||||
executorHandle: 'handle-1',
|
||||
pid: 10,
|
||||
}),
|
||||
);
|
||||
let inspections = 0;
|
||||
const summary = await coordinator(
|
||||
repository,
|
||||
source(repository),
|
||||
receipts(undefined),
|
||||
async () => {
|
||||
inspections += 1;
|
||||
return inspections === 1
|
||||
? { status: 'running', identityPid: 10 }
|
||||
: { status: 'not_running', identityPid: 10 };
|
||||
},
|
||||
).recover();
|
||||
|
||||
assert.deepEqual(summary, {
|
||||
safe: false,
|
||||
scanned: 1,
|
||||
recovered: 0,
|
||||
remaining: 1,
|
||||
failed: 0,
|
||||
truncated: false,
|
||||
});
|
||||
assert.equal(repository.runs.get(RUN_ID).status, 'running');
|
||||
});
|
||||
|
||||
test('trusted not-running evidence marks the aggregate lost but unknown evidence blocks', async () => {
|
||||
const activeRun = run({ status: 'running', startedAtMs: 3 });
|
||||
const activeAttempt = attempt({
|
||||
status: 'running',
|
||||
startedAtMs: 3,
|
||||
executorHandle: 'handle-1',
|
||||
pid: 10,
|
||||
});
|
||||
const repository = new MemoryRepository(activeRun, activeAttempt);
|
||||
const summary = await coordinator(
|
||||
repository,
|
||||
source(repository),
|
||||
receipts(undefined),
|
||||
async () => ({ status: 'not_running', identityPid: 10 }),
|
||||
).recover();
|
||||
assert.equal(summary.safe, true);
|
||||
assert.equal(repository.runs.get(RUN_ID).status, 'lost');
|
||||
|
||||
const unknownRepository = new MemoryRepository(activeRun, activeAttempt);
|
||||
const unknown = await coordinator(
|
||||
unknownRepository,
|
||||
source(unknownRepository),
|
||||
receipts(undefined),
|
||||
async () => ({ status: 'unknown', reason: 'invalid_handle' }),
|
||||
).recover();
|
||||
assert.equal(unknown.safe, false);
|
||||
assert.equal(unknown.remaining, 1);
|
||||
assert.equal(unknownRepository.runs.get(RUN_ID).status, 'running');
|
||||
assert.equal(unknownRepository.events.length, 0);
|
||||
});
|
||||
|
||||
test('recovers an orphaned claimed Workflow Task without terminalizing its parent', async () => {
|
||||
const workflowRun = run({
|
||||
taskId: 'workflow',
|
||||
triggerType: 'plugin_package_workflow',
|
||||
executionOrigin: 'system',
|
||||
status: 'running',
|
||||
version: 4,
|
||||
eventSequence: 4,
|
||||
startedAtMs: 2,
|
||||
});
|
||||
const workflowAttempt = attempt({
|
||||
stepRunId: 'workflow-step',
|
||||
status: 'claimed',
|
||||
createdAtMs: 5,
|
||||
});
|
||||
const repository = new MemoryRepository(workflowRun, workflowAttempt);
|
||||
let recovered = false;
|
||||
let inspections = 0;
|
||||
const recovery = {
|
||||
async listRecoveryCandidates() {
|
||||
return {
|
||||
candidates: recovered
|
||||
? []
|
||||
: [
|
||||
{
|
||||
runId: RUN_ID,
|
||||
attemptId: ATTEMPT_ID,
|
||||
attemptCreatedAtMs: 5,
|
||||
},
|
||||
],
|
||||
truncated: false,
|
||||
};
|
||||
},
|
||||
async recover(command) {
|
||||
assert.equal(command.reason, 'unstarted_claim_expired');
|
||||
repository.attempts.set(ATTEMPT_ID, {
|
||||
...workflowAttempt,
|
||||
status: 'lost',
|
||||
finishedAtMs: command.observedAtMs,
|
||||
});
|
||||
recovered = true;
|
||||
return 'requeued';
|
||||
},
|
||||
};
|
||||
const coordinator = new LocalWorkflowTaskStartupRecoveryCoordinator(
|
||||
repository,
|
||||
recovery,
|
||||
{
|
||||
async recordRunning() {
|
||||
throw new Error('claimed recovery must not mark running');
|
||||
},
|
||||
},
|
||||
{ process: async () => 'missing' },
|
||||
{
|
||||
executorType: 'local_process',
|
||||
async inspect() {
|
||||
inspections += 1;
|
||||
throw new Error('claimed recovery must not inspect a process');
|
||||
},
|
||||
},
|
||||
{ clock: { now: () => 10 } },
|
||||
);
|
||||
|
||||
assert.deepEqual(await coordinator.recover(), {
|
||||
safe: true,
|
||||
scanned: 1,
|
||||
recovered: 1,
|
||||
verified: 0,
|
||||
remaining: 0,
|
||||
failed: 0,
|
||||
truncated: false,
|
||||
});
|
||||
assert.equal(repository.runs.get(RUN_ID).status, 'running');
|
||||
assert.equal(repository.attempts.get(ATTEMPT_ID).status, 'lost');
|
||||
assert.equal(inspections, 0);
|
||||
});
|
||||
@@ -0,0 +1,377 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
LocalSchedulerCoordinator,
|
||||
LocalSchedulerLifecycle,
|
||||
LocalWorkflowSchedulerCoordinator,
|
||||
} = require('../dist/scheduler');
|
||||
|
||||
function nextMinute(schedule, afterMs) {
|
||||
if (schedule.expression !== '* * * * *' || schedule.timezone !== 'UTC') {
|
||||
throw new Error('unsupported test schedule');
|
||||
}
|
||||
return Math.floor(afterMs / 60_000 + 1) * 60_000;
|
||||
}
|
||||
|
||||
function candidate(overrides = {}) {
|
||||
return {
|
||||
projectId: 'default',
|
||||
triggerId: 'trigger-1',
|
||||
triggerRevision: 1,
|
||||
triggerContentDigest: 'a'.repeat(64),
|
||||
triggerUpdatedAtMs: 1,
|
||||
taskId: 'task-1',
|
||||
taskRevision: 1,
|
||||
taskContentDigest: 'b'.repeat(64),
|
||||
expression: '* * * * *',
|
||||
timezone: 'UTC',
|
||||
misfirePolicy: 'skip',
|
||||
stateVersion: 0,
|
||||
nextFireAtMs: 60_000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('coordinates one bounded page and notifies only committed admissions', async () => {
|
||||
const committed = [];
|
||||
const notified = [];
|
||||
let sequence = 0;
|
||||
const coordinator = new LocalSchedulerCoordinator(
|
||||
{
|
||||
async listLocalScheduleCandidates(options) {
|
||||
assert.deepEqual(options, { observedAtMs: 61_000, limit: 4 });
|
||||
return {
|
||||
candidates: [candidate(), candidate({ triggerId: 'trigger-2' })],
|
||||
truncated: true,
|
||||
};
|
||||
},
|
||||
async commitLocalScheduleDecision(command) {
|
||||
committed.push(command);
|
||||
if (committed.length === 2) return { status: 'raced' };
|
||||
return {
|
||||
status: 'admitted',
|
||||
disposition: 'admit',
|
||||
runId: command.runId,
|
||||
attemptId: command.attemptId,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
pageSize: 4,
|
||||
misfireGraceMs: 5_000,
|
||||
clock: () => 61_000,
|
||||
nextOccurrence: nextMinute,
|
||||
createId: () =>
|
||||
`019f7500-0000-4000-8000-${String(++sequence).padStart(12, '0')}`,
|
||||
onAdmitted: (runId, attemptId) => notified.push([runId, attemptId]),
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(await coordinator.scheduleOnce(), {
|
||||
observedAtMs: 61_000,
|
||||
scanned: 2,
|
||||
initialized: 0,
|
||||
skipped: 0,
|
||||
admitted: 1,
|
||||
raced: 1,
|
||||
truncated: true,
|
||||
});
|
||||
assert.equal(committed.length, 2);
|
||||
assert.deepEqual(notified, [[committed[0].runId, committed[0].attemptId]]);
|
||||
});
|
||||
|
||||
test('does not allocate Run identities for initialization or skip decisions', async () => {
|
||||
let allocations = 0;
|
||||
const commands = [];
|
||||
const coordinator = new LocalSchedulerCoordinator(
|
||||
{
|
||||
async listLocalScheduleCandidates() {
|
||||
return {
|
||||
candidates: [
|
||||
candidate({ nextFireAtMs: null, triggerUpdatedAtMs: 90_000 }),
|
||||
candidate({ triggerId: 'trigger-2', nextFireAtMs: 60_000 }),
|
||||
],
|
||||
truncated: false,
|
||||
};
|
||||
},
|
||||
async commitLocalScheduleDecision(command) {
|
||||
commands.push(command);
|
||||
return {
|
||||
status: 'advanced',
|
||||
disposition: command.decision.disposition,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
clock: () => 100_000,
|
||||
misfireGraceMs: 5_000,
|
||||
nextOccurrence: nextMinute,
|
||||
createId() {
|
||||
allocations += 1;
|
||||
return 'unused';
|
||||
},
|
||||
},
|
||||
);
|
||||
const summary = await coordinator.scheduleOnce();
|
||||
assert.equal(summary.initialized, 1);
|
||||
assert.equal(summary.skipped, 1);
|
||||
assert.equal(allocations, 0);
|
||||
assert.equal(
|
||||
commands.every((command) => command.runId === undefined),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('reuses one scheduler cycle for cancellation, frontier, Task admission and dispatch', async () => {
|
||||
const calls = [];
|
||||
let dispatches = 0;
|
||||
const schedulerSummary = {
|
||||
observedAtMs: 10,
|
||||
scanned: 0,
|
||||
initialized: 0,
|
||||
skipped: 0,
|
||||
admitted: 0,
|
||||
raced: 0,
|
||||
truncated: false,
|
||||
};
|
||||
const coordinator = new LocalWorkflowSchedulerCoordinator(
|
||||
{
|
||||
async scheduleOnce() {
|
||||
calls.push('schedule');
|
||||
return schedulerSummary;
|
||||
},
|
||||
},
|
||||
{
|
||||
async convergePage(command) {
|
||||
calls.push(`cancel:${command.limit}`);
|
||||
return {
|
||||
scanned: 0,
|
||||
settledRuns: 0,
|
||||
settledAttempts: 0,
|
||||
blocked: 0,
|
||||
hasMore: false,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
async listCandidates(command) {
|
||||
calls.push(`frontier-list:${command.limit}`);
|
||||
return {
|
||||
candidates: [
|
||||
{
|
||||
runId: 'workflow-run',
|
||||
planDigest: 'a'.repeat(64),
|
||||
admittedAtMs: 1,
|
||||
},
|
||||
],
|
||||
truncated: false,
|
||||
};
|
||||
},
|
||||
async advance(runId) {
|
||||
calls.push(`frontier-advance:${runId}`);
|
||||
return {};
|
||||
},
|
||||
},
|
||||
{
|
||||
async listCandidates(command) {
|
||||
calls.push(`task-list:${command.limit}`);
|
||||
return {
|
||||
candidates: [
|
||||
{
|
||||
runId: 'workflow-run',
|
||||
stepRunId: 'workflow-step',
|
||||
readyAtMs: 2,
|
||||
planDigest: 'a'.repeat(64),
|
||||
},
|
||||
],
|
||||
truncated: false,
|
||||
};
|
||||
},
|
||||
async admit(runId, stepRunId) {
|
||||
calls.push(`task-admit:${runId}:${stepRunId}`);
|
||||
return { status: 'created', receipt: {} };
|
||||
},
|
||||
},
|
||||
{
|
||||
async dispatchOnce() {
|
||||
dispatches += 1;
|
||||
calls.push(`dispatch:${dispatches}`);
|
||||
const stats = {
|
||||
pages: 1,
|
||||
candidatesScanned: dispatches === 1 ? 1 : 0,
|
||||
plansUnavailable: 0,
|
||||
activationRaces: 0,
|
||||
};
|
||||
return dispatches === 1
|
||||
? {
|
||||
status: 'activated',
|
||||
runId: 'workflow-run',
|
||||
attemptId: 'workflow-attempt',
|
||||
stats,
|
||||
truncated: false,
|
||||
}
|
||||
: {
|
||||
status: 'idle',
|
||||
reason: 'no_candidates',
|
||||
stats,
|
||||
truncated: false,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
cancellationPageSize: 1,
|
||||
cancellationMaxPages: 1,
|
||||
frontierPageSize: 1,
|
||||
frontierMaxPages: 1,
|
||||
taskAttemptPageSize: 1,
|
||||
taskAttemptMaxPages: 1,
|
||||
maxDispatches: 2,
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(await coordinator.scheduleOnce(), schedulerSummary);
|
||||
assert.deepEqual(calls, [
|
||||
'cancel:1',
|
||||
'schedule',
|
||||
'frontier-list:1',
|
||||
'frontier-advance:workflow-run',
|
||||
'task-list:1',
|
||||
'task-admit:workflow-run:workflow-step',
|
||||
'dispatch:1',
|
||||
'dispatch:2',
|
||||
]);
|
||||
assert.deepEqual(coordinator.latestWorkflowSummary(), {
|
||||
cancellation: {
|
||||
pages: 1,
|
||||
scanned: 0,
|
||||
settledRuns: 0,
|
||||
settledAttempts: 0,
|
||||
blocked: 0,
|
||||
hasMore: false,
|
||||
remaining: false,
|
||||
stopReason: 'complete',
|
||||
},
|
||||
frontierPages: 1,
|
||||
frontierScanned: 1,
|
||||
frontierAdvanced: 1,
|
||||
frontierTruncated: false,
|
||||
taskAttemptPages: 1,
|
||||
taskAttemptsScanned: 1,
|
||||
taskAttemptsCreated: 1,
|
||||
taskAttemptsExisting: 0,
|
||||
taskAttemptsTruncated: false,
|
||||
dispatches: 2,
|
||||
activated: 1,
|
||||
activationFailed: 0,
|
||||
dispatchIdle: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('lifecycle coalesces cycles and stops without leaving a scheduler timer', async () => {
|
||||
let calls = 0;
|
||||
let release;
|
||||
const pending = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const summary = {
|
||||
observedAtMs: 1,
|
||||
scanned: 0,
|
||||
initialized: 0,
|
||||
skipped: 0,
|
||||
admitted: 0,
|
||||
raced: 0,
|
||||
truncated: false,
|
||||
};
|
||||
const lifecycle = new LocalSchedulerLifecycle(
|
||||
{
|
||||
async scheduleOnce() {
|
||||
calls += 1;
|
||||
await pending;
|
||||
return summary;
|
||||
},
|
||||
},
|
||||
{ intervalMs: 250, stopTimeoutMs: 1_000 },
|
||||
);
|
||||
|
||||
assert.equal(lifecycle.start(), 'started');
|
||||
const first = lifecycle.runOnce();
|
||||
const second = lifecycle.runOnce();
|
||||
assert.equal(first, second);
|
||||
const stopping = lifecycle.stopAndDrain();
|
||||
release();
|
||||
assert.deepEqual(await first, summary);
|
||||
assert.deepEqual(await stopping, { status: 'stopped' });
|
||||
assert.equal(calls, 1);
|
||||
await assert.rejects(lifecycle.runOnce(), /stopping/);
|
||||
});
|
||||
|
||||
test('lifecycle bounds shutdown when a schedule transaction does not settle', async () => {
|
||||
const lifecycle = new LocalSchedulerLifecycle(
|
||||
{ scheduleOnce: () => new Promise(() => {}) },
|
||||
{ intervalMs: 250, stopTimeoutMs: 100 },
|
||||
);
|
||||
void lifecycle.runOnce();
|
||||
assert.deepEqual(await lifecycle.stopAndDrain(), { status: 'timed_out' });
|
||||
});
|
||||
|
||||
test('lifecycle shutdown absorbs an already isolated cycle failure', async () => {
|
||||
let rejectCycle;
|
||||
const lifecycle = new LocalSchedulerLifecycle(
|
||||
{
|
||||
scheduleOnce: () =>
|
||||
new Promise((resolve, reject) => {
|
||||
rejectCycle = reject;
|
||||
}),
|
||||
},
|
||||
{ intervalMs: 250, stopTimeoutMs: 1_000 },
|
||||
);
|
||||
const cycle = lifecycle.runOnce();
|
||||
const stopping = lifecycle.stopAndDrain();
|
||||
rejectCycle(new Error('schedule storage unavailable'));
|
||||
await assert.rejects(cycle, /schedule storage unavailable/);
|
||||
assert.deepEqual(await stopping, { status: 'stopped' });
|
||||
});
|
||||
|
||||
test('lifecycle runs an unrefed non-overlapping cadence and isolates diagnostics', async () => {
|
||||
let active = 0;
|
||||
let maximumActive = 0;
|
||||
let calls = 0;
|
||||
const diagnostics = [];
|
||||
const lifecycle = new LocalSchedulerLifecycle(
|
||||
{
|
||||
async scheduleOnce() {
|
||||
active += 1;
|
||||
maximumActive = Math.max(maximumActive, active);
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
active -= 1;
|
||||
calls += 1;
|
||||
if (calls === 1) throw new Error('temporary schedule failure');
|
||||
return {
|
||||
observedAtMs: calls,
|
||||
scanned: 0,
|
||||
initialized: 0,
|
||||
skipped: 0,
|
||||
admitted: 0,
|
||||
raced: 0,
|
||||
truncated: false,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
intervalMs: 250,
|
||||
stopTimeoutMs: 1_000,
|
||||
onDiagnostic(error, summary) {
|
||||
diagnostics.push({ error, summary });
|
||||
if (error === undefined) throw new Error('diagnostic sink unavailable');
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
lifecycle.start();
|
||||
await new Promise((resolve) => setTimeout(resolve, 650));
|
||||
assert.deepEqual(await lifecycle.stopAndDrain(), { status: 'stopped' });
|
||||
assert.ok(calls >= 2);
|
||||
assert.equal(maximumActive, 1);
|
||||
assert.ok(diagnostics.some(({ error }) => error instanceof Error));
|
||||
assert.ok(diagnostics.some(({ summary }) => summary?.observedAtMs >= 2));
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"],
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": false
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user