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,265 @@
|
||||
import {
|
||||
ApprovalHumanDecisionRequiredError,
|
||||
ApprovalPolicyDeniedError,
|
||||
ApprovalRequestNotFoundError,
|
||||
ApprovalSelfDecisionError,
|
||||
approvalRequestEffectiveStatus,
|
||||
assertApprovalMutationId,
|
||||
assertApprovalReasonCode,
|
||||
assertApprovalRequestId,
|
||||
assertApprovalRequestVersion,
|
||||
assertApprovalTimestamp,
|
||||
normalizeApprovalActionBinding,
|
||||
normalizeApprovalRequestRecord,
|
||||
normalizeApprovalPolicyFence,
|
||||
sameApprovalSubject,
|
||||
type ApprovalActionBinding,
|
||||
type ApprovalDecision,
|
||||
type ApprovalRequestEffectiveStatus,
|
||||
type ApprovalRequestRecord,
|
||||
type ApprovedActionDispatchRecord,
|
||||
type ApprovalRisk,
|
||||
} from '../domain/approvalRequest';
|
||||
import {
|
||||
assertProjectPolicyProjectId,
|
||||
normalizePolicySubject,
|
||||
type PolicySubject,
|
||||
} from '../domain/projectPolicy';
|
||||
import type { ApprovalRequestRepository } from '../ports/approvalRequestRepository';
|
||||
import type { ProjectPolicyEngine } from './projectPolicyEngine';
|
||||
|
||||
export interface CreateApprovalRequestInput {
|
||||
id: string;
|
||||
projectId: string;
|
||||
action: ApprovalActionBinding;
|
||||
risk: ApprovalRisk;
|
||||
requestedBy: PolicySubject;
|
||||
requestedAtMs: number;
|
||||
expiresAtMs: number;
|
||||
}
|
||||
|
||||
export interface DecideApprovalRequestInput {
|
||||
requestId: string;
|
||||
expectedVersion: number;
|
||||
decisionId: string;
|
||||
decision: ApprovalDecision;
|
||||
reasonCode: string;
|
||||
decidedBy: PolicySubject;
|
||||
decidedAtMs: number;
|
||||
}
|
||||
|
||||
export interface ConsumeApprovalRequestInput {
|
||||
requestId: string;
|
||||
expectedVersion: number;
|
||||
consumptionId: string;
|
||||
dispatchId: string;
|
||||
action: ApprovalActionBinding;
|
||||
requestedBy: PolicySubject;
|
||||
consumedBy: PolicySubject;
|
||||
consumedAtMs: number;
|
||||
}
|
||||
|
||||
export interface ApprovalRequestView {
|
||||
request: Readonly<ApprovalRequestRecord>;
|
||||
effectiveStatus: ApprovalRequestEffectiveStatus;
|
||||
}
|
||||
|
||||
function assertExactKeys(
|
||||
name: string,
|
||||
value: object,
|
||||
expected: readonly string[],
|
||||
): void {
|
||||
const keys = Object.keys(value).sort();
|
||||
const canonical = [...expected].sort();
|
||||
if (
|
||||
keys.length !== canonical.length ||
|
||||
keys.some((key, index) => key !== canonical[index])
|
||||
) {
|
||||
throw new TypeError(`${name} shape is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertInput(name: string, value: unknown): asserts value is object {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new TypeError(`${name} must be an object`);
|
||||
}
|
||||
}
|
||||
|
||||
export class ApprovalRequestService {
|
||||
constructor(
|
||||
private readonly repository: ApprovalRequestRepository,
|
||||
private readonly policy: ProjectPolicyEngine,
|
||||
) {}
|
||||
|
||||
async create(
|
||||
input: CreateApprovalRequestInput,
|
||||
): Promise<Readonly<ApprovalRequestRecord>> {
|
||||
assertInput('Approval create input', input);
|
||||
assertExactKeys('Approval create input', input, [
|
||||
'id',
|
||||
'projectId',
|
||||
'action',
|
||||
'risk',
|
||||
'requestedBy',
|
||||
'requestedAtMs',
|
||||
'expiresAtMs',
|
||||
]);
|
||||
const action = normalizeApprovalActionBinding(input.action);
|
||||
const requestedBy = normalizePolicySubject(input.requestedBy);
|
||||
const request = normalizeApprovalRequestRecord({
|
||||
id: input.id,
|
||||
projectId: input.projectId,
|
||||
version: 1,
|
||||
state: 'pending',
|
||||
action,
|
||||
risk: input.risk,
|
||||
requestedBy,
|
||||
requestedAtMs: input.requestedAtMs,
|
||||
expiresAtMs: input.expiresAtMs,
|
||||
decisionId: null,
|
||||
decision: null,
|
||||
decisionReasonCode: null,
|
||||
decidedBy: null,
|
||||
decidedAtMs: null,
|
||||
consumptionId: null,
|
||||
dispatchId: null,
|
||||
consumedBy: null,
|
||||
consumedAtMs: null,
|
||||
});
|
||||
const authorization = await this.policy.decideWithFence({
|
||||
projectId: request.projectId,
|
||||
subject: requestedBy,
|
||||
permission: action.permission,
|
||||
});
|
||||
if (
|
||||
authorization.decision.effect !== 'require_approval' ||
|
||||
!authorization.fence
|
||||
) {
|
||||
throw new ApprovalPolicyDeniedError();
|
||||
}
|
||||
const result = await this.repository.create({
|
||||
request,
|
||||
authorizationFence: normalizeApprovalPolicyFence(authorization.fence),
|
||||
});
|
||||
return result.request;
|
||||
}
|
||||
|
||||
async decide(
|
||||
input: DecideApprovalRequestInput,
|
||||
): Promise<Readonly<ApprovalRequestRecord>> {
|
||||
assertInput('Approval decision input', input);
|
||||
assertExactKeys('Approval decision input', input, [
|
||||
'requestId',
|
||||
'expectedVersion',
|
||||
'decisionId',
|
||||
'decision',
|
||||
'reasonCode',
|
||||
'decidedBy',
|
||||
'decidedAtMs',
|
||||
]);
|
||||
assertApprovalRequestId(input.requestId);
|
||||
assertApprovalRequestVersion(input.expectedVersion);
|
||||
assertApprovalMutationId(input.decisionId);
|
||||
assertApprovalReasonCode(input.reasonCode);
|
||||
assertApprovalTimestamp('decidedAtMs', input.decidedAtMs);
|
||||
const decidedBy = normalizePolicySubject(input.decidedBy);
|
||||
if (decidedBy.type !== 'user') {
|
||||
throw new ApprovalHumanDecisionRequiredError();
|
||||
}
|
||||
const existing = await this.repository.findById(input.requestId);
|
||||
if (!existing) throw new ApprovalRequestNotFoundError();
|
||||
const request = normalizeApprovalRequestRecord(existing);
|
||||
if (sameApprovalSubject(request.requestedBy, decidedBy)) {
|
||||
throw new ApprovalSelfDecisionError();
|
||||
}
|
||||
const authorization = await this.policy.decideWithFence({
|
||||
projectId: request.projectId,
|
||||
subject: decidedBy,
|
||||
permission: 'approval.decide',
|
||||
});
|
||||
if (authorization.decision.effect !== 'allow' || !authorization.fence) {
|
||||
throw new ApprovalPolicyDeniedError();
|
||||
}
|
||||
const result = await this.repository.decide({
|
||||
requestId: input.requestId,
|
||||
expectedVersion: input.expectedVersion,
|
||||
decisionId: input.decisionId,
|
||||
decision: input.decision,
|
||||
reasonCode: input.reasonCode,
|
||||
decidedBy,
|
||||
decidedAtMs: input.decidedAtMs,
|
||||
authorizationFence: normalizeApprovalPolicyFence(authorization.fence),
|
||||
});
|
||||
return result.request;
|
||||
}
|
||||
|
||||
async consume(input: ConsumeApprovalRequestInput): Promise<{
|
||||
request: Readonly<ApprovalRequestRecord>;
|
||||
dispatch: Readonly<ApprovedActionDispatchRecord>;
|
||||
}> {
|
||||
assertInput('Approval consumption input', input);
|
||||
assertExactKeys('Approval consumption input', input, [
|
||||
'requestId',
|
||||
'expectedVersion',
|
||||
'consumptionId',
|
||||
'dispatchId',
|
||||
'action',
|
||||
'requestedBy',
|
||||
'consumedBy',
|
||||
'consumedAtMs',
|
||||
]);
|
||||
assertApprovalRequestId(input.requestId);
|
||||
assertApprovalRequestVersion(input.expectedVersion);
|
||||
assertApprovalMutationId(input.consumptionId);
|
||||
assertApprovalMutationId(input.dispatchId);
|
||||
assertApprovalTimestamp('consumedAtMs', input.consumedAtMs);
|
||||
const action = normalizeApprovalActionBinding(input.action);
|
||||
const requestedBy = normalizePolicySubject(input.requestedBy);
|
||||
const consumedBy = normalizePolicySubject(input.consumedBy);
|
||||
if (consumedBy.type !== 'system' && consumedBy.type !== 'worker') {
|
||||
throw new ApprovalPolicyDeniedError();
|
||||
}
|
||||
const existing = await this.repository.findById(input.requestId);
|
||||
if (!existing) throw new ApprovalRequestNotFoundError();
|
||||
const request = normalizeApprovalRequestRecord(existing);
|
||||
const authorization = await this.policy.decideWithFence({
|
||||
projectId: request.projectId,
|
||||
subject: requestedBy,
|
||||
permission: action.permission,
|
||||
});
|
||||
if (
|
||||
(authorization.decision.effect !== 'allow' &&
|
||||
authorization.decision.effect !== 'require_approval') ||
|
||||
!authorization.fence
|
||||
) {
|
||||
throw new ApprovalPolicyDeniedError();
|
||||
}
|
||||
const result = await this.repository.consume({
|
||||
requestId: input.requestId,
|
||||
expectedVersion: input.expectedVersion,
|
||||
consumptionId: input.consumptionId,
|
||||
dispatchId: input.dispatchId,
|
||||
action,
|
||||
requestedBy,
|
||||
consumedBy,
|
||||
consumedAtMs: input.consumedAtMs,
|
||||
authorizationFence: normalizeApprovalPolicyFence(authorization.fence),
|
||||
});
|
||||
return Object.freeze({
|
||||
request: result.request,
|
||||
dispatch: result.dispatch,
|
||||
});
|
||||
}
|
||||
|
||||
async get(requestId: string, nowMs: number): Promise<ApprovalRequestView> {
|
||||
assertApprovalRequestId(requestId);
|
||||
assertApprovalTimestamp('nowMs', nowMs);
|
||||
const request = await this.repository.findById(requestId);
|
||||
if (!request) throw new ApprovalRequestNotFoundError();
|
||||
const normalized = normalizeApprovalRequestRecord(request);
|
||||
return Object.freeze({
|
||||
request: normalized,
|
||||
effectiveStatus: approvalRequestEffectiveStatus(normalized, nowMs),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
import { v7 as uuidV7 } from 'uuid';
|
||||
import {
|
||||
assertApprovedActionLeaseDuration,
|
||||
assertApprovedActionLeaseIdentity,
|
||||
assertApprovedActionPageSize,
|
||||
assertApprovedActionResultCode,
|
||||
type ApprovedActionDispatchCursor,
|
||||
type ApprovedActionDispatchExecutionRecord,
|
||||
} from '../domain/approvedActionDispatchExecution';
|
||||
import type { ApprovedActionHandler } from '../ports/approvedActionHandler';
|
||||
import type { ApprovedActionDispatchRepository } from '../ports/approvedActionDispatchRepository';
|
||||
|
||||
const DEFAULT_LEASE_DURATION_MS = 30_000;
|
||||
const DEFAULT_RETRY_BASE_MS = 1_000;
|
||||
const DEFAULT_RETRY_MAX_MS = 60_000;
|
||||
|
||||
export interface ApprovedActionDispatcherOptions {
|
||||
owner: string;
|
||||
leaseDurationMs?: number;
|
||||
retryBaseMs?: number;
|
||||
retryMaxMs?: number;
|
||||
clock?: () => number;
|
||||
createId?: () => string;
|
||||
}
|
||||
|
||||
export interface ApprovedActionDispatchBatchSummary {
|
||||
scanned: number;
|
||||
claimed: number;
|
||||
started: number;
|
||||
succeeded: number;
|
||||
failed: number;
|
||||
blocked: number;
|
||||
retrying: number;
|
||||
deferred: number;
|
||||
recoveryRequired: number;
|
||||
alreadyTerminal: number;
|
||||
unavailable: number;
|
||||
truncated: boolean;
|
||||
nextCursor?: Readonly<ApprovedActionDispatchCursor>;
|
||||
}
|
||||
|
||||
function assertPositiveInteger(name: string, value: number): void {
|
||||
if (!Number.isSafeInteger(value) || value < 1) {
|
||||
throw new RangeError(`${name} must be a positive safe integer`);
|
||||
}
|
||||
}
|
||||
|
||||
function exactKeys(value: object, expected: readonly string[]): boolean {
|
||||
const keys = Object.keys(value).sort();
|
||||
const canonical = [...expected].sort();
|
||||
return (
|
||||
keys.length === canonical.length &&
|
||||
keys.every((key, index) => key === canonical[index])
|
||||
);
|
||||
}
|
||||
|
||||
export class ApprovedActionDispatcher {
|
||||
private readonly handlers = new Map<string, ApprovedActionHandler>();
|
||||
private readonly owner: string;
|
||||
private readonly leaseDurationMs: number;
|
||||
private readonly retryBaseMs: number;
|
||||
private readonly retryMaxMs: number;
|
||||
private readonly clock: () => number;
|
||||
private readonly createId: () => string;
|
||||
|
||||
constructor(
|
||||
private readonly repository: ApprovedActionDispatchRepository,
|
||||
handlers: readonly ApprovedActionHandler[],
|
||||
options: ApprovedActionDispatcherOptions,
|
||||
) {
|
||||
assertApprovedActionLeaseIdentity(options.owner);
|
||||
this.owner = options.owner;
|
||||
this.leaseDurationMs = options.leaseDurationMs ?? DEFAULT_LEASE_DURATION_MS;
|
||||
this.retryBaseMs = options.retryBaseMs ?? DEFAULT_RETRY_BASE_MS;
|
||||
this.retryMaxMs = options.retryMaxMs ?? DEFAULT_RETRY_MAX_MS;
|
||||
this.clock = options.clock ?? Date.now;
|
||||
this.createId = options.createId ?? uuidV7;
|
||||
assertApprovedActionLeaseDuration(this.leaseDurationMs);
|
||||
assertPositiveInteger('retryBaseMs', this.retryBaseMs);
|
||||
assertPositiveInteger('retryMaxMs', this.retryMaxMs);
|
||||
if (this.retryMaxMs < this.retryBaseMs) {
|
||||
throw new RangeError(
|
||||
'retryMaxMs must be greater than or equal to retryBaseMs',
|
||||
);
|
||||
}
|
||||
for (const handler of handlers) {
|
||||
if (
|
||||
!handler ||
|
||||
typeof handler !== 'object' ||
|
||||
typeof handler.actionType !== 'string' ||
|
||||
handler.actionType.length < 1 ||
|
||||
handler.actionType.length > 64 ||
|
||||
typeof handler.inspect !== 'function' ||
|
||||
typeof handler.execute !== 'function'
|
||||
) {
|
||||
throw new TypeError('Approved action handler is invalid');
|
||||
}
|
||||
if (this.handlers.has(handler.actionType)) {
|
||||
throw new TypeError(
|
||||
`Duplicate approved action handler: ${handler.actionType}`,
|
||||
);
|
||||
}
|
||||
this.handlers.set(handler.actionType, handler);
|
||||
}
|
||||
}
|
||||
|
||||
async dispatchBatch(
|
||||
options: { cursor?: ApprovedActionDispatchCursor; limit?: number } = {},
|
||||
): Promise<Readonly<ApprovedActionDispatchBatchSummary>> {
|
||||
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
||||
throw new TypeError('Approved action dispatch options must be an object');
|
||||
}
|
||||
if (
|
||||
!exactKeys(
|
||||
options,
|
||||
options.cursor === undefined && options.limit === undefined
|
||||
? []
|
||||
: [
|
||||
...(options.cursor === undefined ? [] : ['cursor']),
|
||||
...(options.limit === undefined ? [] : ['limit']),
|
||||
],
|
||||
)
|
||||
) {
|
||||
throw new TypeError('Approved action dispatch options shape is invalid');
|
||||
}
|
||||
const limit = options.limit ?? 16;
|
||||
assertApprovedActionPageSize(limit);
|
||||
const observedAtMs = this.now();
|
||||
const page = await this.repository.listDue({
|
||||
nowMs: observedAtMs,
|
||||
limit,
|
||||
...(options.cursor ? { cursor: options.cursor } : {}),
|
||||
});
|
||||
const summary: ApprovedActionDispatchBatchSummary = {
|
||||
scanned: page.dispatches.length,
|
||||
claimed: 0,
|
||||
started: 0,
|
||||
succeeded: 0,
|
||||
failed: 0,
|
||||
blocked: 0,
|
||||
retrying: 0,
|
||||
deferred: 0,
|
||||
recoveryRequired: 0,
|
||||
alreadyTerminal: 0,
|
||||
unavailable: 0,
|
||||
truncated: page.truncated,
|
||||
...(page.nextCursor ? { nextCursor: page.nextCursor } : {}),
|
||||
};
|
||||
for (const candidate of page.dispatches) {
|
||||
await this.dispatchOne(candidate.dispatch.id, summary);
|
||||
}
|
||||
return Object.freeze(summary);
|
||||
}
|
||||
|
||||
private async dispatchOne(
|
||||
dispatchId: string,
|
||||
summary: ApprovedActionDispatchBatchSummary,
|
||||
): Promise<void> {
|
||||
const claimedAtMs = this.now();
|
||||
let claim;
|
||||
try {
|
||||
claim = await this.repository.claim({
|
||||
dispatchId,
|
||||
owner: this.owner,
|
||||
leaseToken: this.createId(),
|
||||
nowMs: claimedAtMs,
|
||||
leaseDurationMs: this.leaseDurationMs,
|
||||
});
|
||||
} catch {
|
||||
summary.unavailable += 1;
|
||||
return;
|
||||
}
|
||||
if (claim.status === 'not_found') {
|
||||
summary.unavailable += 1;
|
||||
return;
|
||||
}
|
||||
if (claim.status !== 'claimed') {
|
||||
if (claim.status === 'recovery_required') summary.recoveryRequired += 1;
|
||||
else if (
|
||||
claim.status === 'succeeded' ||
|
||||
claim.status === 'failed' ||
|
||||
claim.status === 'blocked'
|
||||
) {
|
||||
summary.alreadyTerminal += 1;
|
||||
} else summary.deferred += 1;
|
||||
return;
|
||||
}
|
||||
summary.claimed += 1;
|
||||
const handler = this.handlers.get(
|
||||
claim.snapshot.dispatch.action.actionType,
|
||||
);
|
||||
if (!handler) {
|
||||
await this.releasePreflight(
|
||||
claim.snapshot.execution,
|
||||
'handler_unavailable',
|
||||
true,
|
||||
summary,
|
||||
);
|
||||
return;
|
||||
}
|
||||
let inspection;
|
||||
try {
|
||||
inspection = await handler.inspect(claim.snapshot.dispatch);
|
||||
this.assertInspection(inspection);
|
||||
} catch {
|
||||
await this.releasePreflight(
|
||||
claim.snapshot.execution,
|
||||
'handler_inspection_failed',
|
||||
true,
|
||||
summary,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (inspection.status !== 'ready') {
|
||||
await this.releasePreflight(
|
||||
claim.snapshot.execution,
|
||||
inspection.resultCode,
|
||||
inspection.status === 'retry',
|
||||
summary,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
inspection.actionDigest !== claim.snapshot.dispatch.action.actionDigest
|
||||
) {
|
||||
await this.releasePreflight(
|
||||
claim.snapshot.execution,
|
||||
'action_digest_mismatch',
|
||||
false,
|
||||
summary,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let started;
|
||||
try {
|
||||
const startedAtMs = this.now();
|
||||
started = await this.repository.start({
|
||||
dispatchId,
|
||||
approvalRequestId: claim.snapshot.dispatch.approvalRequestId,
|
||||
actionDigest: inspection.actionDigest,
|
||||
owner: this.owner,
|
||||
leaseToken: claim.snapshot.execution.leaseToken!,
|
||||
expectedVersion: claim.snapshot.execution.version,
|
||||
startedAtMs,
|
||||
});
|
||||
summary.started += 1;
|
||||
} catch {
|
||||
summary.unavailable += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
let outcome: 'succeeded' | 'failed' | 'indeterminate';
|
||||
let resultCode: string;
|
||||
try {
|
||||
const result = await handler.execute(
|
||||
Object.freeze({
|
||||
dispatch: started.dispatch,
|
||||
execution: started.execution,
|
||||
idempotencyKey: started.dispatch.id,
|
||||
fence: Object.freeze({
|
||||
owner: this.owner,
|
||||
leaseToken: started.execution.leaseToken!,
|
||||
version: started.execution.version,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
this.assertExecutionResult(result);
|
||||
outcome = result.outcome;
|
||||
resultCode = result.resultCode;
|
||||
} catch {
|
||||
outcome = 'indeterminate';
|
||||
resultCode = 'handler_failed_after_start';
|
||||
}
|
||||
try {
|
||||
const completed = await this.repository.complete({
|
||||
dispatchId,
|
||||
owner: this.owner,
|
||||
leaseToken: started.execution.leaseToken!,
|
||||
expectedVersion: started.execution.version,
|
||||
resultMutationId: this.createId(),
|
||||
outcome,
|
||||
resultCode,
|
||||
completedAtMs: this.now(),
|
||||
});
|
||||
if (completed.execution.status === 'succeeded') summary.succeeded += 1;
|
||||
else if (completed.execution.status === 'failed') summary.failed += 1;
|
||||
else summary.blocked += 1;
|
||||
} catch {
|
||||
summary.unavailable += 1;
|
||||
summary.recoveryRequired += 1;
|
||||
}
|
||||
}
|
||||
|
||||
private async releasePreflight(
|
||||
execution: Readonly<ApprovedActionDispatchExecutionRecord>,
|
||||
resultCode: string,
|
||||
retry: boolean,
|
||||
summary: ApprovedActionDispatchBatchSummary,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const atMs = this.now();
|
||||
const released = await this.repository.releaseBeforeStart({
|
||||
dispatchId: execution.dispatchId,
|
||||
owner: this.owner,
|
||||
leaseToken: execution.leaseToken!,
|
||||
expectedVersion: execution.version,
|
||||
resultMutationId: this.createId(),
|
||||
resultCode,
|
||||
atMs,
|
||||
...(retry
|
||||
? { retryAtMs: this.nextRetryAt(atMs, execution.attemptCount) }
|
||||
: {}),
|
||||
});
|
||||
if (released.execution.status === 'retry_wait') summary.retrying += 1;
|
||||
else summary.blocked += 1;
|
||||
} catch {
|
||||
summary.unavailable += 1;
|
||||
}
|
||||
}
|
||||
|
||||
private assertInspection(
|
||||
value: unknown,
|
||||
): asserts value is Awaited<ReturnType<ApprovedActionHandler['inspect']>> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new TypeError('Approved action inspection is invalid');
|
||||
}
|
||||
if (
|
||||
'status' in value &&
|
||||
value.status === 'ready' &&
|
||||
exactKeys(value, ['status', 'actionDigest']) &&
|
||||
'actionDigest' in value &&
|
||||
typeof value.actionDigest === 'string' &&
|
||||
/^[0-9a-f]{64}$/.test(value.actionDigest)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
'status' in value &&
|
||||
(value.status === 'retry' || value.status === 'blocked') &&
|
||||
exactKeys(value, ['status', 'resultCode']) &&
|
||||
'resultCode' in value &&
|
||||
typeof value.resultCode === 'string'
|
||||
) {
|
||||
assertApprovedActionResultCode(value.resultCode);
|
||||
return;
|
||||
}
|
||||
throw new TypeError('Approved action inspection is invalid');
|
||||
}
|
||||
|
||||
private assertExecutionResult(
|
||||
value: unknown,
|
||||
): asserts value is Awaited<ReturnType<ApprovedActionHandler['execute']>> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
!exactKeys(value, ['outcome', 'resultCode']) ||
|
||||
!('outcome' in value) ||
|
||||
!['succeeded', 'failed', 'indeterminate'].includes(
|
||||
value.outcome as string,
|
||||
) ||
|
||||
!('resultCode' in value) ||
|
||||
typeof value.resultCode !== 'string'
|
||||
) {
|
||||
throw new TypeError('Approved action execution result is invalid');
|
||||
}
|
||||
assertApprovedActionResultCode(value.resultCode);
|
||||
}
|
||||
|
||||
private nextRetryAt(atMs: number, attemptCount: number): number {
|
||||
const exponent = Math.max(0, Math.min(attemptCount - 1, 30));
|
||||
const delay = Math.min(this.retryMaxMs, this.retryBaseMs * 2 ** exponent);
|
||||
return Math.min(Number.MAX_SAFE_INTEGER, atMs + delay);
|
||||
}
|
||||
|
||||
private now(): number {
|
||||
const nowMs = this.clock();
|
||||
if (!Number.isSafeInteger(nowMs) || nowMs < 0) {
|
||||
throw new RangeError('clock must return a non-negative safe integer');
|
||||
}
|
||||
return nowMs;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import {
|
||||
assertAuthenticatedPrincipalActive,
|
||||
normalizeAuthenticatedPrincipal,
|
||||
type AuthenticatedPrincipal,
|
||||
} from '../domain/authenticatedPrincipal';
|
||||
import {
|
||||
APPROVED_ACTION_RECOVERY_STRONG_ASSURANCES,
|
||||
ApprovedActionRecoveryAuthorizationDeniedError,
|
||||
ApprovedActionRecoveryHumanRequiredError,
|
||||
ApprovedActionRecoveryNotFoundError,
|
||||
ApprovedActionRecoveryStrongAuthenticationRequiredError,
|
||||
MAX_APPROVED_ACTION_RECOVERY_AUTH_AGE_MS,
|
||||
createApprovedActionRecoveryAuthorizationFact,
|
||||
} from '../domain/approvedActionRecoveryAuthorization';
|
||||
import {
|
||||
assertApprovedActionEvidenceDigest,
|
||||
type ApprovedActionRecoveryDecision,
|
||||
} from '../domain/approvedActionRecovery';
|
||||
import { assertApprovedActionResultCode } from '../domain/approvedActionDispatchExecution';
|
||||
import { assertApprovalMutationId } from '../domain/approvalRequest';
|
||||
import type {
|
||||
ApprovedActionRecoveryRepository,
|
||||
ResolveApprovedActionRecoveryResult,
|
||||
} from '../ports/approvedActionRecoveryRepository';
|
||||
import type { ProjectPolicyEngine } from './projectPolicyEngine';
|
||||
|
||||
export interface ManuallyResolveApprovedActionRecoveryInput {
|
||||
dispatchId: string;
|
||||
expectedExecutionVersion: number;
|
||||
expectedRecoveryVersion: number;
|
||||
mutationId: string;
|
||||
decision: ApprovedActionRecoveryDecision;
|
||||
evidenceDigest?: string;
|
||||
reasonCode: string;
|
||||
principal: AuthenticatedPrincipal;
|
||||
}
|
||||
|
||||
function exactKeys(value: object, expected: readonly string[]): void {
|
||||
const keys = Object.keys(value).sort();
|
||||
const canonical = [...expected].sort();
|
||||
if (
|
||||
keys.length !== canonical.length ||
|
||||
keys.some((key, index) => key !== canonical[index])
|
||||
) {
|
||||
throw new TypeError('Manual recovery input shape is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
function assertVersion(name: string, value: number): void {
|
||||
if (!Number.isSafeInteger(value) || value < 0 || value > 2_147_483_647) {
|
||||
throw new RangeError(`${name} is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
export class ApprovedActionManualRecoveryService {
|
||||
constructor(
|
||||
private readonly repository: ApprovedActionRecoveryRepository,
|
||||
private readonly policy: ProjectPolicyEngine,
|
||||
private readonly clock: () => number = Date.now,
|
||||
) {}
|
||||
|
||||
async resolve(
|
||||
input: ManuallyResolveApprovedActionRecoveryInput,
|
||||
): Promise<ResolveApprovedActionRecoveryResult> {
|
||||
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
||||
throw new TypeError('Manual recovery input must be an object');
|
||||
}
|
||||
exactKeys(input, [
|
||||
'dispatchId',
|
||||
'expectedExecutionVersion',
|
||||
'expectedRecoveryVersion',
|
||||
'mutationId',
|
||||
'decision',
|
||||
...(input.evidenceDigest === undefined ? [] : ['evidenceDigest']),
|
||||
'reasonCode',
|
||||
'principal',
|
||||
]);
|
||||
assertApprovalMutationId(input.dispatchId);
|
||||
assertVersion('expectedExecutionVersion', input.expectedExecutionVersion);
|
||||
assertVersion('expectedRecoveryVersion', input.expectedRecoveryVersion);
|
||||
assertApprovalMutationId(input.mutationId);
|
||||
if (
|
||||
!['confirm_succeeded', 'confirm_failed', 'abandon_unknown'].includes(
|
||||
input.decision,
|
||||
)
|
||||
) {
|
||||
throw new TypeError('Manual recovery decision is invalid');
|
||||
}
|
||||
if (input.evidenceDigest !== undefined) {
|
||||
assertApprovedActionEvidenceDigest(input.evidenceDigest);
|
||||
}
|
||||
assertApprovedActionResultCode(input.reasonCode);
|
||||
const resolvedAtMs = this.now();
|
||||
const principal = normalizeAuthenticatedPrincipal(input.principal);
|
||||
assertAuthenticatedPrincipalActive(principal, resolvedAtMs);
|
||||
if (principal.subject.type !== 'user') {
|
||||
throw new ApprovedActionRecoveryHumanRequiredError();
|
||||
}
|
||||
if (
|
||||
!APPROVED_ACTION_RECOVERY_STRONG_ASSURANCES.includes(
|
||||
principal.assurance as (typeof APPROVED_ACTION_RECOVERY_STRONG_ASSURANCES)[number],
|
||||
) ||
|
||||
resolvedAtMs - principal.authenticatedAtMs >
|
||||
MAX_APPROVED_ACTION_RECOVERY_AUTH_AGE_MS
|
||||
) {
|
||||
throw new ApprovedActionRecoveryStrongAuthenticationRequiredError();
|
||||
}
|
||||
const snapshot = await this.repository.findById(input.dispatchId);
|
||||
if (!snapshot) throw new ApprovedActionRecoveryNotFoundError();
|
||||
const authorization = await this.policy.decideWithFence({
|
||||
projectId: snapshot.action.dispatch.projectId,
|
||||
subject: principal.subject,
|
||||
permission: 'approval.recover',
|
||||
});
|
||||
if (
|
||||
authorization.decision.effect !== 'allow' ||
|
||||
!authorization.fence ||
|
||||
authorization.fence.bindingVersion === null
|
||||
) {
|
||||
throw new ApprovedActionRecoveryAuthorizationDeniedError();
|
||||
}
|
||||
const authorizationFact = createApprovedActionRecoveryAuthorizationFact({
|
||||
dispatchId: input.dispatchId,
|
||||
projectId: snapshot.action.dispatch.projectId,
|
||||
mutationId: input.mutationId,
|
||||
resolvedBy: principal.subject,
|
||||
authenticationId: principal.authenticationId,
|
||||
assurance:
|
||||
principal.assurance as (typeof APPROVED_ACTION_RECOVERY_STRONG_ASSURANCES)[number],
|
||||
authenticatedAtMs: principal.authenticatedAtMs,
|
||||
projectVersion: authorization.fence.projectVersion,
|
||||
bindingVersion: authorization.fence.bindingVersion,
|
||||
authorizedAtMs: resolvedAtMs,
|
||||
});
|
||||
return this.repository.resolve({
|
||||
dispatchId: input.dispatchId,
|
||||
expectedExecutionVersion: input.expectedExecutionVersion,
|
||||
expectedRecoveryVersion: input.expectedRecoveryVersion,
|
||||
mutationId: input.mutationId,
|
||||
source: 'human',
|
||||
decision: input.decision,
|
||||
...(input.evidenceDigest === undefined
|
||||
? {}
|
||||
: { evidenceDigest: input.evidenceDigest }),
|
||||
reasonCode: input.reasonCode,
|
||||
resolvedBy: principal.subject,
|
||||
resolvedAtMs,
|
||||
authorizationFact,
|
||||
});
|
||||
}
|
||||
|
||||
private now(): number {
|
||||
const nowMs = this.clock();
|
||||
if (!Number.isSafeInteger(nowMs) || nowMs < 0) {
|
||||
throw new RangeError('clock must return a non-negative safe integer');
|
||||
}
|
||||
return nowMs;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
import { v7 as uuidV7 } from 'uuid';
|
||||
import {
|
||||
assertApprovedActionLeaseIdentity,
|
||||
assertApprovedActionResultCode,
|
||||
} from '../domain/approvedActionDispatchExecution';
|
||||
import {
|
||||
assertApprovedActionEvidenceDigest,
|
||||
assertApprovedActionRecoveryLeaseDuration,
|
||||
assertApprovedActionRecoveryPageSize,
|
||||
type ApprovedActionRecoveryCursor,
|
||||
} from '../domain/approvedActionRecovery';
|
||||
import type {
|
||||
ApprovedActionRecoveryEvidence,
|
||||
ApprovedActionRecoveryEvidenceProvider,
|
||||
} from '../ports/approvedActionRecoveryEvidenceProvider';
|
||||
import type { ApprovedActionRecoveryRepository } from '../ports/approvedActionRecoveryRepository';
|
||||
|
||||
const DEFAULT_LEASE_DURATION_MS = 30_000;
|
||||
const DEFAULT_RETRY_BASE_MS = 5_000;
|
||||
const DEFAULT_RETRY_MAX_MS = 5 * 60_000;
|
||||
|
||||
export interface ApprovedActionRecoveryReconcilerOptions {
|
||||
owner: string;
|
||||
leaseDurationMs?: number;
|
||||
retryBaseMs?: number;
|
||||
retryMaxMs?: number;
|
||||
clock?: () => number;
|
||||
createId?: () => string;
|
||||
}
|
||||
|
||||
export interface ApprovedActionRecoveryBatchSummary {
|
||||
scanned: number;
|
||||
claimed: number;
|
||||
verifiedSucceeded: number;
|
||||
verifiedFailed: number;
|
||||
deferred: number;
|
||||
manualRequired: number;
|
||||
executionActive: number;
|
||||
alreadyResolved: number;
|
||||
unavailable: number;
|
||||
truncated: boolean;
|
||||
nextCursor?: Readonly<ApprovedActionRecoveryCursor>;
|
||||
}
|
||||
|
||||
function assertPositiveInteger(name: string, value: number): void {
|
||||
if (!Number.isSafeInteger(value) || value < 1) {
|
||||
throw new RangeError(`${name} must be a positive safe integer`);
|
||||
}
|
||||
}
|
||||
|
||||
function exactKeys(value: object, expected: readonly string[]): boolean {
|
||||
const keys = Object.keys(value).sort();
|
||||
const canonical = [...expected].sort();
|
||||
return (
|
||||
keys.length === canonical.length &&
|
||||
keys.every((key, index) => key === canonical[index])
|
||||
);
|
||||
}
|
||||
|
||||
export class ApprovedActionRecoveryReconciler {
|
||||
private readonly providers = new Map<
|
||||
string,
|
||||
ApprovedActionRecoveryEvidenceProvider
|
||||
>();
|
||||
private readonly owner: string;
|
||||
private readonly leaseDurationMs: number;
|
||||
private readonly retryBaseMs: number;
|
||||
private readonly retryMaxMs: number;
|
||||
private readonly clock: () => number;
|
||||
private readonly createId: () => string;
|
||||
|
||||
constructor(
|
||||
private readonly repository: ApprovedActionRecoveryRepository,
|
||||
providers: readonly ApprovedActionRecoveryEvidenceProvider[],
|
||||
options: ApprovedActionRecoveryReconcilerOptions,
|
||||
) {
|
||||
assertApprovedActionLeaseIdentity(options.owner);
|
||||
this.owner = options.owner;
|
||||
this.leaseDurationMs = options.leaseDurationMs ?? DEFAULT_LEASE_DURATION_MS;
|
||||
this.retryBaseMs = options.retryBaseMs ?? DEFAULT_RETRY_BASE_MS;
|
||||
this.retryMaxMs = options.retryMaxMs ?? DEFAULT_RETRY_MAX_MS;
|
||||
this.clock = options.clock ?? Date.now;
|
||||
this.createId = options.createId ?? uuidV7;
|
||||
assertApprovedActionRecoveryLeaseDuration(this.leaseDurationMs);
|
||||
assertPositiveInteger('retryBaseMs', this.retryBaseMs);
|
||||
assertPositiveInteger('retryMaxMs', this.retryMaxMs);
|
||||
if (this.retryMaxMs < this.retryBaseMs) {
|
||||
throw new RangeError(
|
||||
'retryMaxMs must be greater than or equal to retryBaseMs',
|
||||
);
|
||||
}
|
||||
for (const provider of providers) {
|
||||
if (
|
||||
!provider ||
|
||||
typeof provider !== 'object' ||
|
||||
typeof provider.actionType !== 'string' ||
|
||||
provider.actionType.length < 1 ||
|
||||
provider.actionType.length > 64 ||
|
||||
!['automatic', 'manual_only'].includes(provider.capability) ||
|
||||
typeof provider.inspect !== 'function'
|
||||
) {
|
||||
throw new TypeError('Approved action recovery provider is invalid');
|
||||
}
|
||||
if (this.providers.has(provider.actionType)) {
|
||||
throw new TypeError(
|
||||
`Duplicate approved action recovery provider: ${provider.actionType}`,
|
||||
);
|
||||
}
|
||||
this.providers.set(provider.actionType, provider);
|
||||
}
|
||||
}
|
||||
|
||||
async reconcileBatch(
|
||||
options: { cursor?: ApprovedActionRecoveryCursor; limit?: number } = {},
|
||||
): Promise<Readonly<ApprovedActionRecoveryBatchSummary>> {
|
||||
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
||||
throw new TypeError('Approved action recovery options must be an object');
|
||||
}
|
||||
const expectedKeys = [
|
||||
...(options.cursor === undefined ? [] : ['cursor']),
|
||||
...(options.limit === undefined ? [] : ['limit']),
|
||||
];
|
||||
if (!exactKeys(options, expectedKeys)) {
|
||||
throw new TypeError('Approved action recovery options shape is invalid');
|
||||
}
|
||||
const limit = options.limit ?? 16;
|
||||
assertApprovedActionRecoveryPageSize(limit);
|
||||
const observedAtMs = this.now();
|
||||
const page = await this.repository.listDue({
|
||||
nowMs: observedAtMs,
|
||||
limit,
|
||||
...(options.cursor ? { cursor: options.cursor } : {}),
|
||||
});
|
||||
const summary: ApprovedActionRecoveryBatchSummary = {
|
||||
scanned: page.recoveries.length,
|
||||
claimed: 0,
|
||||
verifiedSucceeded: 0,
|
||||
verifiedFailed: 0,
|
||||
deferred: 0,
|
||||
manualRequired: 0,
|
||||
executionActive: 0,
|
||||
alreadyResolved: 0,
|
||||
unavailable: 0,
|
||||
truncated: page.truncated,
|
||||
...(page.nextCursor ? { nextCursor: page.nextCursor } : {}),
|
||||
};
|
||||
for (const candidate of page.recoveries) {
|
||||
await this.reconcileOne(candidate.action.dispatch.id, summary);
|
||||
}
|
||||
return Object.freeze(summary);
|
||||
}
|
||||
|
||||
private async reconcileOne(
|
||||
dispatchId: string,
|
||||
summary: ApprovedActionRecoveryBatchSummary,
|
||||
): Promise<void> {
|
||||
let claim;
|
||||
try {
|
||||
claim = await this.repository.claim({
|
||||
dispatchId,
|
||||
owner: this.owner,
|
||||
leaseToken: this.createId(),
|
||||
nowMs: this.now(),
|
||||
leaseDurationMs: this.leaseDurationMs,
|
||||
});
|
||||
} catch {
|
||||
summary.unavailable += 1;
|
||||
return;
|
||||
}
|
||||
if (claim.status === 'not_found') {
|
||||
summary.unavailable += 1;
|
||||
return;
|
||||
}
|
||||
if (claim.status !== 'claimed') {
|
||||
if (claim.status === 'manual_required') summary.manualRequired += 1;
|
||||
else if (claim.status === 'resolved') summary.alreadyResolved += 1;
|
||||
else if (claim.status === 'execution_active')
|
||||
summary.executionActive += 1;
|
||||
else summary.deferred += 1;
|
||||
return;
|
||||
}
|
||||
summary.claimed += 1;
|
||||
const snapshot = claim.snapshot;
|
||||
const provider = this.providers.get(
|
||||
snapshot.action.dispatch.action.actionType,
|
||||
);
|
||||
let evidence: ApprovedActionRecoveryEvidence;
|
||||
if (!provider || provider.capability === 'manual_only') {
|
||||
evidence = {
|
||||
finding: 'unsupported',
|
||||
resultCode: 'automatic_recovery_unsupported',
|
||||
};
|
||||
} else {
|
||||
try {
|
||||
evidence = await provider.inspect(
|
||||
Object.freeze({
|
||||
snapshot,
|
||||
idempotencyKey: snapshot.action.dispatch.id,
|
||||
observedAtMs: this.now(),
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
evidence = {
|
||||
finding: 'unavailable',
|
||||
resultCode: 'recovery_evidence_unavailable',
|
||||
};
|
||||
}
|
||||
try {
|
||||
this.assertEvidence(evidence);
|
||||
} catch {
|
||||
evidence = {
|
||||
finding: 'conflict',
|
||||
resultCode: 'recovery_evidence_invalid',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
evidence.finding === 'verified_succeeded' ||
|
||||
evidence.finding === 'verified_failed'
|
||||
) {
|
||||
try {
|
||||
const resolved = await this.repository.resolve({
|
||||
dispatchId,
|
||||
expectedExecutionVersion: snapshot.action.execution.version,
|
||||
expectedRecoveryVersion: snapshot.recovery.version,
|
||||
owner: this.owner,
|
||||
leaseToken: snapshot.recovery.leaseToken!,
|
||||
mutationId: this.createId(),
|
||||
source: 'automatic_evidence',
|
||||
decision:
|
||||
evidence.finding === 'verified_succeeded'
|
||||
? 'confirm_succeeded'
|
||||
: 'confirm_failed',
|
||||
evidenceDigest: evidence.evidenceDigest,
|
||||
reasonCode: evidence.resultCode,
|
||||
resolvedAtMs: this.now(),
|
||||
});
|
||||
if (resolved.status === 'not_found') {
|
||||
summary.unavailable += 1;
|
||||
} else if (resolved.status === 'already_terminal') {
|
||||
summary.alreadyResolved += 1;
|
||||
} else if (resolved.snapshot.action.execution.status === 'succeeded') {
|
||||
summary.verifiedSucceeded += 1;
|
||||
} else {
|
||||
summary.verifiedFailed += 1;
|
||||
}
|
||||
} catch {
|
||||
summary.unavailable += 1;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const retry = ['still_running', 'missing', 'unavailable'].includes(
|
||||
evidence.finding,
|
||||
);
|
||||
try {
|
||||
const observedAtMs = this.now();
|
||||
const recorded = await this.repository.recordFinding({
|
||||
dispatchId,
|
||||
expectedExecutionVersion: snapshot.action.execution.version,
|
||||
expectedRecoveryVersion: snapshot.recovery.version,
|
||||
owner: this.owner,
|
||||
leaseToken: snapshot.recovery.leaseToken!,
|
||||
findingMutationId: this.createId(),
|
||||
finding: evidence.finding,
|
||||
resultCode: evidence.resultCode,
|
||||
...(evidence.evidenceDigest
|
||||
? { evidenceDigest: evidence.evidenceDigest }
|
||||
: {}),
|
||||
observedAtMs,
|
||||
...(retry
|
||||
? {
|
||||
retryAtMs: this.nextRetryAt(
|
||||
observedAtMs,
|
||||
snapshot.recovery.findingCount + 1,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
if (recorded.recovery.status === 'manual_required') {
|
||||
summary.manualRequired += 1;
|
||||
} else {
|
||||
summary.deferred += 1;
|
||||
}
|
||||
} catch {
|
||||
summary.unavailable += 1;
|
||||
}
|
||||
}
|
||||
|
||||
private assertEvidence(
|
||||
value: unknown,
|
||||
): asserts value is ApprovedActionRecoveryEvidence {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new TypeError('Approved action recovery evidence is invalid');
|
||||
}
|
||||
if (
|
||||
!('finding' in value) ||
|
||||
![
|
||||
'verified_succeeded',
|
||||
'verified_failed',
|
||||
'still_running',
|
||||
'missing',
|
||||
'conflict',
|
||||
'unsupported',
|
||||
'unavailable',
|
||||
].includes(value.finding as string) ||
|
||||
!('resultCode' in value) ||
|
||||
typeof value.resultCode !== 'string'
|
||||
) {
|
||||
throw new TypeError('Approved action recovery evidence is invalid');
|
||||
}
|
||||
assertApprovedActionResultCode(value.resultCode);
|
||||
const verified =
|
||||
value.finding === 'verified_succeeded' ||
|
||||
value.finding === 'verified_failed';
|
||||
if (
|
||||
!exactKeys(
|
||||
value,
|
||||
verified || 'evidenceDigest' in value
|
||||
? ['finding', 'resultCode', 'evidenceDigest']
|
||||
: ['finding', 'resultCode'],
|
||||
)
|
||||
) {
|
||||
throw new TypeError('Approved action recovery evidence is invalid');
|
||||
}
|
||||
if (verified && !('evidenceDigest' in value)) {
|
||||
throw new TypeError('Verified recovery evidence has no digest');
|
||||
}
|
||||
if ('evidenceDigest' in value) {
|
||||
if (typeof value.evidenceDigest !== 'string') {
|
||||
throw new TypeError('Approved action recovery evidence is invalid');
|
||||
}
|
||||
assertApprovedActionEvidenceDigest(value.evidenceDigest);
|
||||
}
|
||||
}
|
||||
|
||||
private nextRetryAt(atMs: number, findingCount: number): number {
|
||||
const exponent = Math.max(0, Math.min(findingCount - 1, 30));
|
||||
const delay = Math.min(this.retryMaxMs, this.retryBaseMs * 2 ** exponent);
|
||||
return Math.min(Number.MAX_SAFE_INTEGER, atMs + delay);
|
||||
}
|
||||
|
||||
private now(): number {
|
||||
const nowMs = this.clock();
|
||||
if (!Number.isSafeInteger(nowMs) || nowMs < 0) {
|
||||
throw new RangeError('clock must return a non-negative safe integer');
|
||||
}
|
||||
return nowMs;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import type { ApprovedActionDispatchCursor } from '../domain/approvedActionDispatchExecution';
|
||||
import type { ApprovedActionRecoveryCursor } from '../domain/approvedActionRecovery';
|
||||
import type {
|
||||
ApprovedActionDispatchCycleOptions,
|
||||
ApprovedActionRecoveryCycleOptions,
|
||||
ApprovedActionRuntimeCycleSummary,
|
||||
ApprovedActionRuntimeSupervisor,
|
||||
} from './approvedActionRuntimeSupervisor';
|
||||
|
||||
export const MIN_APPROVED_ACTION_RUNTIME_INTERVAL_MS = 250;
|
||||
export const MAX_APPROVED_ACTION_RUNTIME_INTERVAL_MS = 24 * 60 * 60 * 1_000;
|
||||
export const MAX_APPROVED_ACTION_RUNTIME_INITIAL_DELAY_MS =
|
||||
24 * 60 * 60 * 1_000;
|
||||
export const MAX_APPROVED_ACTION_RUNTIME_STOP_TIMEOUT_MS = 60_000;
|
||||
|
||||
interface ScheduledTimer {
|
||||
unref?: () => void;
|
||||
}
|
||||
|
||||
export interface ApprovedActionRuntimeLifecycleScheduler {
|
||||
setTimeout(callback: () => void, delayMs: number): ScheduledTimer;
|
||||
clearTimeout(timer: ScheduledTimer): void;
|
||||
}
|
||||
|
||||
export interface ApprovedActionRuntimeLifecycleOptions {
|
||||
intervalMs: number;
|
||||
initialDelayMs?: number;
|
||||
stopTimeoutMs?: number;
|
||||
cycle?: {
|
||||
dispatch?: ApprovedActionDispatchCycleOptions;
|
||||
recovery?: ApprovedActionRecoveryCycleOptions;
|
||||
};
|
||||
scheduler?: ApprovedActionRuntimeLifecycleScheduler;
|
||||
onCycle?: (summary: Readonly<ApprovedActionRuntimeCycleSummary>) => void;
|
||||
onError?: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export type ApprovedActionRuntimeStopResult = 'drained' | 'timed_out';
|
||||
|
||||
const defaultScheduler: ApprovedActionRuntimeLifecycleScheduler = {
|
||||
setTimeout(callback, delayMs) {
|
||||
return setTimeout(callback, delayMs);
|
||||
},
|
||||
clearTimeout(timer) {
|
||||
clearTimeout(timer as ReturnType<typeof setTimeout>);
|
||||
},
|
||||
};
|
||||
|
||||
function assertIntegerBetween(
|
||||
name: string,
|
||||
value: number,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): void {
|
||||
if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
|
||||
throw new RangeError(`${name} must be between ${minimum} and ${maximum}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One timer serializes recovery and new dispatch work. It remains inert until
|
||||
* start(), never overlaps a slow cycle, resumes bounded keyset cursors, and
|
||||
* waits only a bounded time during shutdown.
|
||||
*/
|
||||
export class ApprovedActionRuntimeLifecycle {
|
||||
private readonly intervalMs: number;
|
||||
private readonly initialDelayMs: number;
|
||||
private readonly stopTimeoutMs: number;
|
||||
private readonly dispatchOptions: Omit<
|
||||
ApprovedActionDispatchCycleOptions,
|
||||
'cursor'
|
||||
>;
|
||||
private readonly recoveryOptions: Omit<
|
||||
ApprovedActionRecoveryCycleOptions,
|
||||
'cursor'
|
||||
>;
|
||||
private readonly scheduler: ApprovedActionRuntimeLifecycleScheduler;
|
||||
private readonly onCycle?: (
|
||||
summary: Readonly<ApprovedActionRuntimeCycleSummary>,
|
||||
) => void;
|
||||
private readonly onError?: (error: unknown) => void;
|
||||
private started = false;
|
||||
private timer?: ScheduledTimer;
|
||||
private inFlight?: Promise<void>;
|
||||
private dispatchCursor?: ApprovedActionDispatchCursor;
|
||||
private recoveryCursor?: ApprovedActionRecoveryCursor;
|
||||
|
||||
constructor(
|
||||
private readonly supervisor: Pick<
|
||||
ApprovedActionRuntimeSupervisor,
|
||||
'runCycle'
|
||||
>,
|
||||
options: ApprovedActionRuntimeLifecycleOptions,
|
||||
) {
|
||||
this.intervalMs = options.intervalMs;
|
||||
this.initialDelayMs = options.initialDelayMs ?? 0;
|
||||
this.stopTimeoutMs = options.stopTimeoutMs ?? 5_000;
|
||||
this.dispatchOptions = {
|
||||
...(options.cycle?.dispatch?.pageSize === undefined
|
||||
? {}
|
||||
: { pageSize: options.cycle.dispatch.pageSize }),
|
||||
...(options.cycle?.dispatch?.maxPages === undefined
|
||||
? {}
|
||||
: { maxPages: options.cycle.dispatch.maxPages }),
|
||||
};
|
||||
this.recoveryOptions = {
|
||||
...(options.cycle?.recovery?.pageSize === undefined
|
||||
? {}
|
||||
: { pageSize: options.cycle.recovery.pageSize }),
|
||||
...(options.cycle?.recovery?.maxPages === undefined
|
||||
? {}
|
||||
: { maxPages: options.cycle.recovery.maxPages }),
|
||||
};
|
||||
this.dispatchCursor =
|
||||
options.cycle?.dispatch?.cursor === undefined
|
||||
? undefined
|
||||
: { ...options.cycle.dispatch.cursor };
|
||||
this.recoveryCursor =
|
||||
options.cycle?.recovery?.cursor === undefined
|
||||
? undefined
|
||||
: { ...options.cycle.recovery.cursor };
|
||||
this.scheduler = options.scheduler ?? defaultScheduler;
|
||||
this.onCycle = options.onCycle;
|
||||
this.onError = options.onError;
|
||||
assertIntegerBetween(
|
||||
'intervalMs',
|
||||
this.intervalMs,
|
||||
MIN_APPROVED_ACTION_RUNTIME_INTERVAL_MS,
|
||||
MAX_APPROVED_ACTION_RUNTIME_INTERVAL_MS,
|
||||
);
|
||||
assertIntegerBetween(
|
||||
'initialDelayMs',
|
||||
this.initialDelayMs,
|
||||
0,
|
||||
MAX_APPROVED_ACTION_RUNTIME_INITIAL_DELAY_MS,
|
||||
);
|
||||
assertIntegerBetween(
|
||||
'stopTimeoutMs',
|
||||
this.stopTimeoutMs,
|
||||
1,
|
||||
MAX_APPROVED_ACTION_RUNTIME_STOP_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
|
||||
start(): boolean {
|
||||
if (this.started || this.inFlight) return false;
|
||||
this.started = true;
|
||||
this.schedule(this.initialDelayMs);
|
||||
return true;
|
||||
}
|
||||
|
||||
async stop(): Promise<ApprovedActionRuntimeStopResult> {
|
||||
this.started = false;
|
||||
if (this.timer) {
|
||||
this.scheduler.clearTimeout(this.timer);
|
||||
this.timer = undefined;
|
||||
}
|
||||
const inFlight = this.inFlight;
|
||||
if (!inFlight) return 'drained';
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
const result = await Promise.race<ApprovedActionRuntimeStopResult>([
|
||||
inFlight.then(() => 'drained' as const),
|
||||
new Promise<'timed_out'>((resolve) => {
|
||||
timeout = setTimeout(() => resolve('timed_out'), this.stopTimeoutMs);
|
||||
}),
|
||||
]);
|
||||
if (timeout) clearTimeout(timeout);
|
||||
return result;
|
||||
}
|
||||
|
||||
private schedule(delayMs: number): void {
|
||||
if (!this.started || this.timer) return;
|
||||
const timer = this.scheduler.setTimeout(() => {
|
||||
if (this.timer === timer) this.timer = undefined;
|
||||
this.run();
|
||||
}, delayMs);
|
||||
this.timer = timer;
|
||||
timer.unref?.();
|
||||
}
|
||||
|
||||
private run(): void {
|
||||
if (!this.started || this.inFlight) return;
|
||||
const inFlight = this.supervisor
|
||||
.runCycle({
|
||||
recovery: {
|
||||
...this.recoveryOptions,
|
||||
...(this.recoveryCursor === undefined
|
||||
? {}
|
||||
: { cursor: { ...this.recoveryCursor } }),
|
||||
},
|
||||
dispatch: {
|
||||
...this.dispatchOptions,
|
||||
...(this.dispatchCursor === undefined
|
||||
? {}
|
||||
: { cursor: { ...this.dispatchCursor } }),
|
||||
},
|
||||
})
|
||||
.then((summary) => {
|
||||
this.recoveryCursor =
|
||||
summary.recovery.remaining && summary.recovery.nextCursor
|
||||
? { ...summary.recovery.nextCursor }
|
||||
: undefined;
|
||||
this.dispatchCursor =
|
||||
summary.dispatch.remaining && summary.dispatch.nextCursor
|
||||
? { ...summary.dispatch.nextCursor }
|
||||
: undefined;
|
||||
this.notifyCycle(summary);
|
||||
})
|
||||
.catch((error) => this.notifyError(error))
|
||||
.then(() => undefined)
|
||||
.finally(() => {
|
||||
if (this.inFlight === inFlight) this.inFlight = undefined;
|
||||
if (this.started) this.schedule(this.intervalMs);
|
||||
});
|
||||
this.inFlight = inFlight;
|
||||
}
|
||||
|
||||
private notifyCycle(
|
||||
summary: Readonly<ApprovedActionRuntimeCycleSummary>,
|
||||
): void {
|
||||
try {
|
||||
this.onCycle?.(summary);
|
||||
} catch (error) {
|
||||
this.notifyError(error);
|
||||
}
|
||||
}
|
||||
|
||||
private notifyError(error: unknown): void {
|
||||
try {
|
||||
this.onError?.(error);
|
||||
} catch {
|
||||
// Diagnostics must not create another scheduler failure loop.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
import {
|
||||
assertApprovedActionPageSize,
|
||||
type ApprovedActionDispatchCursor,
|
||||
} from '../domain/approvedActionDispatchExecution';
|
||||
import {
|
||||
assertApprovedActionRecoveryPageSize,
|
||||
type ApprovedActionRecoveryCursor,
|
||||
} from '../domain/approvedActionRecovery';
|
||||
import type {
|
||||
ApprovedActionDispatchBatchSummary,
|
||||
ApprovedActionDispatcher,
|
||||
} from './approvedActionDispatcher';
|
||||
import type {
|
||||
ApprovedActionRecoveryBatchSummary,
|
||||
ApprovedActionRecoveryReconciler,
|
||||
} from './approvedActionRecoveryReconciler';
|
||||
|
||||
export const MAX_APPROVED_ACTION_RUNTIME_PAGES_PER_PHASE = 64;
|
||||
|
||||
export type ApprovedActionRuntimePhaseStopReason =
|
||||
| 'complete'
|
||||
| 'page_limit'
|
||||
| 'cursor_stalled';
|
||||
|
||||
export interface ApprovedActionDispatchCycleOptions {
|
||||
cursor?: ApprovedActionDispatchCursor;
|
||||
pageSize?: number;
|
||||
maxPages?: number;
|
||||
}
|
||||
|
||||
export interface ApprovedActionRecoveryCycleOptions {
|
||||
cursor?: ApprovedActionRecoveryCursor;
|
||||
pageSize?: number;
|
||||
maxPages?: number;
|
||||
}
|
||||
|
||||
export interface ApprovedActionRuntimeCycleOptions {
|
||||
dispatch?: ApprovedActionDispatchCycleOptions;
|
||||
recovery?: ApprovedActionRecoveryCycleOptions;
|
||||
}
|
||||
|
||||
export interface ApprovedActionDispatchCycleSummary
|
||||
extends Omit<ApprovedActionDispatchBatchSummary, 'truncated' | 'nextCursor'> {
|
||||
pages: number;
|
||||
stopReason: ApprovedActionRuntimePhaseStopReason;
|
||||
remaining: boolean;
|
||||
nextCursor?: Readonly<ApprovedActionDispatchCursor>;
|
||||
}
|
||||
|
||||
export interface ApprovedActionRecoveryCycleSummary
|
||||
extends Omit<ApprovedActionRecoveryBatchSummary, 'truncated' | 'nextCursor'> {
|
||||
pages: number;
|
||||
stopReason: ApprovedActionRuntimePhaseStopReason;
|
||||
remaining: boolean;
|
||||
nextCursor?: Readonly<ApprovedActionRecoveryCursor>;
|
||||
}
|
||||
|
||||
export interface ApprovedActionRuntimeCycleSummary {
|
||||
recovery: Readonly<ApprovedActionRecoveryCycleSummary>;
|
||||
dispatch: Readonly<ApprovedActionDispatchCycleSummary>;
|
||||
}
|
||||
|
||||
function exactKeys(value: object, expected: readonly string[]): boolean {
|
||||
const keys = Object.keys(value).sort();
|
||||
const canonical = [...expected].sort();
|
||||
return (
|
||||
keys.length === canonical.length &&
|
||||
keys.every((key, index) => key === canonical[index])
|
||||
);
|
||||
}
|
||||
|
||||
function assertOptionsObject(
|
||||
name: string,
|
||||
value: unknown,
|
||||
): asserts value is object {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new TypeError(`${name} must be an object`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertMaxPages(value: number): void {
|
||||
if (
|
||||
!Number.isSafeInteger(value) ||
|
||||
value < 1 ||
|
||||
value > MAX_APPROVED_ACTION_RUNTIME_PAGES_PER_PHASE
|
||||
) {
|
||||
throw new RangeError(
|
||||
'maxPages must be between 1 and MAX_APPROVED_ACTION_RUNTIME_PAGES_PER_PHASE',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function sameDispatchCursor(
|
||||
left: ApprovedActionDispatchCursor | undefined,
|
||||
right: Readonly<ApprovedActionDispatchCursor>,
|
||||
): boolean {
|
||||
return (
|
||||
left !== undefined &&
|
||||
left.eligibleAtMs === right.eligibleAtMs &&
|
||||
left.dispatchId === right.dispatchId
|
||||
);
|
||||
}
|
||||
|
||||
function sameRecoveryCursor(
|
||||
left: ApprovedActionRecoveryCursor | undefined,
|
||||
right: Readonly<ApprovedActionRecoveryCursor>,
|
||||
): boolean {
|
||||
return (
|
||||
left !== undefined &&
|
||||
left.nextScanAtMs === right.nextScanAtMs &&
|
||||
left.dispatchId === right.dispatchId
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeDispatchOptions(
|
||||
value: ApprovedActionDispatchCycleOptions = {},
|
||||
): Required<Pick<ApprovedActionDispatchCycleOptions, 'pageSize' | 'maxPages'>> &
|
||||
Pick<ApprovedActionDispatchCycleOptions, 'cursor'> {
|
||||
assertOptionsObject('dispatch cycle options', value);
|
||||
const expectedKeys = [
|
||||
...(value.cursor === undefined ? [] : ['cursor']),
|
||||
...(value.pageSize === undefined ? [] : ['pageSize']),
|
||||
...(value.maxPages === undefined ? [] : ['maxPages']),
|
||||
];
|
||||
if (!exactKeys(value, expectedKeys)) {
|
||||
throw new TypeError('dispatch cycle options shape is invalid');
|
||||
}
|
||||
const pageSize = value.pageSize ?? 16;
|
||||
const maxPages = value.maxPages ?? 4;
|
||||
assertApprovedActionPageSize(pageSize);
|
||||
assertMaxPages(maxPages);
|
||||
return {
|
||||
pageSize,
|
||||
maxPages,
|
||||
...(value.cursor === undefined ? {} : { cursor: { ...value.cursor } }),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRecoveryOptions(
|
||||
value: ApprovedActionRecoveryCycleOptions = {},
|
||||
): Required<Pick<ApprovedActionRecoveryCycleOptions, 'pageSize' | 'maxPages'>> &
|
||||
Pick<ApprovedActionRecoveryCycleOptions, 'cursor'> {
|
||||
assertOptionsObject('recovery cycle options', value);
|
||||
const expectedKeys = [
|
||||
...(value.cursor === undefined ? [] : ['cursor']),
|
||||
...(value.pageSize === undefined ? [] : ['pageSize']),
|
||||
...(value.maxPages === undefined ? [] : ['maxPages']),
|
||||
];
|
||||
if (!exactKeys(value, expectedKeys)) {
|
||||
throw new TypeError('recovery cycle options shape is invalid');
|
||||
}
|
||||
const pageSize = value.pageSize ?? 16;
|
||||
const maxPages = value.maxPages ?? 4;
|
||||
assertApprovedActionRecoveryPageSize(pageSize);
|
||||
assertMaxPages(maxPages);
|
||||
return {
|
||||
pageSize,
|
||||
maxPages,
|
||||
...(value.cursor === undefined ? {} : { cursor: { ...value.cursor } }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs one bounded SQLite control-plane cycle. Recovery is deliberately first:
|
||||
* if the recovery index cannot be read, the cycle does not create more action
|
||||
* side effects. The class owns no timer and is safe to embed in other profiles.
|
||||
*/
|
||||
export class ApprovedActionRuntimeSupervisor {
|
||||
constructor(
|
||||
private readonly dispatcher: Pick<
|
||||
ApprovedActionDispatcher,
|
||||
'dispatchBatch'
|
||||
>,
|
||||
private readonly reconciler: Pick<
|
||||
ApprovedActionRecoveryReconciler,
|
||||
'reconcileBatch'
|
||||
>,
|
||||
) {}
|
||||
|
||||
async runCycle(
|
||||
options: ApprovedActionRuntimeCycleOptions = {},
|
||||
): Promise<Readonly<ApprovedActionRuntimeCycleSummary>> {
|
||||
assertOptionsObject('approved action runtime options', options);
|
||||
const expectedKeys = [
|
||||
...(options.dispatch === undefined ? [] : ['dispatch']),
|
||||
...(options.recovery === undefined ? [] : ['recovery']),
|
||||
];
|
||||
if (!exactKeys(options, expectedKeys)) {
|
||||
throw new TypeError('approved action runtime options shape is invalid');
|
||||
}
|
||||
const recoveryOptions = normalizeRecoveryOptions(options.recovery);
|
||||
const dispatchOptions = normalizeDispatchOptions(options.dispatch);
|
||||
const recovery = await this.runRecovery(recoveryOptions);
|
||||
const dispatch = await this.runDispatch(dispatchOptions);
|
||||
return Object.freeze({ recovery, dispatch });
|
||||
}
|
||||
|
||||
private async runDispatch(
|
||||
options: ReturnType<typeof normalizeDispatchOptions>,
|
||||
): Promise<Readonly<ApprovedActionDispatchCycleSummary>> {
|
||||
const total: ApprovedActionDispatchCycleSummary = {
|
||||
pages: 0,
|
||||
scanned: 0,
|
||||
claimed: 0,
|
||||
started: 0,
|
||||
succeeded: 0,
|
||||
failed: 0,
|
||||
blocked: 0,
|
||||
retrying: 0,
|
||||
deferred: 0,
|
||||
recoveryRequired: 0,
|
||||
alreadyTerminal: 0,
|
||||
unavailable: 0,
|
||||
stopReason: 'complete',
|
||||
remaining: false,
|
||||
};
|
||||
let cursor = options.cursor;
|
||||
for (let pageNumber = 0; pageNumber < options.maxPages; pageNumber += 1) {
|
||||
const page = await this.dispatcher.dispatchBatch({
|
||||
...(cursor === undefined ? {} : { cursor }),
|
||||
limit: options.pageSize,
|
||||
});
|
||||
total.pages += 1;
|
||||
total.scanned += page.scanned;
|
||||
total.claimed += page.claimed;
|
||||
total.started += page.started;
|
||||
total.succeeded += page.succeeded;
|
||||
total.failed += page.failed;
|
||||
total.blocked += page.blocked;
|
||||
total.retrying += page.retrying;
|
||||
total.deferred += page.deferred;
|
||||
total.recoveryRequired += page.recoveryRequired;
|
||||
total.alreadyTerminal += page.alreadyTerminal;
|
||||
total.unavailable += page.unavailable;
|
||||
if (!page.truncated) return Object.freeze(total);
|
||||
if (!page.nextCursor || sameDispatchCursor(cursor, page.nextCursor)) {
|
||||
total.stopReason = 'cursor_stalled';
|
||||
total.remaining = true;
|
||||
if (page.nextCursor) total.nextCursor = { ...page.nextCursor };
|
||||
return Object.freeze(total);
|
||||
}
|
||||
cursor = { ...page.nextCursor };
|
||||
if (pageNumber === options.maxPages - 1) {
|
||||
total.stopReason = 'page_limit';
|
||||
total.remaining = true;
|
||||
total.nextCursor = cursor;
|
||||
return Object.freeze(total);
|
||||
}
|
||||
}
|
||||
return Object.freeze(total);
|
||||
}
|
||||
|
||||
private async runRecovery(
|
||||
options: ReturnType<typeof normalizeRecoveryOptions>,
|
||||
): Promise<Readonly<ApprovedActionRecoveryCycleSummary>> {
|
||||
const total: ApprovedActionRecoveryCycleSummary = {
|
||||
pages: 0,
|
||||
scanned: 0,
|
||||
claimed: 0,
|
||||
verifiedSucceeded: 0,
|
||||
verifiedFailed: 0,
|
||||
deferred: 0,
|
||||
manualRequired: 0,
|
||||
executionActive: 0,
|
||||
alreadyResolved: 0,
|
||||
unavailable: 0,
|
||||
stopReason: 'complete',
|
||||
remaining: false,
|
||||
};
|
||||
let cursor = options.cursor;
|
||||
for (let pageNumber = 0; pageNumber < options.maxPages; pageNumber += 1) {
|
||||
const page = await this.reconciler.reconcileBatch({
|
||||
...(cursor === undefined ? {} : { cursor }),
|
||||
limit: options.pageSize,
|
||||
});
|
||||
total.pages += 1;
|
||||
total.scanned += page.scanned;
|
||||
total.claimed += page.claimed;
|
||||
total.verifiedSucceeded += page.verifiedSucceeded;
|
||||
total.verifiedFailed += page.verifiedFailed;
|
||||
total.deferred += page.deferred;
|
||||
total.manualRequired += page.manualRequired;
|
||||
total.executionActive += page.executionActive;
|
||||
total.alreadyResolved += page.alreadyResolved;
|
||||
total.unavailable += page.unavailable;
|
||||
if (!page.truncated) return Object.freeze(total);
|
||||
if (!page.nextCursor || sameRecoveryCursor(cursor, page.nextCursor)) {
|
||||
total.stopReason = 'cursor_stalled';
|
||||
total.remaining = true;
|
||||
if (page.nextCursor) total.nextCursor = { ...page.nextCursor };
|
||||
return Object.freeze(total);
|
||||
}
|
||||
cursor = { ...page.nextCursor };
|
||||
if (pageNumber === options.maxPages - 1) {
|
||||
total.stopReason = 'page_limit';
|
||||
total.remaining = true;
|
||||
total.nextCursor = cursor;
|
||||
return Object.freeze(total);
|
||||
}
|
||||
}
|
||||
return Object.freeze(total);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import {
|
||||
APPROVED_RUN_ACTION_TYPE,
|
||||
APPROVED_RUN_RECEIPT_RESULT_CODE,
|
||||
ApprovedRunActionBindingConflictError,
|
||||
InvalidApprovedRunActionError,
|
||||
digestApprovedRunCreationPlan,
|
||||
normalizeApprovedRunCreationPlan,
|
||||
type ApprovedRunCreationPlan,
|
||||
} from '../domain/approvedRunAction';
|
||||
import type { ApprovedActionDispatchRecord } from '../domain/approvalRequest';
|
||||
import type {
|
||||
ApprovedActionExecutionContext,
|
||||
ApprovedActionExecutionResult,
|
||||
ApprovedActionHandler,
|
||||
ApprovedActionInspectionResult,
|
||||
} from '../ports/approvedActionHandler';
|
||||
import type { ApprovedRunActionPlanResolver } from '../ports/approvedRunActionPlanResolver';
|
||||
import type { ApprovedRunActionRepository } from '../ports/approvedRunActionRepository';
|
||||
|
||||
export class ApprovedRunActionHandler implements ApprovedActionHandler {
|
||||
readonly actionType = APPROVED_RUN_ACTION_TYPE;
|
||||
|
||||
constructor(
|
||||
private readonly plans: ApprovedRunActionPlanResolver,
|
||||
private readonly repository: ApprovedRunActionRepository,
|
||||
) {}
|
||||
|
||||
async inspect(
|
||||
dispatch: Readonly<ApprovedActionDispatchRecord>,
|
||||
): Promise<ApprovedActionInspectionResult> {
|
||||
if (dispatch.action.actionType !== this.actionType) {
|
||||
return { status: 'blocked', resultCode: 'approved_run_type_mismatch' };
|
||||
}
|
||||
const plan = await this.plans.resolve(dispatch.action.actionRef);
|
||||
if (!plan) {
|
||||
return { status: 'retry', resultCode: 'approved_run_plan_missing' };
|
||||
}
|
||||
try {
|
||||
const normalized = normalizeApprovedRunCreationPlan(plan);
|
||||
if (
|
||||
normalized.actionRef !== dispatch.action.actionRef ||
|
||||
normalized.projectId !== dispatch.projectId
|
||||
) {
|
||||
return {
|
||||
status: 'blocked',
|
||||
resultCode: 'approved_run_plan_binding_invalid',
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: 'ready',
|
||||
actionDigest: digestApprovedRunCreationPlan(normalized),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidApprovedRunActionError) {
|
||||
return { status: 'blocked', resultCode: 'approved_run_plan_invalid' };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async execute(
|
||||
context: Readonly<ApprovedActionExecutionContext>,
|
||||
): Promise<ApprovedActionExecutionResult> {
|
||||
if (!this.contextIsBound(context)) {
|
||||
return { outcome: 'failed', resultCode: 'approved_run_fence_invalid' };
|
||||
}
|
||||
const plan = await this.plans.resolve(context.dispatch.action.actionRef);
|
||||
if (!plan) {
|
||||
return {
|
||||
outcome: 'failed',
|
||||
resultCode: 'approved_run_plan_disappeared',
|
||||
};
|
||||
}
|
||||
let normalized: Readonly<ApprovedRunCreationPlan>;
|
||||
try {
|
||||
normalized = normalizeApprovedRunCreationPlan(plan);
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidApprovedRunActionError) {
|
||||
return { outcome: 'failed', resultCode: 'approved_run_plan_changed' };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (
|
||||
normalized.actionRef !== context.dispatch.action.actionRef ||
|
||||
normalized.projectId !== context.dispatch.projectId ||
|
||||
digestApprovedRunCreationPlan(normalized) !==
|
||||
context.dispatch.action.actionDigest
|
||||
) {
|
||||
return { outcome: 'failed', resultCode: 'approved_run_plan_changed' };
|
||||
}
|
||||
try {
|
||||
await this.repository.create({
|
||||
snapshot: Object.freeze({
|
||||
dispatch: context.dispatch,
|
||||
execution: context.execution,
|
||||
}),
|
||||
plan: normalized,
|
||||
});
|
||||
return {
|
||||
outcome: 'succeeded',
|
||||
resultCode: APPROVED_RUN_RECEIPT_RESULT_CODE,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof ApprovedRunActionBindingConflictError) {
|
||||
return {
|
||||
outcome: 'failed',
|
||||
resultCode: 'approved_run_receipt_conflict',
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private contextIsBound(
|
||||
context: Readonly<ApprovedActionExecutionContext>,
|
||||
): boolean {
|
||||
return (
|
||||
context.dispatch.action.actionType === this.actionType &&
|
||||
context.execution.status === 'executing' &&
|
||||
context.execution.dispatchId === context.dispatch.id &&
|
||||
context.execution.projectId === context.dispatch.projectId &&
|
||||
context.execution.startedAtMs !== null &&
|
||||
context.execution.leaseOwner === context.fence.owner &&
|
||||
context.execution.leaseToken === context.fence.leaseToken &&
|
||||
context.execution.version === context.fence.version &&
|
||||
context.idempotencyKey === context.dispatch.id
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type {
|
||||
AcknowledgeRemoteRunRunningCommand,
|
||||
AcknowledgeRemoteRunStartingCommand,
|
||||
FailRemoteRunStartCommand,
|
||||
RemoteRunActivationResult,
|
||||
} from './remoteRunActivationService';
|
||||
import { RemoteRunActivationService } from './remoteRunActivationService';
|
||||
import type { AuthenticatedWorkerPrincipal } from './workerControlService';
|
||||
import type { WorkerRemoteRunActivationClient } from '../ports/workerRemoteRunActivationClient';
|
||||
|
||||
/** The transport authenticates once; Worker request bodies cannot select a principal. */
|
||||
export class BoundWorkerRemoteRunActivationClient
|
||||
implements WorkerRemoteRunActivationClient
|
||||
{
|
||||
constructor(
|
||||
private readonly service: RemoteRunActivationService,
|
||||
private readonly principal: AuthenticatedWorkerPrincipal,
|
||||
) {}
|
||||
|
||||
acknowledgeStarting(
|
||||
command: AcknowledgeRemoteRunStartingCommand,
|
||||
): Promise<RemoteRunActivationResult> {
|
||||
return this.service.acknowledgeStarting(this.principal, command);
|
||||
}
|
||||
|
||||
acknowledgeRunning(
|
||||
command: AcknowledgeRemoteRunRunningCommand,
|
||||
): Promise<RemoteRunActivationResult> {
|
||||
return this.service.acknowledgeRunning(this.principal, command);
|
||||
}
|
||||
|
||||
failStart(
|
||||
command: FailRemoteRunStartCommand,
|
||||
): Promise<RemoteRunActivationResult> {
|
||||
return this.service.failStart(this.principal, command);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { WorkerRemoteRunCompletionClient } from '../ports/workerRemoteRunCompletionClient';
|
||||
import type { PrimaryRunCompletionResult } from './primaryRunCompletionService';
|
||||
import {
|
||||
RemoteRunCompletionService,
|
||||
type RemoteRunCompletionCommand,
|
||||
} from './remoteRunCompletionService';
|
||||
import type { AuthenticatedWorkerPrincipal } from './workerControlService';
|
||||
|
||||
/** The transport authenticates once; Worker request bodies cannot select a principal. */
|
||||
export class BoundWorkerRemoteRunCompletionClient
|
||||
implements WorkerRemoteRunCompletionClient
|
||||
{
|
||||
constructor(
|
||||
private readonly service: RemoteRunCompletionService,
|
||||
private readonly principal: AuthenticatedWorkerPrincipal,
|
||||
) {}
|
||||
|
||||
complete(
|
||||
command: RemoteRunCompletionCommand,
|
||||
): Promise<PrimaryRunCompletionResult> {
|
||||
return this.service.complete(this.principal, command);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { RunDispatchLeaseRecord } from '../domain/runDispatchLease';
|
||||
import type { ReleaseRunDispatchLeaseResult } from '../ports/runDispatchLeaseRepository';
|
||||
import type { WorkerRunLeaseClient } from '../ports/workerRunLeaseClient';
|
||||
import type {
|
||||
FencedRunDispatchLeaseRequest,
|
||||
ReleaseRunDispatchLeaseRequest,
|
||||
} from './runDispatchLeaseService';
|
||||
import { RunDispatchLeaseService } from './runDispatchLeaseService';
|
||||
import type { AuthenticatedWorkerPrincipal } from './workerControlService';
|
||||
|
||||
/**
|
||||
* Transport seam: the authenticated principal is fixed when the client is
|
||||
* constructed and can never be supplied by a Worker request body.
|
||||
*/
|
||||
export class BoundWorkerRunLeaseClient implements WorkerRunLeaseClient {
|
||||
constructor(
|
||||
private readonly service: RunDispatchLeaseService,
|
||||
private readonly principal: AuthenticatedWorkerPrincipal,
|
||||
) {}
|
||||
|
||||
renew(
|
||||
request: FencedRunDispatchLeaseRequest,
|
||||
): Promise<RunDispatchLeaseRecord> {
|
||||
return this.service.renew(this.principal, request);
|
||||
}
|
||||
|
||||
release(
|
||||
request: ReleaseRunDispatchLeaseRequest,
|
||||
): Promise<ReleaseRunDispatchLeaseResult> {
|
||||
return this.service.release(this.principal, request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import type { DeploymentProfile } from '../domain/deploymentProfile';
|
||||
|
||||
export type ClusterControlActivationState =
|
||||
| 'disabled'
|
||||
| 'schema_ready'
|
||||
| 'reconciled'
|
||||
| 'active'
|
||||
| 'failed'
|
||||
| 'stopped';
|
||||
|
||||
export interface ClusterControlReadinessEvidence {
|
||||
readonly contractName: string;
|
||||
readonly contractVersion: number;
|
||||
readonly serverMajor: number;
|
||||
readonly migrationIds: readonly string[];
|
||||
}
|
||||
|
||||
export interface ClusterControlReadinessProbe {
|
||||
assertReady(): Promise<ClusterControlReadinessEvidence>;
|
||||
}
|
||||
|
||||
export interface ClusterControlStartupRecoverySummary {
|
||||
readonly safe: boolean;
|
||||
readonly remaining: number;
|
||||
readonly failed: number;
|
||||
}
|
||||
|
||||
export type ClusterControlStopResult = 'stopped' | 'timed_out';
|
||||
|
||||
export interface ClusterControlActivationStack {
|
||||
reconcile(): Promise<ClusterControlStartupRecoverySummary>;
|
||||
startLifecycles(): Promise<boolean>;
|
||||
installAdmission(): () => void;
|
||||
stop(): Promise<ClusterControlStopResult>;
|
||||
}
|
||||
|
||||
export interface ClusterControlActivationAudit {
|
||||
readonly state: ClusterControlActivationState;
|
||||
readonly contractName?: string;
|
||||
readonly contractVersion?: number;
|
||||
readonly serverMajor?: number;
|
||||
readonly migrationCount?: number;
|
||||
readonly recovery?: ClusterControlStartupRecoverySummary;
|
||||
}
|
||||
|
||||
export interface ClusterControlRuntimeActivationOptions {
|
||||
readonly enabled?: boolean;
|
||||
readonly profile: DeploymentProfile;
|
||||
readonly readiness: ClusterControlReadinessProbe;
|
||||
readonly create: (
|
||||
evidence: ClusterControlReadinessEvidence,
|
||||
) => ClusterControlActivationStack;
|
||||
readonly audit: (
|
||||
record: ClusterControlActivationAudit,
|
||||
) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export type ClusterControlRuntimeActivationResult =
|
||||
| { readonly status: 'disabled'; stop(): Promise<'stopped'> }
|
||||
| {
|
||||
readonly status: 'active';
|
||||
readonly evidence: ClusterControlReadinessEvidence;
|
||||
readonly recovery: ClusterControlStartupRecoverySummary;
|
||||
stop(): Promise<ClusterControlStopResult>;
|
||||
};
|
||||
|
||||
const DISABLED_STOP = async (): Promise<'stopped'> => 'stopped';
|
||||
|
||||
function auditEvidence(
|
||||
evidence: ClusterControlReadinessEvidence,
|
||||
): Pick<
|
||||
ClusterControlActivationAudit,
|
||||
'contractName' | 'contractVersion' | 'serverMajor' | 'migrationCount'
|
||||
> {
|
||||
return {
|
||||
contractName: evidence.contractName,
|
||||
contractVersion: evidence.contractVersion,
|
||||
serverMajor: evidence.serverMajor,
|
||||
migrationCount: evidence.migrationIds.length,
|
||||
};
|
||||
}
|
||||
|
||||
function assertSafeRecovery(
|
||||
recovery: ClusterControlStartupRecoverySummary,
|
||||
): void {
|
||||
if (!recovery.safe || recovery.remaining !== 0 || recovery.failed !== 0) {
|
||||
throw new Error('Cluster-control startup recovery did not converge safely');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforces readiness -> assembly -> recovery -> lifecycle -> admission order.
|
||||
* The factory is deliberately called only after schema/role readiness, so an
|
||||
* invalid cluster database cannot even construct business repositories.
|
||||
*/
|
||||
export async function activateClusterControlRuntime(
|
||||
options: ClusterControlRuntimeActivationOptions,
|
||||
): Promise<ClusterControlRuntimeActivationResult> {
|
||||
const enabled = options.enabled ?? false;
|
||||
if (!enabled) {
|
||||
await options.audit({ state: 'disabled' });
|
||||
return { status: 'disabled', stop: DISABLED_STOP };
|
||||
}
|
||||
if (options.profile !== 'cluster-control') {
|
||||
throw new TypeError(
|
||||
`Deployment profile ${options.profile} cannot activate cluster-control`,
|
||||
);
|
||||
}
|
||||
|
||||
let evidence: ClusterControlReadinessEvidence | undefined;
|
||||
let stack: ClusterControlActivationStack | undefined;
|
||||
let disposeAdmission: (() => void) | undefined;
|
||||
try {
|
||||
evidence = await options.readiness.assertReady();
|
||||
await options.audit({ state: 'schema_ready', ...auditEvidence(evidence) });
|
||||
stack = options.create(evidence);
|
||||
const recovery = await stack.reconcile();
|
||||
assertSafeRecovery(recovery);
|
||||
await options.audit({
|
||||
state: 'reconciled',
|
||||
...auditEvidence(evidence),
|
||||
recovery,
|
||||
});
|
||||
if (!(await stack.startLifecycles())) {
|
||||
throw new Error('Cluster-control lifecycles did not start');
|
||||
}
|
||||
disposeAdmission = stack.installAdmission();
|
||||
await options.audit({
|
||||
state: 'active',
|
||||
...auditEvidence(evidence),
|
||||
recovery,
|
||||
});
|
||||
|
||||
let stopPromise: Promise<ClusterControlStopResult> | undefined;
|
||||
return {
|
||||
status: 'active',
|
||||
evidence,
|
||||
recovery,
|
||||
stop() {
|
||||
if (stopPromise) return stopPromise;
|
||||
stopPromise = (async () => {
|
||||
let admissionError: unknown;
|
||||
try {
|
||||
disposeAdmission?.();
|
||||
} catch (error) {
|
||||
admissionError = error;
|
||||
}
|
||||
disposeAdmission = undefined;
|
||||
const result = await stack!.stop();
|
||||
if (admissionError) {
|
||||
try {
|
||||
await options.audit({
|
||||
state: 'failed',
|
||||
...auditEvidence(evidence!),
|
||||
});
|
||||
} catch {
|
||||
// Preserve the admission cleanup failure after stopping the stack.
|
||||
}
|
||||
throw admissionError;
|
||||
}
|
||||
try {
|
||||
await options.audit({
|
||||
state: 'stopped',
|
||||
...auditEvidence(evidence!),
|
||||
recovery,
|
||||
});
|
||||
} catch {
|
||||
// Diagnostic failure cannot reverse stopped ownership.
|
||||
}
|
||||
return result;
|
||||
})();
|
||||
return stopPromise;
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
try {
|
||||
disposeAdmission?.();
|
||||
} catch {
|
||||
// Preserve the activation failure and continue stopping the stack.
|
||||
}
|
||||
if (stack) {
|
||||
try {
|
||||
await stack.stop();
|
||||
} catch {
|
||||
// Preserve the activation failure after best-effort cleanup.
|
||||
}
|
||||
}
|
||||
try {
|
||||
await options.audit({
|
||||
state: 'failed',
|
||||
...(evidence ? auditEvidence(evidence) : {}),
|
||||
});
|
||||
} catch {
|
||||
// Diagnostic failure cannot replace the activation failure.
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { RunAttemptStatus } from '../domain/run';
|
||||
|
||||
export class RunCommandError extends Error {
|
||||
constructor(message: string, public readonly code: string) {
|
||||
super(message);
|
||||
this.name = new.target.name;
|
||||
}
|
||||
}
|
||||
|
||||
export class RunNotFoundError extends RunCommandError {
|
||||
constructor(public readonly runId: string) {
|
||||
super('Run does not exist', 'RUN_NOT_FOUND');
|
||||
}
|
||||
}
|
||||
|
||||
export class RunAttemptNotFoundError extends RunCommandError {
|
||||
constructor(public readonly attemptId: string) {
|
||||
super('RunAttempt does not exist', 'RUN_ATTEMPT_NOT_FOUND');
|
||||
}
|
||||
}
|
||||
|
||||
export class RunAttemptConcurrentWriteError extends RunCommandError {
|
||||
constructor(
|
||||
public readonly attemptId: string,
|
||||
public readonly expectedStatus: RunAttemptStatus,
|
||||
public readonly expectedCallbackSequence: number,
|
||||
) {
|
||||
super(
|
||||
'RunAttempt changed while applying the command',
|
||||
'RUN_ATTEMPT_CONCURRENT_WRITE',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import { isTerminalRunAttemptStatus } from '../domain/runStateMachine';
|
||||
import type {
|
||||
CompletionReceiptDirectoryEntry,
|
||||
CompletionReceiptOrphanDirectory,
|
||||
CompletionReceiptOwnership,
|
||||
CompletionReceiptOwnershipSource,
|
||||
} from '../ports/completionReceiptOrphanMaintenance';
|
||||
|
||||
export const MAX_ORPHAN_AUDIT_SHARDS = 32;
|
||||
export const MAX_ORPHAN_AUDIT_ENTRIES_PER_SHARD = 64;
|
||||
|
||||
export type CompletionReceiptOrphanAuditMode = 'audit' | 'quarantine';
|
||||
export type CompletionReceiptOrphanCategory =
|
||||
| 'journaled'
|
||||
| 'active_attempt'
|
||||
| 'young_terminal_attempt'
|
||||
| 'terminal_orphan'
|
||||
| 'young_unknown_receipt'
|
||||
| 'unknown_receipt'
|
||||
| 'young_temporary'
|
||||
| 'stale_temporary'
|
||||
| 'young_unknown_entry'
|
||||
| 'unknown_entry'
|
||||
| 'unsafe_entry';
|
||||
export type CompletionReceiptOrphanAction =
|
||||
| 'retained'
|
||||
| 'eligible'
|
||||
| 'blocked_overflow'
|
||||
| 'quarantined'
|
||||
| 'changed';
|
||||
|
||||
export interface CompletionReceiptOrphanAuditEntry {
|
||||
shard: string;
|
||||
name: string;
|
||||
category: CompletionReceiptOrphanCategory;
|
||||
action: CompletionReceiptOrphanAction;
|
||||
ageMs: number;
|
||||
attemptId?: string;
|
||||
attemptStatus?: string;
|
||||
quarantineRef?: string;
|
||||
}
|
||||
|
||||
export interface CompletionReceiptOrphanAuditReport {
|
||||
schemaVersion: 1;
|
||||
mode: CompletionReceiptOrphanAuditMode;
|
||||
observedAtMs: number;
|
||||
minimumAgeMs: number;
|
||||
startShard: string;
|
||||
nextShard: string;
|
||||
wrapped: boolean;
|
||||
shardCount: number;
|
||||
maxEntriesPerShard: number;
|
||||
scannedEntries: number;
|
||||
overflowShards: readonly string[];
|
||||
entries: readonly CompletionReceiptOrphanAuditEntry[];
|
||||
counts: Readonly<Record<CompletionReceiptOrphanCategory, number>>;
|
||||
}
|
||||
|
||||
export interface CompletionReceiptOrphanAuditorOptions {
|
||||
mode?: CompletionReceiptOrphanAuditMode;
|
||||
observedAtMs?: number;
|
||||
minimumAgeMs?: number;
|
||||
startShard?: number;
|
||||
shardCount?: number;
|
||||
maxEntriesPerShard?: number;
|
||||
clock?: { now(): number };
|
||||
}
|
||||
|
||||
const CATEGORIES: readonly CompletionReceiptOrphanCategory[] = [
|
||||
'journaled',
|
||||
'active_attempt',
|
||||
'young_terminal_attempt',
|
||||
'terminal_orphan',
|
||||
'young_unknown_receipt',
|
||||
'unknown_receipt',
|
||||
'young_temporary',
|
||||
'stale_temporary',
|
||||
'young_unknown_entry',
|
||||
'unknown_entry',
|
||||
'unsafe_entry',
|
||||
];
|
||||
|
||||
function assertIntegerBetween(
|
||||
name: string,
|
||||
value: number,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): void {
|
||||
if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
|
||||
throw new RangeError(`${name} must be between ${minimum} and ${maximum}`);
|
||||
}
|
||||
}
|
||||
|
||||
function shardName(value: number): string {
|
||||
return value.toString(16).padStart(2, '0');
|
||||
}
|
||||
|
||||
function classify(
|
||||
entry: CompletionReceiptDirectoryEntry,
|
||||
ownership: CompletionReceiptOwnership | undefined,
|
||||
oldEnough: boolean,
|
||||
): { category: CompletionReceiptOrphanCategory; eligible: boolean } {
|
||||
if (entry.kind === 'unsafe') {
|
||||
return { category: 'unsafe_entry', eligible: false };
|
||||
}
|
||||
if (entry.kind === 'temporary') {
|
||||
return oldEnough
|
||||
? { category: 'stale_temporary', eligible: true }
|
||||
: { category: 'young_temporary', eligible: false };
|
||||
}
|
||||
if (entry.kind === 'unknown') {
|
||||
return oldEnough
|
||||
? { category: 'unknown_entry', eligible: true }
|
||||
: { category: 'young_unknown_entry', eligible: false };
|
||||
}
|
||||
if (ownership?.journalState) {
|
||||
return { category: 'journaled', eligible: false };
|
||||
}
|
||||
if (ownership?.attemptStatus) {
|
||||
if (!isTerminalRunAttemptStatus(ownership.attemptStatus)) {
|
||||
return { category: 'active_attempt', eligible: false };
|
||||
}
|
||||
return oldEnough
|
||||
? { category: 'terminal_orphan', eligible: true }
|
||||
: { category: 'young_terminal_attempt', eligible: false };
|
||||
}
|
||||
return oldEnough
|
||||
? { category: 'unknown_receipt', eligible: true }
|
||||
: { category: 'young_unknown_receipt', eligible: false };
|
||||
}
|
||||
|
||||
export class CompletionReceiptOrphanAuditor {
|
||||
constructor(
|
||||
private readonly directory: CompletionReceiptOrphanDirectory,
|
||||
private readonly ownership: CompletionReceiptOwnershipSource,
|
||||
) {}
|
||||
|
||||
async run(
|
||||
options: CompletionReceiptOrphanAuditorOptions = {},
|
||||
): Promise<CompletionReceiptOrphanAuditReport> {
|
||||
const mode = options.mode ?? 'audit';
|
||||
if (mode !== 'audit' && mode !== 'quarantine') {
|
||||
throw new RangeError('mode must be audit or quarantine');
|
||||
}
|
||||
const observedAtMs =
|
||||
options.observedAtMs ?? options.clock?.now() ?? Date.now();
|
||||
const minimumAgeMs = options.minimumAgeMs ?? 5 * 60_000;
|
||||
const startShard = options.startShard ?? 0;
|
||||
const shardCount = options.shardCount ?? 8;
|
||||
const maxEntriesPerShard = options.maxEntriesPerShard ?? 32;
|
||||
assertIntegerBetween(
|
||||
'observedAtMs',
|
||||
observedAtMs,
|
||||
0,
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
);
|
||||
assertIntegerBetween(
|
||||
'minimumAgeMs',
|
||||
minimumAgeMs,
|
||||
0,
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
);
|
||||
assertIntegerBetween('startShard', startShard, 0, 255);
|
||||
assertIntegerBetween('shardCount', shardCount, 1, MAX_ORPHAN_AUDIT_SHARDS);
|
||||
assertIntegerBetween(
|
||||
'maxEntriesPerShard',
|
||||
maxEntriesPerShard,
|
||||
1,
|
||||
MAX_ORPHAN_AUDIT_ENTRIES_PER_SHARD,
|
||||
);
|
||||
|
||||
const counts = Object.fromEntries(
|
||||
CATEGORIES.map((category) => [category, 0]),
|
||||
) as Record<CompletionReceiptOrphanCategory, number>;
|
||||
const overflowShards: string[] = [];
|
||||
const entries: CompletionReceiptOrphanAuditEntry[] = [];
|
||||
|
||||
for (let offset = 0; offset < shardCount; offset += 1) {
|
||||
const shard = shardName((startShard + offset) % 256);
|
||||
const snapshot = await this.directory.inspectShard(
|
||||
shard,
|
||||
maxEntriesPerShard,
|
||||
);
|
||||
if (snapshot.shard !== shard) {
|
||||
throw new Error('Completion receipt directory returned another shard');
|
||||
}
|
||||
if (snapshot.entries.length > maxEntriesPerShard) {
|
||||
throw new Error('Completion receipt directory exceeded its hard limit');
|
||||
}
|
||||
if (snapshot.overflow) overflowShards.push(shard);
|
||||
|
||||
const attemptIds = snapshot.entries.flatMap((entry) =>
|
||||
entry.kind === 'receipt' && entry.attemptId ? [entry.attemptId] : [],
|
||||
);
|
||||
const ownership = await this.ownership.lookup(attemptIds);
|
||||
for (const entry of snapshot.entries) {
|
||||
const ageMs = Math.max(0, observedAtMs - entry.modifiedAtMs);
|
||||
const classification = classify(
|
||||
entry,
|
||||
entry.attemptId ? ownership.get(entry.attemptId) : undefined,
|
||||
ageMs >= minimumAgeMs,
|
||||
);
|
||||
counts[classification.category] += 1;
|
||||
let action: CompletionReceiptOrphanAction = classification.eligible
|
||||
? 'eligible'
|
||||
: 'retained';
|
||||
let quarantineRef: string | undefined;
|
||||
if (classification.eligible && mode === 'quarantine') {
|
||||
if (snapshot.overflow) {
|
||||
action = 'blocked_overflow';
|
||||
} else {
|
||||
const result = await this.directory.quarantine(entry);
|
||||
action = result.status;
|
||||
if (result.status === 'quarantined') {
|
||||
quarantineRef = result.reference;
|
||||
}
|
||||
}
|
||||
}
|
||||
entries.push({
|
||||
shard,
|
||||
name: entry.name,
|
||||
category: classification.category,
|
||||
action,
|
||||
ageMs,
|
||||
...(entry.attemptId ? { attemptId: entry.attemptId } : {}),
|
||||
...(entry.attemptId && ownership.get(entry.attemptId)?.attemptStatus
|
||||
? {
|
||||
attemptStatus: ownership.get(entry.attemptId)!.attemptStatus,
|
||||
}
|
||||
: {}),
|
||||
...(quarantineRef ? { quarantineRef } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const absoluteNextShard = startShard + shardCount;
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
mode,
|
||||
observedAtMs,
|
||||
minimumAgeMs,
|
||||
startShard: shardName(startShard),
|
||||
nextShard: shardName(absoluteNextShard % 256),
|
||||
wrapped: absoluteNextShard > 255,
|
||||
shardCount,
|
||||
maxEntriesPerShard,
|
||||
scannedEntries: entries.length,
|
||||
overflowShards,
|
||||
entries,
|
||||
counts,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import { timingSafeEqual } from 'crypto';
|
||||
import {
|
||||
decodeLocalSecretPlaintext,
|
||||
decryptLocalSecretEnvelopeToBuffer,
|
||||
encryptLocalSecretEnvelope,
|
||||
type LocalSecretNonceFactory,
|
||||
} from '../adapters/crypto/aes256GcmLocalSecret';
|
||||
import {
|
||||
LOCAL_SECRET_ALGORITHM,
|
||||
LocalSecretMutationConflictError,
|
||||
LocalSecretUnavailableError,
|
||||
LocalSecretVersionConflictError,
|
||||
assertLocalSecretMutationId,
|
||||
assertLocalSecretName,
|
||||
assertLocalSecretPlaintext,
|
||||
assertLocalSecretProjectId,
|
||||
assertLocalSecretKeyId,
|
||||
createLocalSecretRef,
|
||||
parseLocalSecretRef,
|
||||
type LocalSecretEnvelope,
|
||||
} from '../domain/localSecret';
|
||||
import { assertRunDispatchCandidate } from '../domain/runDispatchCandidate';
|
||||
import type { LocalSecretEnvelopeRepository } from '../ports/localSecretEnvelopeRepository';
|
||||
import type {
|
||||
LocalSecretEnvironmentProvider,
|
||||
LocalSecretEnvironmentRequest,
|
||||
} from '../ports/localSecretEnvironmentProvider';
|
||||
import type {
|
||||
LocalSecretKeyMaterial,
|
||||
LocalSecretKeyProvider,
|
||||
} from '../ports/localSecretKeyProvider';
|
||||
|
||||
export interface PutEncryptedLocalSecretCommand {
|
||||
projectId: string;
|
||||
name: string;
|
||||
plaintext: string;
|
||||
mutationId: string;
|
||||
expectedCurrentVersion: number;
|
||||
createdAtMs: number;
|
||||
}
|
||||
|
||||
export interface PutEncryptedLocalSecretResult {
|
||||
status: 'inserted' | 'existing';
|
||||
version: number;
|
||||
secretRef: string;
|
||||
}
|
||||
|
||||
export { LocalSecretMutationConflictError, LocalSecretVersionConflictError };
|
||||
|
||||
function assertPutCommand(command: PutEncryptedLocalSecretCommand): void {
|
||||
if (!command || typeof command !== 'object' || Array.isArray(command)) {
|
||||
throw new TypeError('Local Secret write command must be an object');
|
||||
}
|
||||
assertLocalSecretProjectId(command.projectId);
|
||||
assertLocalSecretName(command.name);
|
||||
assertLocalSecretPlaintext(command.plaintext);
|
||||
assertLocalSecretMutationId(command.mutationId);
|
||||
if (
|
||||
!Number.isSafeInteger(command.expectedCurrentVersion) ||
|
||||
command.expectedCurrentVersion < 0 ||
|
||||
command.expectedCurrentVersion >= 2_147_483_647
|
||||
) {
|
||||
throw new TypeError('Local Secret expected current version is invalid');
|
||||
}
|
||||
if (!Number.isSafeInteger(command.createdAtMs) || command.createdAtMs < 0) {
|
||||
throw new TypeError('Local Secret creation time is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
function ownedKeyMaterial(
|
||||
material: LocalSecretKeyMaterial | null,
|
||||
expectedKeyId?: string,
|
||||
): { keyId: string; key: Buffer } {
|
||||
if (!material || !(material.key instanceof Uint8Array)) {
|
||||
throw new LocalSecretUnavailableError();
|
||||
}
|
||||
try {
|
||||
assertLocalSecretKeyId(material.keyId);
|
||||
if (
|
||||
(expectedKeyId !== undefined && material.keyId !== expectedKeyId) ||
|
||||
material.key.byteLength !== 32
|
||||
) {
|
||||
throw new LocalSecretUnavailableError();
|
||||
}
|
||||
return { keyId: material.keyId, key: Buffer.from(material.key) };
|
||||
} catch {
|
||||
throw new LocalSecretUnavailableError();
|
||||
} finally {
|
||||
material.key.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
function plaintextMatches(
|
||||
envelope: LocalSecretEnvelope,
|
||||
key: Uint8Array,
|
||||
expected: string,
|
||||
): boolean {
|
||||
const actual = decryptLocalSecretEnvelopeToBuffer(envelope, key);
|
||||
const wanted = Buffer.from(expected, 'utf8');
|
||||
try {
|
||||
return actual.length === wanted.length && timingSafeEqual(actual, wanted);
|
||||
} finally {
|
||||
actual.fill(0);
|
||||
wanted.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
export class EncryptedLocalSecretService
|
||||
implements LocalSecretEnvironmentProvider
|
||||
{
|
||||
constructor(
|
||||
private readonly envelopes: LocalSecretEnvelopeRepository,
|
||||
private readonly keys: LocalSecretKeyProvider,
|
||||
private readonly nonceFactory?: LocalSecretNonceFactory,
|
||||
) {}
|
||||
|
||||
async put(
|
||||
command: PutEncryptedLocalSecretCommand,
|
||||
): Promise<PutEncryptedLocalSecretResult> {
|
||||
assertPutCommand(command);
|
||||
try {
|
||||
return await this.putValidated(command);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof LocalSecretVersionConflictError ||
|
||||
error instanceof LocalSecretMutationConflictError ||
|
||||
error instanceof LocalSecretUnavailableError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw new LocalSecretUnavailableError();
|
||||
}
|
||||
}
|
||||
|
||||
private async putValidated(
|
||||
command: PutEncryptedLocalSecretCommand,
|
||||
): Promise<PutEncryptedLocalSecretResult> {
|
||||
const existing = await this.envelopes.findByMutation(
|
||||
command.projectId,
|
||||
command.name,
|
||||
command.mutationId,
|
||||
);
|
||||
if (existing) {
|
||||
const material = ownedKeyMaterial(
|
||||
await this.keys.resolve(existing.keyId),
|
||||
existing.keyId,
|
||||
);
|
||||
try {
|
||||
if (
|
||||
existing.version !== command.expectedCurrentVersion + 1 ||
|
||||
!plaintextMatches(existing, material.key, command.plaintext)
|
||||
) {
|
||||
throw new LocalSecretMutationConflictError();
|
||||
}
|
||||
} finally {
|
||||
material.key.fill(0);
|
||||
}
|
||||
return this.result('existing', existing);
|
||||
}
|
||||
|
||||
const material = ownedKeyMaterial(await this.keys.active());
|
||||
try {
|
||||
const envelope = encryptLocalSecretEnvelope(
|
||||
{
|
||||
projectId: command.projectId,
|
||||
name: command.name,
|
||||
version: command.expectedCurrentVersion + 1,
|
||||
mutationId: command.mutationId,
|
||||
keyId: material.keyId,
|
||||
algorithm: LOCAL_SECRET_ALGORITHM,
|
||||
createdAtMs: command.createdAtMs,
|
||||
},
|
||||
command.plaintext,
|
||||
material.key,
|
||||
this.nonceFactory,
|
||||
);
|
||||
const appended = await this.envelopes.append({
|
||||
envelope,
|
||||
expectedCurrentVersion: command.expectedCurrentVersion,
|
||||
});
|
||||
if (appended.status === 'existing') {
|
||||
const existingMaterial =
|
||||
appended.envelope.keyId === material.keyId
|
||||
? material
|
||||
: ownedKeyMaterial(
|
||||
await this.keys.resolve(appended.envelope.keyId),
|
||||
appended.envelope.keyId,
|
||||
);
|
||||
try {
|
||||
if (
|
||||
appended.envelope.version !== command.expectedCurrentVersion + 1 ||
|
||||
!plaintextMatches(
|
||||
appended.envelope,
|
||||
existingMaterial.key,
|
||||
command.plaintext,
|
||||
)
|
||||
) {
|
||||
throw new LocalSecretMutationConflictError();
|
||||
}
|
||||
} finally {
|
||||
if (existingMaterial !== material) existingMaterial.key.fill(0);
|
||||
}
|
||||
}
|
||||
return this.result(appended.status, appended.envelope);
|
||||
} finally {
|
||||
material.key.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
async resolve(
|
||||
request: Readonly<LocalSecretEnvironmentRequest>,
|
||||
): Promise<readonly string[] | null> {
|
||||
const cachedKeys = new Map<string, Buffer>();
|
||||
try {
|
||||
if (!request || typeof request !== 'object' || Array.isArray(request)) {
|
||||
throw new LocalSecretUnavailableError();
|
||||
}
|
||||
assertRunDispatchCandidate(request.candidate);
|
||||
if (
|
||||
!Array.isArray(request.secretRefs) ||
|
||||
request.secretRefs.length > 64
|
||||
) {
|
||||
throw new LocalSecretUnavailableError();
|
||||
}
|
||||
const references = request.secretRefs.map(parseLocalSecretRef);
|
||||
if (
|
||||
references.some(
|
||||
(reference) => reference.projectId !== request.candidate.projectId,
|
||||
)
|
||||
) {
|
||||
throw new LocalSecretUnavailableError();
|
||||
}
|
||||
const envelopes = await this.envelopes.resolveMany(references);
|
||||
if (
|
||||
envelopes.length !== references.length ||
|
||||
envelopes.some((item) => !item)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const plaintext: string[] = [];
|
||||
for (const envelope of envelopes as readonly LocalSecretEnvelope[]) {
|
||||
let key = cachedKeys.get(envelope.keyId);
|
||||
if (!key) {
|
||||
const material = ownedKeyMaterial(
|
||||
await this.keys.resolve(envelope.keyId),
|
||||
envelope.keyId,
|
||||
);
|
||||
key = material.key;
|
||||
cachedKeys.set(envelope.keyId, key);
|
||||
}
|
||||
const bytes = decryptLocalSecretEnvelopeToBuffer(envelope, key);
|
||||
try {
|
||||
plaintext.push(decodeLocalSecretPlaintext(bytes));
|
||||
} finally {
|
||||
bytes.fill(0);
|
||||
}
|
||||
}
|
||||
return Object.freeze(plaintext);
|
||||
} catch (error) {
|
||||
if (error instanceof LocalSecretUnavailableError) throw error;
|
||||
throw new LocalSecretUnavailableError();
|
||||
} finally {
|
||||
for (const key of cachedKeys.values()) key.fill(0);
|
||||
cachedKeys.clear();
|
||||
}
|
||||
}
|
||||
|
||||
private result(
|
||||
status: PutEncryptedLocalSecretResult['status'],
|
||||
envelope: LocalSecretEnvelope,
|
||||
): PutEncryptedLocalSecretResult {
|
||||
return Object.freeze({
|
||||
status,
|
||||
version: envelope.version,
|
||||
secretRef: createLocalSecretRef({
|
||||
projectId: envelope.projectId,
|
||||
name: envelope.name,
|
||||
version: envelope.version,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { DeploymentProfile } from '../domain/deploymentProfile';
|
||||
import type { WorkerRecord } from '../domain/worker';
|
||||
import type { WorkerExecutionDrainer } from '../ports/workerExecutionDrainer';
|
||||
import type {
|
||||
WorkerHeartbeatLifecycle,
|
||||
WorkerHeartbeatStopResult,
|
||||
} from './workerHeartbeatLifecycle';
|
||||
|
||||
export type HeadlessWorkerStopResult =
|
||||
| 'stopped'
|
||||
| 'not_started'
|
||||
| 'executions_timed_out'
|
||||
| 'heartbeat_timed_out'
|
||||
| 'heartbeat_disconnect_failed';
|
||||
|
||||
export interface HeadlessWorkerBootstrapOptions {
|
||||
enabled?: boolean;
|
||||
profile: DeploymentProfile;
|
||||
heartbeat: WorkerHeartbeatLifecycle;
|
||||
executions: WorkerExecutionDrainer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Independent Worker boot topology. It owns no HTTP panel, Scheduler, SQLite
|
||||
* control-plane repository, or local Primary router. Shutdown first advertises
|
||||
* zero capacity, then waits for the execution plane, and only then marks the
|
||||
* Worker session offline.
|
||||
*/
|
||||
export class HeadlessWorkerRuntime {
|
||||
private started = false;
|
||||
|
||||
constructor(
|
||||
private readonly heartbeat: WorkerHeartbeatLifecycle,
|
||||
private readonly executions: WorkerExecutionDrainer,
|
||||
) {}
|
||||
|
||||
currentSession(): WorkerRecord | undefined {
|
||||
return this.heartbeat.currentSession();
|
||||
}
|
||||
|
||||
async start(): Promise<boolean> {
|
||||
if (this.started) return false;
|
||||
const started = await this.heartbeat.start();
|
||||
this.started = started;
|
||||
return started;
|
||||
}
|
||||
|
||||
async drainAndStop(): Promise<HeadlessWorkerStopResult> {
|
||||
if (!this.started) return 'not_started';
|
||||
await this.heartbeat.drain();
|
||||
if ((await this.executions.drain()) === 'timed_out') {
|
||||
return 'executions_timed_out';
|
||||
}
|
||||
const heartbeatResult: WorkerHeartbeatStopResult =
|
||||
await this.heartbeat.stop();
|
||||
if (heartbeatResult === 'timed_out') return 'heartbeat_timed_out';
|
||||
if (heartbeatResult === 'disconnect_failed') {
|
||||
return 'heartbeat_disconnect_failed';
|
||||
}
|
||||
this.started = false;
|
||||
return 'stopped';
|
||||
}
|
||||
}
|
||||
|
||||
export type HeadlessWorkerBootstrapResult =
|
||||
| { status: 'disabled' }
|
||||
| { status: 'active'; runtime: HeadlessWorkerRuntime };
|
||||
|
||||
export async function bootstrapHeadlessWorkerRuntime({
|
||||
enabled = false,
|
||||
profile,
|
||||
heartbeat,
|
||||
executions,
|
||||
}: HeadlessWorkerBootstrapOptions): Promise<HeadlessWorkerBootstrapResult> {
|
||||
if (!enabled) return { status: 'disabled' };
|
||||
if (profile !== 'worker') {
|
||||
throw new TypeError(
|
||||
`Deployment profile ${profile} cannot activate the headless Worker runtime`,
|
||||
);
|
||||
}
|
||||
const runtime = new HeadlessWorkerRuntime(heartbeat, executions);
|
||||
if (!(await runtime.start())) {
|
||||
throw new Error('Headless Worker runtime did not start');
|
||||
}
|
||||
return { status: 'active', runtime };
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { createHash } from 'crypto';
|
||||
import jwt, { type JwtPayload } from 'jsonwebtoken';
|
||||
import {
|
||||
assertAuthenticatedPrincipalActive,
|
||||
normalizeAuthenticatedPrincipal,
|
||||
type AuthenticatedPrincipal,
|
||||
} from '../domain/authenticatedPrincipal';
|
||||
import {
|
||||
IdentityDirectoryUnavailableError,
|
||||
LEGACY_PANEL_IDENTITY_PROVIDER,
|
||||
LEGACY_PANEL_PROVIDER_SUBJECT,
|
||||
} from '../domain/identityDirectory';
|
||||
import type { IdentityDirectoryRepository } from '../ports/identityDirectoryRepository';
|
||||
import type {
|
||||
LegacyPanelPlatform,
|
||||
LegacyPanelSessionSource,
|
||||
} from '../ports/legacyPanelSessionSource';
|
||||
|
||||
const LEGACY_PANEL_JWT_PATTERN =
|
||||
/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
|
||||
const MAX_LEGACY_PANEL_TOKEN_LENGTH = 4096;
|
||||
const MAX_LEGACY_PANEL_JWT_DATA_LENGTH = 256;
|
||||
|
||||
export interface AuthenticateLegacyPanelSessionRequest {
|
||||
token: string;
|
||||
platform: LegacyPanelPlatform;
|
||||
nowMs: number;
|
||||
}
|
||||
|
||||
export class LegacyPanelAuthenticationRejectedError extends Error {
|
||||
readonly code = 'LEGACY_PANEL_AUTHENTICATION_REJECTED';
|
||||
|
||||
constructor() {
|
||||
super('Legacy panel authentication was rejected');
|
||||
this.name = 'LegacyPanelAuthenticationRejectedError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LegacyPanelAuthenticationUnavailableError extends Error {
|
||||
readonly code = 'LEGACY_PANEL_AUTHENTICATION_UNAVAILABLE';
|
||||
|
||||
constructor() {
|
||||
super('Legacy panel authentication is unavailable');
|
||||
this.name = 'LegacyPanelAuthenticationUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
function assertExactRequest(request: AuthenticateLegacyPanelSessionRequest) {
|
||||
if (!request || typeof request !== 'object' || Array.isArray(request)) {
|
||||
throw new TypeError('Legacy panel authentication request is invalid');
|
||||
}
|
||||
const keys = Object.keys(request).sort();
|
||||
const expected = ['nowMs', 'platform', 'token'];
|
||||
if (
|
||||
keys.length !== expected.length ||
|
||||
keys.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
throw new TypeError('Legacy panel authentication request shape is invalid');
|
||||
}
|
||||
if (
|
||||
typeof request.token !== 'string' ||
|
||||
request.token.length < 1 ||
|
||||
request.token.length > MAX_LEGACY_PANEL_TOKEN_LENGTH ||
|
||||
!LEGACY_PANEL_JWT_PATTERN.test(request.token)
|
||||
) {
|
||||
throw new LegacyPanelAuthenticationRejectedError();
|
||||
}
|
||||
if (request.platform !== 'desktop' && request.platform !== 'mobile') {
|
||||
throw new TypeError('Legacy panel platform is invalid');
|
||||
}
|
||||
if (!Number.isSafeInteger(request.nowMs) || request.nowMs < 0) {
|
||||
throw new TypeError('Legacy panel authentication time is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePayload(value: string | JwtPayload): {
|
||||
authenticatedAtMs: number;
|
||||
expiresAtMs: number;
|
||||
} {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new LegacyPanelAuthenticationRejectedError();
|
||||
}
|
||||
const keys = Object.keys(value).sort();
|
||||
const expected = ['data', 'exp', 'iat'];
|
||||
if (
|
||||
keys.length !== expected.length ||
|
||||
keys.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
throw new LegacyPanelAuthenticationRejectedError();
|
||||
}
|
||||
if (
|
||||
typeof value.data !== 'string' ||
|
||||
value.data.length < 1 ||
|
||||
value.data.length > MAX_LEGACY_PANEL_JWT_DATA_LENGTH ||
|
||||
!Number.isSafeInteger(value.iat) ||
|
||||
value.iat! < 0 ||
|
||||
!Number.isSafeInteger(value.exp) ||
|
||||
value.exp! <= value.iat!
|
||||
) {
|
||||
throw new LegacyPanelAuthenticationRejectedError();
|
||||
}
|
||||
const authenticatedAtMs = value.iat! * 1000;
|
||||
const expiresAtMs = value.exp! * 1000;
|
||||
if (
|
||||
!Number.isSafeInteger(authenticatedAtMs) ||
|
||||
!Number.isSafeInteger(expiresAtMs)
|
||||
) {
|
||||
throw new LegacyPanelAuthenticationRejectedError();
|
||||
}
|
||||
return { authenticatedAtMs, expiresAtMs };
|
||||
}
|
||||
|
||||
export class LegacyPanelAuthenticationService {
|
||||
constructor(
|
||||
private readonly identityDirectory: IdentityDirectoryRepository,
|
||||
private readonly sessions: LegacyPanelSessionSource,
|
||||
private readonly jwtSecret: string,
|
||||
) {
|
||||
if (
|
||||
typeof jwtSecret !== 'string' ||
|
||||
jwtSecret.length < 1 ||
|
||||
jwtSecret.length > 4096
|
||||
) {
|
||||
throw new TypeError('Legacy panel JWT secret is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
async authenticate(
|
||||
request: AuthenticateLegacyPanelSessionRequest,
|
||||
): Promise<Readonly<AuthenticatedPrincipal>> {
|
||||
assertExactRequest(request);
|
||||
let payload: { authenticatedAtMs: number; expiresAtMs: number };
|
||||
try {
|
||||
payload = normalizePayload(
|
||||
jwt.verify(request.token, this.jwtSecret, {
|
||||
algorithms: ['HS384'],
|
||||
clockTimestamp: Math.floor(request.nowMs / 1000),
|
||||
clockTolerance: 0,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
throw new LegacyPanelAuthenticationRejectedError();
|
||||
}
|
||||
if (
|
||||
payload.authenticatedAtMs > request.nowMs ||
|
||||
payload.expiresAtMs <= request.nowMs
|
||||
) {
|
||||
throw new LegacyPanelAuthenticationRejectedError();
|
||||
}
|
||||
|
||||
let active: boolean;
|
||||
try {
|
||||
active = await this.sessions.isActive(request.token, request.platform);
|
||||
} catch {
|
||||
throw new LegacyPanelAuthenticationUnavailableError();
|
||||
}
|
||||
if (!active) throw new LegacyPanelAuthenticationRejectedError();
|
||||
|
||||
let subject;
|
||||
try {
|
||||
subject = await this.identityDirectory.resolveAuthenticationSubject(
|
||||
LEGACY_PANEL_IDENTITY_PROVIDER,
|
||||
LEGACY_PANEL_PROVIDER_SUBJECT,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof IdentityDirectoryUnavailableError) {
|
||||
throw new LegacyPanelAuthenticationUnavailableError();
|
||||
}
|
||||
throw new LegacyPanelAuthenticationUnavailableError();
|
||||
}
|
||||
if (!subject || subject.type !== 'user') {
|
||||
throw new LegacyPanelAuthenticationRejectedError();
|
||||
}
|
||||
|
||||
const principal = normalizeAuthenticatedPrincipal({
|
||||
subject,
|
||||
authenticationId: `legacy_panel:${createHash('sha256')
|
||||
.update(request.token, 'utf8')
|
||||
.digest('hex')}`,
|
||||
authenticatedAtMs: payload.authenticatedAtMs,
|
||||
expiresAtMs: payload.expiresAtMs,
|
||||
assurance: 'single_factor',
|
||||
});
|
||||
try {
|
||||
assertAuthenticatedPrincipalActive(principal, request.nowMs);
|
||||
} catch {
|
||||
throw new LegacyPanelAuthenticationRejectedError();
|
||||
}
|
||||
return principal;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import type { ExecutionOrigin } from '../domain/run';
|
||||
import { selectOneLegacyExecution } from '../domain/legacyExecutionSelection';
|
||||
import type {
|
||||
LegacyExecutionCallbackFact,
|
||||
LegacyExecutionCancellationFact,
|
||||
LegacyExecutionSelector,
|
||||
} from '../ports/legacyExecutionCorrelation';
|
||||
import type {
|
||||
ActiveLegacyShadowRun,
|
||||
LegacyShadowRunLocator,
|
||||
} from '../ports/legacyShadowRunLocator';
|
||||
import type { LegacyShadowRunWriter } from './legacyShadowRunWriter';
|
||||
|
||||
export type LegacyCorrelationOperation =
|
||||
| 'cancel_all'
|
||||
| 'cancel_one'
|
||||
| 'callback';
|
||||
export type LegacyCorrelationFailureReason =
|
||||
| 'ambiguous'
|
||||
| 'truncated'
|
||||
| 'unmatched'
|
||||
| 'write_failed';
|
||||
|
||||
export interface LegacyCorrelationFailure {
|
||||
operation: LegacyCorrelationOperation;
|
||||
reason: LegacyCorrelationFailureReason;
|
||||
legacyCronId: number;
|
||||
candidateCount: number;
|
||||
}
|
||||
|
||||
export interface LegacyCorrelationReporter {
|
||||
failure(failure: LegacyCorrelationFailure): void;
|
||||
}
|
||||
|
||||
export interface LegacyCorrelationResult {
|
||||
matched: number;
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
export class LegacyShadowRunCorrelator {
|
||||
constructor(
|
||||
private readonly locator: LegacyShadowRunLocator,
|
||||
private readonly writer: LegacyShadowRunWriter,
|
||||
private readonly reporter: LegacyCorrelationReporter,
|
||||
) {}
|
||||
|
||||
async cancel(
|
||||
fact: LegacyExecutionCancellationFact,
|
||||
origins: readonly ExecutionOrigin[],
|
||||
): Promise<LegacyCorrelationResult> {
|
||||
const lookup = await this.locator.listActiveByLegacyCron({
|
||||
legacyCronId: fact.legacyCronId,
|
||||
origins,
|
||||
});
|
||||
const candidates =
|
||||
fact.scope === 'all'
|
||||
? [...lookup.candidates]
|
||||
: this.selectOne(lookup.candidates, fact);
|
||||
if (lookup.truncated) {
|
||||
this.report({
|
||||
operation: fact.scope === 'all' ? 'cancel_all' : 'cancel_one',
|
||||
reason: 'truncated',
|
||||
legacyCronId: fact.legacyCronId,
|
||||
candidateCount: lookup.candidates.length,
|
||||
});
|
||||
}
|
||||
if (fact.scope === 'one' && candidates.length !== 1) {
|
||||
this.reportSelectionFailure('cancel_one', fact, lookup.candidates);
|
||||
return { matched: 0, truncated: lookup.truncated };
|
||||
}
|
||||
|
||||
let matched = 0;
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
await this.writer.cancelled(
|
||||
{ runId: candidate.runId, attemptId: candidate.attemptId },
|
||||
{ atMs: fact.atMs, reason: fact.reason },
|
||||
);
|
||||
matched += 1;
|
||||
} catch {
|
||||
this.report({
|
||||
operation: fact.scope === 'all' ? 'cancel_all' : 'cancel_one',
|
||||
reason: 'write_failed',
|
||||
legacyCronId: fact.legacyCronId,
|
||||
candidateCount: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
return { matched, truncated: lookup.truncated };
|
||||
}
|
||||
|
||||
async callback(
|
||||
fact: LegacyExecutionCallbackFact,
|
||||
origins: readonly ExecutionOrigin[],
|
||||
): Promise<LegacyCorrelationResult> {
|
||||
const lookup = await this.locator.listActiveByLegacyCron({
|
||||
legacyCronId: fact.legacyCronId,
|
||||
origins,
|
||||
});
|
||||
const candidates = this.selectOne(lookup.candidates, fact);
|
||||
if (lookup.truncated) {
|
||||
this.report({
|
||||
operation: 'callback',
|
||||
reason: 'truncated',
|
||||
legacyCronId: fact.legacyCronId,
|
||||
candidateCount: lookup.candidates.length,
|
||||
});
|
||||
}
|
||||
if (candidates.length !== 1) {
|
||||
this.reportSelectionFailure('callback', fact, lookup.candidates);
|
||||
return { matched: 0, truncated: lookup.truncated };
|
||||
}
|
||||
|
||||
const [candidate] = candidates;
|
||||
const reference = {
|
||||
runId: candidate.runId,
|
||||
attemptId: candidate.attemptId,
|
||||
};
|
||||
try {
|
||||
if (fact.phase === 'running') {
|
||||
await this.writer.spawned(reference, {
|
||||
atMs: fact.atMs,
|
||||
...(fact.pid === undefined ? {} : { pid: fact.pid }),
|
||||
...(fact.logArtifactId === undefined
|
||||
? {}
|
||||
: { logArtifactId: fact.logArtifactId }),
|
||||
});
|
||||
await this.writer.running(reference, fact.atMs);
|
||||
} else {
|
||||
await this.writer.spawned(reference, {
|
||||
atMs: fact.atMs,
|
||||
...(fact.pid === undefined ? {} : { pid: fact.pid }),
|
||||
...(fact.logArtifactId === undefined
|
||||
? {}
|
||||
: { logArtifactId: fact.logArtifactId }),
|
||||
});
|
||||
await this.writer.exited(reference, {
|
||||
atMs: fact.atMs,
|
||||
exitCode: fact.exitCode ?? 0,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
this.report({
|
||||
operation: 'callback',
|
||||
reason: 'write_failed',
|
||||
legacyCronId: fact.legacyCronId,
|
||||
candidateCount: 1,
|
||||
});
|
||||
return { matched: 0, truncated: lookup.truncated };
|
||||
}
|
||||
return { matched: 1, truncated: lookup.truncated };
|
||||
}
|
||||
|
||||
private selectOne(
|
||||
candidates: readonly ActiveLegacyShadowRun[],
|
||||
selector: LegacyExecutionSelector,
|
||||
): ActiveLegacyShadowRun[] {
|
||||
return selectOneLegacyExecution(candidates, selector);
|
||||
}
|
||||
|
||||
private reportSelectionFailure(
|
||||
operation: LegacyCorrelationOperation,
|
||||
selector: LegacyExecutionSelector,
|
||||
candidates: readonly ActiveLegacyShadowRun[],
|
||||
): void {
|
||||
this.report({
|
||||
operation,
|
||||
reason: candidates.length === 0 ? 'unmatched' : 'ambiguous',
|
||||
legacyCronId: selector.legacyCronId,
|
||||
candidateCount: candidates.length,
|
||||
});
|
||||
}
|
||||
|
||||
private report(failure: LegacyCorrelationFailure): void {
|
||||
try {
|
||||
this.reporter.failure(failure);
|
||||
} catch {
|
||||
// Correlation diagnostics must not affect legacy execution.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import type { ExecutionOrigin } from '../domain/run';
|
||||
import type { RuntimeRolloutPolicy } from '../domain/runtimeRollout';
|
||||
import type {
|
||||
LegacyExecutionAcceptedFact,
|
||||
LegacyExecutionCancelledFact,
|
||||
LegacyExecutionExitedFact,
|
||||
LegacyExecutionObservation,
|
||||
LegacyExecutionObserver,
|
||||
LegacyExecutionRunningFact,
|
||||
LegacyExecutionSpawnedFact,
|
||||
LegacyExecutionStartFailedFact,
|
||||
} from '../ports/legacyExecutionObserver';
|
||||
import type {
|
||||
LegacyShadowRunReference,
|
||||
LegacyShadowRunWriter,
|
||||
} from './legacyShadowRunWriter';
|
||||
|
||||
export type ShadowObservationOperation =
|
||||
| 'accept'
|
||||
| 'spawned'
|
||||
| 'running'
|
||||
| 'start_failed'
|
||||
| 'exited'
|
||||
| 'cancelled';
|
||||
|
||||
export interface ShadowObservationFailure {
|
||||
origin: ExecutionOrigin;
|
||||
operation: ShadowObservationOperation;
|
||||
errorCode: string;
|
||||
runId?: string;
|
||||
attemptId?: string;
|
||||
}
|
||||
|
||||
export interface ShadowObservationReporter {
|
||||
failure(failure: ShadowObservationFailure): void;
|
||||
}
|
||||
|
||||
export interface TrackedLegacyExecutionObservation
|
||||
extends LegacyExecutionObservation {
|
||||
settled(): Promise<void>;
|
||||
}
|
||||
|
||||
const NOOP_OBSERVATION: TrackedLegacyExecutionObservation = Object.freeze({
|
||||
spawned() {},
|
||||
running() {},
|
||||
startFailed() {},
|
||||
exited() {},
|
||||
cancelled() {},
|
||||
async settled() {},
|
||||
});
|
||||
|
||||
function classifyError(error: unknown): string {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
'code' in error &&
|
||||
typeof (error as Error & { code?: unknown }).code === 'string'
|
||||
) {
|
||||
const code = (error as Error & { code: string }).code;
|
||||
return /^[A-Z0-9_]{1,64}$/.test(code) ? code : 'SHADOW_UNKNOWN';
|
||||
}
|
||||
return 'SHADOW_UNKNOWN';
|
||||
}
|
||||
|
||||
class SerialLegacyExecutionObservation
|
||||
implements TrackedLegacyExecutionObservation
|
||||
{
|
||||
private chain: Promise<LegacyShadowRunReference | null>;
|
||||
|
||||
constructor(
|
||||
private readonly origin: ExecutionOrigin,
|
||||
private readonly writer: LegacyShadowRunWriter,
|
||||
accepted: LegacyExecutionAcceptedFact,
|
||||
private readonly reporter: ShadowObservationReporter,
|
||||
) {
|
||||
this.chain = writer.accept(accepted).catch((error) => {
|
||||
this.report('accept', error);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
spawned(fact: LegacyExecutionSpawnedFact): void {
|
||||
this.enqueue('spawned', (reference) =>
|
||||
this.writer.spawned(reference, fact),
|
||||
);
|
||||
}
|
||||
|
||||
running(fact: LegacyExecutionRunningFact): void {
|
||||
this.enqueue('running', (reference) =>
|
||||
this.writer.running(reference, fact.atMs),
|
||||
);
|
||||
}
|
||||
|
||||
startFailed(fact: LegacyExecutionStartFailedFact): void {
|
||||
this.enqueue('start_failed', (reference) =>
|
||||
this.writer.startFailed(reference, fact),
|
||||
);
|
||||
}
|
||||
|
||||
exited(fact: LegacyExecutionExitedFact): void {
|
||||
this.enqueue('exited', (reference) => this.writer.exited(reference, fact));
|
||||
}
|
||||
|
||||
cancelled(fact: LegacyExecutionCancelledFact): void {
|
||||
this.enqueue('cancelled', (reference) =>
|
||||
this.writer.cancelled(reference, fact),
|
||||
);
|
||||
}
|
||||
|
||||
async settled(): Promise<void> {
|
||||
await this.chain;
|
||||
}
|
||||
|
||||
private enqueue(
|
||||
operation: ShadowObservationOperation,
|
||||
write: (reference: LegacyShadowRunReference) => Promise<void>,
|
||||
): void {
|
||||
this.chain = this.chain.then(async (reference) => {
|
||||
if (!reference) return null;
|
||||
try {
|
||||
await write(reference);
|
||||
} catch (error) {
|
||||
this.report(operation, error, reference);
|
||||
}
|
||||
return reference;
|
||||
});
|
||||
}
|
||||
|
||||
private report(
|
||||
operation: ShadowObservationOperation,
|
||||
error: unknown,
|
||||
reference?: LegacyShadowRunReference,
|
||||
): void {
|
||||
try {
|
||||
this.reporter.failure({
|
||||
origin: this.origin,
|
||||
operation,
|
||||
errorCode: classifyError(error),
|
||||
...(reference === undefined ? {} : reference),
|
||||
});
|
||||
} catch {
|
||||
// Shadow reporting must not become a second failure path.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class LegacyShadowRunObserver implements LegacyExecutionObserver {
|
||||
constructor(
|
||||
private readonly policy: RuntimeRolloutPolicy,
|
||||
private readonly writer: LegacyShadowRunWriter,
|
||||
private readonly reporter: ShadowObservationReporter,
|
||||
) {}
|
||||
|
||||
begin(fact: LegacyExecutionAcceptedFact): TrackedLegacyExecutionObservation {
|
||||
const decision = this.policy.decide(fact.origin);
|
||||
if (decision.mode === 'off') return NOOP_OBSERVATION;
|
||||
if (decision.mode === 'primary') {
|
||||
throw new Error(
|
||||
'Legacy observer cannot accept a Runtime-owned primary execution',
|
||||
);
|
||||
}
|
||||
return new SerialLegacyExecutionObservation(
|
||||
fact.origin,
|
||||
this.writer,
|
||||
fact,
|
||||
this.reporter,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,546 @@
|
||||
import { v7 as uuidV7 } from 'uuid';
|
||||
import type {
|
||||
RunAttemptRecord,
|
||||
RunAttemptStatus,
|
||||
RunEventRecord,
|
||||
RunRecord,
|
||||
RunStatus,
|
||||
} from '../domain/run';
|
||||
import {
|
||||
isTerminalRunAttemptStatus,
|
||||
isTerminalRunStatus,
|
||||
reserveRunEvent,
|
||||
transitionRun,
|
||||
transitionRunAttempt,
|
||||
type RunAttemptTransitionCommand,
|
||||
type RunAttemptTransitionDecision,
|
||||
type RunDomainEventDraft,
|
||||
type RunTransitionCommand,
|
||||
type RunTransitionDecision,
|
||||
} from '../domain/runStateMachine';
|
||||
import { RunVersionConflictError } from '../domain/stateMachineErrors';
|
||||
import type {
|
||||
LegacyExecutionAcceptedFact,
|
||||
LegacyExecutionCancelledFact,
|
||||
LegacyExecutionExitedFact,
|
||||
LegacyExecutionSpawnedFact,
|
||||
LegacyExecutionStartFailedFact,
|
||||
} from '../ports/legacyExecutionObserver';
|
||||
import type {
|
||||
RunRepository,
|
||||
RunRepositoryTransaction,
|
||||
} from '../ports/runRepository';
|
||||
import {
|
||||
RunAttemptConcurrentWriteError,
|
||||
RunAttemptNotFoundError,
|
||||
RunNotFoundError,
|
||||
} from './commandErrors';
|
||||
|
||||
export interface LegacyShadowRunReference {
|
||||
runId: string;
|
||||
attemptId: string;
|
||||
}
|
||||
|
||||
export type ShadowIdFactory = () => string;
|
||||
|
||||
export class LegacyShadowRunWriter {
|
||||
constructor(
|
||||
private readonly repository: RunRepository,
|
||||
private readonly createId: ShadowIdFactory = uuidV7,
|
||||
) {}
|
||||
|
||||
async accept(
|
||||
fact: LegacyExecutionAcceptedFact,
|
||||
): Promise<LegacyShadowRunReference> {
|
||||
const reference = {
|
||||
runId: this.createId(),
|
||||
attemptId: this.createId(),
|
||||
};
|
||||
const initialRun: RunRecord = {
|
||||
id: reference.runId,
|
||||
projectId: fact.projectId,
|
||||
taskId: fact.taskId,
|
||||
taskRevision: fact.taskRevision,
|
||||
...(fact.taskName === undefined ? {} : { taskName: fact.taskName }),
|
||||
...(fact.legacyCronId === undefined
|
||||
? {}
|
||||
: { legacyCronId: fact.legacyCronId }),
|
||||
triggerType: fact.triggerType,
|
||||
executionOrigin: fact.origin,
|
||||
executionOwner: 'legacy',
|
||||
...(fact.triggeredBy === undefined
|
||||
? {}
|
||||
: { triggeredBy: fact.triggeredBy }),
|
||||
...(fact.requestId === undefined ? {} : { requestId: fact.requestId }),
|
||||
...(fact.scheduledForMs === undefined
|
||||
? {}
|
||||
: { scheduledForMs: fact.scheduledForMs }),
|
||||
status: 'created',
|
||||
version: 0,
|
||||
eventSequence: 0,
|
||||
priority: 0,
|
||||
createdAtMs: fact.acceptedAtMs,
|
||||
};
|
||||
const initialAttempt: RunAttemptRecord = {
|
||||
id: reference.attemptId,
|
||||
runId: reference.runId,
|
||||
attempt: 1,
|
||||
status: 'claimed',
|
||||
executorType: 'legacy_local',
|
||||
callbackSequence: 0,
|
||||
createdAtMs: fact.acceptedAtMs,
|
||||
};
|
||||
|
||||
await this.repository.transaction(async (transaction) => {
|
||||
await transaction.insertRun(initialRun);
|
||||
await transaction.insertAttempt(initialAttempt);
|
||||
|
||||
const created = reserveRunEvent(initialRun, 0);
|
||||
const createdRun = created.run;
|
||||
const createdUpdated = await transaction.compareAndSetRun(createdRun, 0);
|
||||
if (!createdUpdated) {
|
||||
throw new RunVersionConflictError(initialRun.id, 0, initialRun.version);
|
||||
}
|
||||
await transaction.appendEvent(
|
||||
this.event(
|
||||
createdRun,
|
||||
{
|
||||
sequence: created.sequence,
|
||||
type: 'run.created',
|
||||
payload: {
|
||||
status: 'created',
|
||||
version: createdRun.version,
|
||||
execution_owner: 'legacy',
|
||||
shadow: true,
|
||||
},
|
||||
},
|
||||
fact.acceptedAtMs,
|
||||
),
|
||||
);
|
||||
|
||||
const queued = transitionRun(createdRun, {
|
||||
to: 'queued',
|
||||
expectedVersion: createdRun.version,
|
||||
atMs: fact.acceptedAtMs,
|
||||
});
|
||||
await this.persistRunDecision(
|
||||
transaction,
|
||||
createdRun,
|
||||
queued,
|
||||
fact.acceptedAtMs,
|
||||
);
|
||||
});
|
||||
return reference;
|
||||
}
|
||||
|
||||
async spawned(
|
||||
reference: LegacyShadowRunReference,
|
||||
fact: LegacyExecutionSpawnedFact,
|
||||
): Promise<void> {
|
||||
await this.repository.transaction(async (transaction) => {
|
||||
const aggregate = await this.load(transaction, reference);
|
||||
await this.ensureSpawned(
|
||||
transaction,
|
||||
aggregate.run,
|
||||
aggregate.attempt,
|
||||
fact,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async running(
|
||||
reference: LegacyShadowRunReference,
|
||||
atMs: number,
|
||||
): Promise<void> {
|
||||
await this.repository.transaction(async (transaction) => {
|
||||
let { run, attempt } = await this.load(transaction, reference);
|
||||
if (isTerminalRunStatus(run.status)) return;
|
||||
({ run, attempt } = await this.ensureSpawned(transaction, run, attempt, {
|
||||
atMs,
|
||||
}));
|
||||
await this.ensureRunning(transaction, run, attempt, atMs);
|
||||
});
|
||||
}
|
||||
|
||||
async startFailed(
|
||||
reference: LegacyShadowRunReference,
|
||||
fact: LegacyExecutionStartFailedFact,
|
||||
): Promise<void> {
|
||||
await this.repository.transaction(async (transaction) => {
|
||||
let { run, attempt } = await this.load(transaction, reference);
|
||||
if (isTerminalRunStatus(run.status)) return;
|
||||
({ run, attempt } = await this.ensureSpawned(transaction, run, attempt, {
|
||||
atMs: fact.atMs,
|
||||
}));
|
||||
|
||||
if (!isTerminalRunAttemptStatus(attempt.status)) {
|
||||
if (attempt.status === 'claimed') {
|
||||
({ run, attempt } = await this.transitionAttempt(
|
||||
transaction,
|
||||
run,
|
||||
attempt,
|
||||
{
|
||||
to: 'starting',
|
||||
expectedRunVersion: run.version,
|
||||
atMs: fact.atMs,
|
||||
},
|
||||
));
|
||||
}
|
||||
if (attempt.status === 'starting' || attempt.status === 'running') {
|
||||
({ run, attempt } = await this.transitionAttempt(
|
||||
transaction,
|
||||
run,
|
||||
attempt,
|
||||
{
|
||||
to: 'failed',
|
||||
expectedRunVersion: run.version,
|
||||
atMs: fact.atMs,
|
||||
errorCode: fact.errorCode,
|
||||
errorSummary: 'Legacy process failed to start',
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if (!isTerminalRunStatus(run.status)) {
|
||||
await this.transitionRunStatus(transaction, run, {
|
||||
to: 'failed',
|
||||
expectedVersion: run.version,
|
||||
atMs: fact.atMs,
|
||||
errorCode: fact.errorCode,
|
||||
errorSummary: 'Legacy process failed to start',
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async exited(
|
||||
reference: LegacyShadowRunReference,
|
||||
fact: LegacyExecutionExitedFact,
|
||||
): Promise<void> {
|
||||
await this.repository.transaction(async (transaction) => {
|
||||
let { run, attempt } = await this.load(transaction, reference);
|
||||
if (isTerminalRunStatus(run.status)) return;
|
||||
({ run, attempt } = await this.ensureSpawned(transaction, run, attempt, {
|
||||
atMs: fact.atMs,
|
||||
}));
|
||||
({ run, attempt } = await this.ensureRunning(
|
||||
transaction,
|
||||
run,
|
||||
attempt,
|
||||
fact.atMs,
|
||||
));
|
||||
|
||||
const succeeded = fact.exitCode === 0;
|
||||
const attemptTarget: RunAttemptStatus = succeeded
|
||||
? 'succeeded'
|
||||
: 'failed';
|
||||
const runTarget: RunStatus = succeeded ? 'succeeded' : 'failed';
|
||||
const errorCode =
|
||||
fact.exitCode === null
|
||||
? fact.signal
|
||||
? 'LEGACY_PROCESS_SIGNALLED'
|
||||
: 'LEGACY_EXIT_UNKNOWN'
|
||||
: succeeded
|
||||
? undefined
|
||||
: 'LEGACY_EXIT_NON_ZERO';
|
||||
const errorSummary = errorCode
|
||||
? 'Legacy process exited without success'
|
||||
: undefined;
|
||||
|
||||
if (!isTerminalRunAttemptStatus(attempt.status)) {
|
||||
({ run, attempt } = await this.transitionAttempt(
|
||||
transaction,
|
||||
run,
|
||||
attempt,
|
||||
{
|
||||
to: attemptTarget,
|
||||
expectedRunVersion: run.version,
|
||||
atMs: fact.atMs,
|
||||
...(fact.exitCode === null ? {} : { exitCode: fact.exitCode }),
|
||||
...(errorCode === undefined ? {} : { errorCode }),
|
||||
...(errorSummary === undefined ? {} : { errorSummary }),
|
||||
},
|
||||
fact.signal === undefined ? {} : { legacy_signal: fact.signal },
|
||||
));
|
||||
}
|
||||
|
||||
if (!isTerminalRunStatus(run.status)) {
|
||||
await this.transitionRunStatus(
|
||||
transaction,
|
||||
run,
|
||||
{
|
||||
to: runTarget,
|
||||
expectedVersion: run.version,
|
||||
atMs: fact.atMs,
|
||||
...(errorCode === undefined ? {} : { errorCode }),
|
||||
...(errorSummary === undefined ? {} : { errorSummary }),
|
||||
},
|
||||
{
|
||||
legacy_exit_code: fact.exitCode,
|
||||
...(fact.signal === undefined
|
||||
? {}
|
||||
: { legacy_signal: fact.signal }),
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async cancelled(
|
||||
reference: LegacyShadowRunReference,
|
||||
fact: LegacyExecutionCancelledFact,
|
||||
): Promise<void> {
|
||||
await this.repository.transaction(async (transaction) => {
|
||||
let { run, attempt } = await this.load(transaction, reference);
|
||||
if (isTerminalRunStatus(run.status)) return;
|
||||
|
||||
if (!isTerminalRunAttemptStatus(attempt.status)) {
|
||||
({ run, attempt } = await this.transitionAttempt(
|
||||
transaction,
|
||||
run,
|
||||
attempt,
|
||||
{
|
||||
to: 'cancelled',
|
||||
expectedRunVersion: run.version,
|
||||
atMs: fact.atMs,
|
||||
errorCode: 'LEGACY_EXECUTION_CANCELLED',
|
||||
errorSummary: 'Legacy execution was cancelled',
|
||||
},
|
||||
{ legacy_cancel_reason: fact.reason },
|
||||
));
|
||||
}
|
||||
if (!isTerminalRunStatus(run.status)) {
|
||||
await this.transitionRunStatus(
|
||||
transaction,
|
||||
run,
|
||||
{
|
||||
to: 'cancelled',
|
||||
expectedVersion: run.version,
|
||||
atMs: fact.atMs,
|
||||
errorCode: 'LEGACY_EXECUTION_CANCELLED',
|
||||
errorSummary: 'Legacy execution was cancelled',
|
||||
},
|
||||
{ legacy_cancel_reason: fact.reason },
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async load(
|
||||
transaction: RunRepositoryTransaction,
|
||||
reference: LegacyShadowRunReference,
|
||||
): Promise<{ run: RunRecord; attempt: RunAttemptRecord }> {
|
||||
const run = await transaction.findRunById(reference.runId);
|
||||
if (!run) throw new RunNotFoundError(reference.runId);
|
||||
const attempt = await transaction.findAttemptById(reference.attemptId);
|
||||
if (!attempt) throw new RunAttemptNotFoundError(reference.attemptId);
|
||||
if (attempt.runId !== run.id) {
|
||||
throw new RunAttemptConcurrentWriteError(
|
||||
attempt.id,
|
||||
attempt.status,
|
||||
attempt.callbackSequence,
|
||||
);
|
||||
}
|
||||
return { run, attempt };
|
||||
}
|
||||
|
||||
private async ensureSpawned(
|
||||
transaction: RunRepositoryTransaction,
|
||||
currentRun: RunRecord,
|
||||
currentAttempt: RunAttemptRecord,
|
||||
fact: LegacyExecutionSpawnedFact,
|
||||
): Promise<{ run: RunRecord; attempt: RunAttemptRecord }> {
|
||||
let run = currentRun;
|
||||
let attempt = currentAttempt;
|
||||
if (isTerminalRunStatus(run.status)) return { run, attempt };
|
||||
|
||||
if (run.status === 'created') {
|
||||
run = await this.transitionRunStatus(transaction, run, {
|
||||
to: 'queued',
|
||||
expectedVersion: run.version,
|
||||
atMs: fact.atMs,
|
||||
});
|
||||
}
|
||||
if (run.status === 'queued') {
|
||||
run = await this.transitionRunStatus(transaction, run, {
|
||||
to: 'dispatching',
|
||||
expectedVersion: run.version,
|
||||
atMs: fact.atMs,
|
||||
});
|
||||
}
|
||||
if (attempt.status === 'claimed') {
|
||||
({ run, attempt } = await this.transitionAttempt(
|
||||
transaction,
|
||||
run,
|
||||
attempt,
|
||||
{
|
||||
to: 'starting',
|
||||
expectedRunVersion: run.version,
|
||||
atMs: fact.atMs,
|
||||
...(fact.pid === undefined ? {} : { pid: fact.pid }),
|
||||
...(fact.executorHandle === undefined
|
||||
? {}
|
||||
: { executorHandle: fact.executorHandle }),
|
||||
...(fact.logArtifactId === undefined
|
||||
? {}
|
||||
: { logArtifactId: fact.logArtifactId }),
|
||||
},
|
||||
));
|
||||
}
|
||||
return { run, attempt };
|
||||
}
|
||||
|
||||
private async ensureRunning(
|
||||
transaction: RunRepositoryTransaction,
|
||||
currentRun: RunRecord,
|
||||
currentAttempt: RunAttemptRecord,
|
||||
atMs: number,
|
||||
): Promise<{ run: RunRecord; attempt: RunAttemptRecord }> {
|
||||
let run = currentRun;
|
||||
let attempt = currentAttempt;
|
||||
if (isTerminalRunStatus(run.status)) return { run, attempt };
|
||||
|
||||
if (attempt.status === 'starting') {
|
||||
({ run, attempt } = await this.transitionAttempt(
|
||||
transaction,
|
||||
run,
|
||||
attempt,
|
||||
{
|
||||
to: 'running',
|
||||
expectedRunVersion: run.version,
|
||||
atMs,
|
||||
},
|
||||
));
|
||||
}
|
||||
if (run.status === 'dispatching') {
|
||||
run = await this.transitionRunStatus(transaction, run, {
|
||||
to: 'running',
|
||||
expectedVersion: run.version,
|
||||
atMs,
|
||||
});
|
||||
}
|
||||
return { run, attempt };
|
||||
}
|
||||
|
||||
private async transitionRunStatus(
|
||||
transaction: RunRepositoryTransaction,
|
||||
current: RunRecord,
|
||||
command: RunTransitionCommand,
|
||||
extraPayload: Readonly<Record<string, unknown>> = {},
|
||||
): Promise<RunRecord> {
|
||||
const decision = transitionRun(current, command);
|
||||
await this.persistRunDecision(
|
||||
transaction,
|
||||
current,
|
||||
decision,
|
||||
command.atMs,
|
||||
extraPayload,
|
||||
);
|
||||
return decision.run;
|
||||
}
|
||||
|
||||
private async transitionAttempt(
|
||||
transaction: RunRepositoryTransaction,
|
||||
currentRun: RunRecord,
|
||||
currentAttempt: RunAttemptRecord,
|
||||
command: RunAttemptTransitionCommand,
|
||||
extraPayload: Readonly<Record<string, unknown>> = {},
|
||||
): Promise<{ run: RunRecord; attempt: RunAttemptRecord }> {
|
||||
const decision = transitionRunAttempt(currentRun, currentAttempt, command);
|
||||
await this.persistAttemptDecision(
|
||||
transaction,
|
||||
currentRun,
|
||||
currentAttempt,
|
||||
decision,
|
||||
command.atMs,
|
||||
extraPayload,
|
||||
);
|
||||
return { run: decision.run, attempt: decision.attempt };
|
||||
}
|
||||
|
||||
private async persistRunDecision(
|
||||
transaction: RunRepositoryTransaction,
|
||||
current: RunRecord,
|
||||
decision: RunTransitionDecision,
|
||||
atMs: number,
|
||||
extraPayload: Readonly<Record<string, unknown>> = {},
|
||||
): Promise<void> {
|
||||
const updated = await transaction.compareAndSetRun(
|
||||
decision.run,
|
||||
current.version,
|
||||
);
|
||||
if (!updated) {
|
||||
const latest = await transaction.findRunById(current.id);
|
||||
throw new RunVersionConflictError(
|
||||
current.id,
|
||||
current.version,
|
||||
latest?.version ?? current.version,
|
||||
);
|
||||
}
|
||||
await transaction.appendEvent(
|
||||
this.event(decision.run, decision.event, atMs, extraPayload),
|
||||
);
|
||||
}
|
||||
|
||||
private async persistAttemptDecision(
|
||||
transaction: RunRepositoryTransaction,
|
||||
currentRun: RunRecord,
|
||||
currentAttempt: RunAttemptRecord,
|
||||
decision: RunAttemptTransitionDecision,
|
||||
atMs: number,
|
||||
extraPayload: Readonly<Record<string, unknown>> = {},
|
||||
): Promise<void> {
|
||||
const runUpdated = await transaction.compareAndSetRun(
|
||||
decision.run,
|
||||
currentRun.version,
|
||||
);
|
||||
if (!runUpdated) {
|
||||
const latest = await transaction.findRunById(currentRun.id);
|
||||
throw new RunVersionConflictError(
|
||||
currentRun.id,
|
||||
currentRun.version,
|
||||
latest?.version ?? currentRun.version,
|
||||
);
|
||||
}
|
||||
const attemptUpdated = await transaction.compareAndSetAttempt(
|
||||
decision.attempt,
|
||||
{
|
||||
status: currentAttempt.status,
|
||||
callbackSequence: currentAttempt.callbackSequence,
|
||||
},
|
||||
);
|
||||
if (!attemptUpdated) {
|
||||
throw new RunAttemptConcurrentWriteError(
|
||||
currentAttempt.id,
|
||||
currentAttempt.status,
|
||||
currentAttempt.callbackSequence,
|
||||
);
|
||||
}
|
||||
await transaction.appendEvent(
|
||||
this.event(decision.run, decision.event, atMs, extraPayload),
|
||||
);
|
||||
}
|
||||
|
||||
private event(
|
||||
run: RunRecord,
|
||||
draft: RunDomainEventDraft,
|
||||
createdAtMs: number,
|
||||
extraPayload: Readonly<Record<string, unknown>> = {},
|
||||
): RunEventRecord {
|
||||
return {
|
||||
id: this.createId(),
|
||||
runId: run.id,
|
||||
sequence: draft.sequence,
|
||||
type: draft.type,
|
||||
dedupeKey: `shadow:${draft.sequence}:${draft.type}`,
|
||||
actorType: 'compatibility',
|
||||
payload: {
|
||||
...draft.payload,
|
||||
...extraPayload,
|
||||
shadow: true,
|
||||
},
|
||||
createdAtMs,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import {
|
||||
assertArtifactReadProjectId,
|
||||
normalizeArtifactReadSubject,
|
||||
normalizeLocalArtifactReadMetadata,
|
||||
normalizeLocalArtifactReadRange,
|
||||
type ArtifactReadSubject,
|
||||
type LocalArtifactReadMetadata,
|
||||
type LocalArtifactReadRange,
|
||||
} from '../domain/artifactRead';
|
||||
import { assertCompletionReceiptId } from '../domain/completionReceipt';
|
||||
import type { LocalArtifactTruncationFact } from '../domain/localArtifactTruncation';
|
||||
import { assertLocalExecutionArtifactId } from '../domain/localExecutionArtifact';
|
||||
import type {
|
||||
ArtifactReadAuthorizationEffect,
|
||||
ArtifactReadAuthorizer,
|
||||
} from '../ports/artifactReadAuthorizer';
|
||||
import type { LocalArtifactByteRangeReader } from '../ports/localArtifactByteRangeReader';
|
||||
import type { LocalArtifactReadMetadataRepository } from '../ports/localArtifactReadMetadataRepository';
|
||||
import type { LocalArtifactTruncationFactStore } from '../ports/localArtifactTruncationFactStore';
|
||||
|
||||
export type LocalArtifactTruncationState = boolean | 'unknown';
|
||||
|
||||
export interface LocalArtifactTruncationView {
|
||||
truncated: LocalArtifactTruncationState;
|
||||
maximumBytes?: number;
|
||||
observedAtMs?: number;
|
||||
}
|
||||
|
||||
export interface LocalArtifactReadRequest {
|
||||
subject: ArtifactReadSubject;
|
||||
projectId: string;
|
||||
runId: string;
|
||||
logArtifactId: string;
|
||||
range: LocalArtifactReadRange;
|
||||
}
|
||||
|
||||
interface LocalArtifactReadIdentity {
|
||||
projectId: string;
|
||||
runId: string;
|
||||
attemptId: string;
|
||||
logArtifactId: string;
|
||||
}
|
||||
|
||||
export type LocalArtifactReadResult =
|
||||
| { status: 'not_found' }
|
||||
| {
|
||||
status: 'forbidden';
|
||||
effect: Exclude<ArtifactReadAuthorizationEffect, 'allow'>;
|
||||
}
|
||||
| (LocalArtifactReadIdentity & {
|
||||
status: 'retained';
|
||||
retention: NonNullable<LocalArtifactReadMetadata['retention']>;
|
||||
truncation: { truncated: 'unknown' };
|
||||
})
|
||||
| (LocalArtifactReadIdentity & {
|
||||
status: 'missing';
|
||||
truncation: Readonly<LocalArtifactTruncationView>;
|
||||
})
|
||||
| (LocalArtifactReadIdentity & {
|
||||
status: 'available';
|
||||
content: Buffer;
|
||||
start: number;
|
||||
endExclusive: number;
|
||||
totalBytes: number;
|
||||
nextOffset?: number;
|
||||
truncation: Readonly<LocalArtifactTruncationView>;
|
||||
});
|
||||
|
||||
export class LocalArtifactReadEvidenceConflictError extends Error {
|
||||
constructor() {
|
||||
super('Local Artifact read evidence conflicts with database identity');
|
||||
this.name = 'LocalArtifactReadEvidenceConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
function identity(
|
||||
metadata: Readonly<LocalArtifactReadMetadata>,
|
||||
): LocalArtifactReadIdentity {
|
||||
return {
|
||||
projectId: metadata.projectId,
|
||||
runId: metadata.runId,
|
||||
attemptId: metadata.attemptId,
|
||||
logArtifactId: metadata.logArtifactId,
|
||||
};
|
||||
}
|
||||
|
||||
function sameIdentity(
|
||||
left: Readonly<LocalArtifactReadMetadata>,
|
||||
right: Readonly<LocalArtifactReadMetadata>,
|
||||
): boolean {
|
||||
return (
|
||||
left.projectId === right.projectId &&
|
||||
left.runId === right.runId &&
|
||||
left.attemptId === right.attemptId &&
|
||||
left.logArtifactId === right.logArtifactId
|
||||
);
|
||||
}
|
||||
|
||||
function truncationView(
|
||||
metadata: Readonly<LocalArtifactReadMetadata>,
|
||||
fact: Readonly<LocalArtifactTruncationFact> | null,
|
||||
): Readonly<LocalArtifactTruncationView> {
|
||||
if (!fact) return Object.freeze({ truncated: 'unknown' });
|
||||
if (
|
||||
fact.runId !== metadata.runId ||
|
||||
fact.attemptId !== metadata.attemptId ||
|
||||
fact.logArtifactId !== metadata.logArtifactId
|
||||
) {
|
||||
throw new LocalArtifactReadEvidenceConflictError();
|
||||
}
|
||||
return Object.freeze({
|
||||
truncated: fact.quotaReached,
|
||||
maximumBytes: fact.maximumBytes,
|
||||
observedAtMs: fact.observedAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
export class LocalArtifactReadService {
|
||||
constructor(
|
||||
private readonly metadata: LocalArtifactReadMetadataRepository,
|
||||
private readonly authorizer: ArtifactReadAuthorizer,
|
||||
private readonly bytes: LocalArtifactByteRangeReader,
|
||||
private readonly truncationFacts: LocalArtifactTruncationFactStore,
|
||||
) {}
|
||||
|
||||
async read(
|
||||
request: LocalArtifactReadRequest,
|
||||
): Promise<LocalArtifactReadResult> {
|
||||
if (!request || typeof request !== 'object' || Array.isArray(request)) {
|
||||
throw new TypeError('Local Artifact read request must be an object');
|
||||
}
|
||||
const subject = normalizeArtifactReadSubject(request.subject);
|
||||
assertArtifactReadProjectId(request.projectId);
|
||||
assertCompletionReceiptId(request.runId, 'runId');
|
||||
assertLocalExecutionArtifactId(request.logArtifactId);
|
||||
const range = normalizeLocalArtifactReadRange(request.range);
|
||||
const lookup = Object.freeze({
|
||||
projectId: request.projectId,
|
||||
runId: request.runId,
|
||||
logArtifactId: request.logArtifactId,
|
||||
});
|
||||
const initial = await this.metadata.find(lookup);
|
||||
if (!initial) return Object.freeze({ status: 'not_found' });
|
||||
const artifact = normalizeLocalArtifactReadMetadata(initial);
|
||||
const effect = await this.authorizer.authorize(
|
||||
Object.freeze({
|
||||
action: 'artifact.read',
|
||||
subject,
|
||||
projectId: artifact.projectId,
|
||||
runId: artifact.runId,
|
||||
logArtifactId: artifact.logArtifactId,
|
||||
}),
|
||||
);
|
||||
if (effect !== 'allow') {
|
||||
if (effect !== 'deny' && effect !== 'require_approval') {
|
||||
throw new TypeError('Artifact read authorization effect is invalid');
|
||||
}
|
||||
return Object.freeze({ status: 'forbidden', effect });
|
||||
}
|
||||
if (artifact.retention) {
|
||||
return Object.freeze({
|
||||
status: 'retained',
|
||||
...identity(artifact),
|
||||
retention: artifact.retention,
|
||||
truncation: Object.freeze({ truncated: 'unknown' as const }),
|
||||
});
|
||||
}
|
||||
|
||||
const content = await this.bytes.read(artifact.logArtifactId, range);
|
||||
if (content.status === 'missing') {
|
||||
const refreshedValue = await this.metadata.find(lookup);
|
||||
if (!refreshedValue) throw new LocalArtifactReadEvidenceConflictError();
|
||||
const refreshed = normalizeLocalArtifactReadMetadata(refreshedValue);
|
||||
if (!sameIdentity(artifact, refreshed)) {
|
||||
throw new LocalArtifactReadEvidenceConflictError();
|
||||
}
|
||||
if (refreshed.retention) {
|
||||
return Object.freeze({
|
||||
status: 'retained',
|
||||
...identity(refreshed),
|
||||
retention: refreshed.retention,
|
||||
truncation: Object.freeze({ truncated: 'unknown' as const }),
|
||||
});
|
||||
}
|
||||
const fact = await this.truncationFacts.read(artifact.logArtifactId);
|
||||
return Object.freeze({
|
||||
status: 'missing',
|
||||
...identity(artifact),
|
||||
truncation: truncationView(artifact, fact),
|
||||
});
|
||||
}
|
||||
|
||||
const fact = await this.truncationFacts.read(artifact.logArtifactId);
|
||||
return Object.freeze({
|
||||
status: 'available',
|
||||
...identity(artifact),
|
||||
content: content.content,
|
||||
start: content.start,
|
||||
endExclusive: content.endExclusive,
|
||||
totalBytes: content.totalBytes,
|
||||
...(content.nextOffset === undefined
|
||||
? {}
|
||||
: { nextOffset: content.nextOffset }),
|
||||
truncation: truncationView(artifact, fact),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import type { LocalArtifactRetentionCursor } from '../domain/localArtifactRetention';
|
||||
import type { LocalArtifactRetentionCheckpointStore } from '../ports/localArtifactRetentionCheckpointStore';
|
||||
import type {
|
||||
LocalArtifactRetentionService,
|
||||
LocalArtifactRetentionSweepResult,
|
||||
} from './localArtifactRetentionService';
|
||||
|
||||
export const MIN_LOCAL_ARTIFACT_RETENTION_INTERVAL_MS = 1_000;
|
||||
export const MAX_LOCAL_ARTIFACT_RETENTION_INTERVAL_MS = 24 * 60 * 60 * 1_000;
|
||||
export const MAX_LOCAL_ARTIFACT_RETENTION_INITIAL_DELAY_MS =
|
||||
24 * 60 * 60 * 1_000;
|
||||
export const MAX_LOCAL_ARTIFACT_RETENTION_STOP_TIMEOUT_MS = 60_000;
|
||||
|
||||
interface ScheduledTimer {
|
||||
unref?: () => void;
|
||||
}
|
||||
|
||||
export interface LocalArtifactRetentionLifecycleScheduler {
|
||||
setTimeout(callback: () => void, delayMs: number): ScheduledTimer;
|
||||
clearTimeout(timer: ScheduledTimer): void;
|
||||
}
|
||||
|
||||
export interface LocalArtifactRetentionCycleSummary {
|
||||
pressure: boolean;
|
||||
observedAtMs: number;
|
||||
retentionMs: number;
|
||||
availableBytes: string;
|
||||
totalBytes: string;
|
||||
candidatesScanned: number;
|
||||
deletionsAttempted: number;
|
||||
recordsWritten: number;
|
||||
failedCandidates: number;
|
||||
bytesReclaimed: number;
|
||||
sweepStatus: LocalArtifactRetentionSweepResult['status'];
|
||||
cursorAction: 'unchanged' | 'advanced' | 'cleared' | 'fenced';
|
||||
}
|
||||
|
||||
export interface LocalArtifactRetentionLifecycleOptions {
|
||||
intervalMs: number;
|
||||
initialDelayMs?: number;
|
||||
stopTimeoutMs?: number;
|
||||
scheduler?: LocalArtifactRetentionLifecycleScheduler;
|
||||
onCycle?: (summary: Readonly<LocalArtifactRetentionCycleSummary>) => void;
|
||||
onError?: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export type LocalArtifactRetentionStopResult = 'drained' | 'timed_out';
|
||||
|
||||
const defaultScheduler: LocalArtifactRetentionLifecycleScheduler = {
|
||||
setTimeout(callback, delayMs) {
|
||||
return setTimeout(callback, delayMs);
|
||||
},
|
||||
clearTimeout(timer) {
|
||||
clearTimeout(timer as ReturnType<typeof setTimeout>);
|
||||
},
|
||||
};
|
||||
|
||||
function assertIntegerBetween(
|
||||
name: string,
|
||||
value: number,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): void {
|
||||
if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
|
||||
throw new RangeError(`${name} must be between ${minimum} and ${maximum}`);
|
||||
}
|
||||
}
|
||||
|
||||
function sameCursor(
|
||||
left: LocalArtifactRetentionCursor | undefined,
|
||||
right: LocalArtifactRetentionCursor | undefined,
|
||||
): boolean {
|
||||
return (
|
||||
left === right ||
|
||||
(left !== undefined &&
|
||||
right !== undefined &&
|
||||
left.finishedAtMs === right.finishedAtMs &&
|
||||
left.attemptId === right.attemptId)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicit one-page cadence with a durable CAS cursor. Idle complete cycles do
|
||||
* not write a checkpoint, keeping edge flash write amplification bounded.
|
||||
*/
|
||||
export class LocalArtifactRetentionLifecycle {
|
||||
private readonly intervalMs: number;
|
||||
private readonly initialDelayMs: number;
|
||||
private readonly stopTimeoutMs: number;
|
||||
private readonly scheduler: LocalArtifactRetentionLifecycleScheduler;
|
||||
private readonly onCycle?: LocalArtifactRetentionLifecycleOptions['onCycle'];
|
||||
private readonly onError?: LocalArtifactRetentionLifecycleOptions['onError'];
|
||||
private started = false;
|
||||
private timer?: ScheduledTimer;
|
||||
private inFlight?: Promise<void>;
|
||||
|
||||
constructor(
|
||||
private readonly service: Pick<LocalArtifactRetentionService, 'sweep'>,
|
||||
private readonly checkpoints: LocalArtifactRetentionCheckpointStore,
|
||||
options: LocalArtifactRetentionLifecycleOptions,
|
||||
) {
|
||||
this.intervalMs = options.intervalMs;
|
||||
this.initialDelayMs = options.initialDelayMs ?? 0;
|
||||
this.stopTimeoutMs = options.stopTimeoutMs ?? 5_000;
|
||||
this.scheduler = options.scheduler ?? defaultScheduler;
|
||||
this.onCycle = options.onCycle;
|
||||
this.onError = options.onError;
|
||||
assertIntegerBetween(
|
||||
'intervalMs',
|
||||
this.intervalMs,
|
||||
MIN_LOCAL_ARTIFACT_RETENTION_INTERVAL_MS,
|
||||
MAX_LOCAL_ARTIFACT_RETENTION_INTERVAL_MS,
|
||||
);
|
||||
assertIntegerBetween(
|
||||
'initialDelayMs',
|
||||
this.initialDelayMs,
|
||||
0,
|
||||
MAX_LOCAL_ARTIFACT_RETENTION_INITIAL_DELAY_MS,
|
||||
);
|
||||
assertIntegerBetween(
|
||||
'stopTimeoutMs',
|
||||
this.stopTimeoutMs,
|
||||
1,
|
||||
MAX_LOCAL_ARTIFACT_RETENTION_STOP_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
|
||||
start(): boolean {
|
||||
if (this.started || this.inFlight) return false;
|
||||
this.started = true;
|
||||
this.schedule(this.initialDelayMs);
|
||||
return true;
|
||||
}
|
||||
|
||||
async stop(): Promise<LocalArtifactRetentionStopResult> {
|
||||
this.started = false;
|
||||
if (this.timer) {
|
||||
this.scheduler.clearTimeout(this.timer);
|
||||
this.timer = undefined;
|
||||
}
|
||||
const inFlight = this.inFlight;
|
||||
if (!inFlight) return 'drained';
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
const result = await Promise.race<LocalArtifactRetentionStopResult>([
|
||||
inFlight.then(() => 'drained' as const),
|
||||
new Promise<'timed_out'>((resolve) => {
|
||||
timeout = setTimeout(() => resolve('timed_out'), this.stopTimeoutMs);
|
||||
}),
|
||||
]);
|
||||
if (timeout) clearTimeout(timeout);
|
||||
return result;
|
||||
}
|
||||
|
||||
private schedule(delayMs: number): void {
|
||||
if (!this.started || this.timer) return;
|
||||
const timer = this.scheduler.setTimeout(() => {
|
||||
if (this.timer === timer) this.timer = undefined;
|
||||
this.run();
|
||||
}, delayMs);
|
||||
this.timer = timer;
|
||||
timer.unref?.();
|
||||
}
|
||||
|
||||
private run(): void {
|
||||
if (!this.started || this.inFlight) return;
|
||||
const inFlight = this.runCycle()
|
||||
.then((summary) => this.notifyCycle(summary))
|
||||
.catch((error) => this.notifyError(error))
|
||||
.then(() => undefined)
|
||||
.finally(() => {
|
||||
if (this.inFlight === inFlight) this.inFlight = undefined;
|
||||
if (this.started) this.schedule(this.intervalMs);
|
||||
});
|
||||
this.inFlight = inFlight;
|
||||
}
|
||||
|
||||
private async runCycle(): Promise<LocalArtifactRetentionCycleSummary> {
|
||||
const checkpoint = await this.checkpoints.load();
|
||||
const sweep = await this.service.sweep(checkpoint.cursor);
|
||||
const nextCursor =
|
||||
sweep.status === 'complete' ? undefined : sweep.nextCursor;
|
||||
if (sweep.status !== 'complete' && !nextCursor) {
|
||||
throw new TypeError(
|
||||
'Incomplete Local Artifact retention sweep requires a resume cursor',
|
||||
);
|
||||
}
|
||||
let cursorAction: LocalArtifactRetentionCycleSummary['cursorAction'] =
|
||||
'unchanged';
|
||||
if (!sameCursor(checkpoint.cursor, nextCursor)) {
|
||||
const updated = await this.checkpoints.compareAndSet({
|
||||
expectedVersion: checkpoint.version,
|
||||
...(nextCursor ? { cursor: nextCursor } : {}),
|
||||
updatedAtMs: sweep.observedAtMs,
|
||||
});
|
||||
cursorAction = updated ? (nextCursor ? 'advanced' : 'cleared') : 'fenced';
|
||||
}
|
||||
return Object.freeze({
|
||||
pressure: sweep.pressure,
|
||||
observedAtMs: sweep.observedAtMs,
|
||||
retentionMs: sweep.retentionMs,
|
||||
availableBytes: sweep.availableBytes.toString(10),
|
||||
totalBytes: sweep.totalBytes.toString(10),
|
||||
candidatesScanned: sweep.candidatesScanned,
|
||||
deletionsAttempted: sweep.deletionsAttempted,
|
||||
recordsWritten: sweep.recordsWritten,
|
||||
failedCandidates: sweep.failedCandidates,
|
||||
bytesReclaimed: sweep.bytesReclaimed,
|
||||
sweepStatus: sweep.status,
|
||||
cursorAction,
|
||||
});
|
||||
}
|
||||
|
||||
private notifyCycle(summary: LocalArtifactRetentionCycleSummary): void {
|
||||
try {
|
||||
this.onCycle?.(summary);
|
||||
} catch (error) {
|
||||
this.notifyError(error);
|
||||
}
|
||||
}
|
||||
|
||||
private notifyError(error: unknown): void {
|
||||
try {
|
||||
this.onError?.(error);
|
||||
} catch {
|
||||
// Diagnostics must never create another scheduler failure loop.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
import type {
|
||||
LocalArtifactRetentionCandidate,
|
||||
LocalArtifactRetentionCursor,
|
||||
} from '../domain/localArtifactRetention';
|
||||
import {
|
||||
normalizeLocalArtifactRetentionCandidate,
|
||||
normalizeLocalArtifactRetentionCursor,
|
||||
} from '../domain/localArtifactRetention';
|
||||
import type { LocalArtifactCapacitySource } from '../ports/localArtifactCapacityProbe';
|
||||
import type { LocalArtifactFileRetirementStore } from '../ports/localArtifactFileRetirementStore';
|
||||
import type {
|
||||
LocalArtifactRetentionPage,
|
||||
LocalArtifactRetentionRepository,
|
||||
} from '../ports/localArtifactRetentionRepository';
|
||||
import { MAX_LOCAL_ARTIFACT_RETENTION_PAGE_SIZE } from '../ports/localArtifactRetentionRepository';
|
||||
|
||||
export const MIN_LOCAL_ARTIFACT_RETENTION_MS = 60_000;
|
||||
export const MAX_LOCAL_ARTIFACT_RETENTION_MS = 365 * 24 * 60 * 60_000;
|
||||
|
||||
export interface LocalArtifactRetentionServiceOptions {
|
||||
normalRetentionMs: number;
|
||||
pressureRetentionMs: number;
|
||||
minimumFreeBytes: number;
|
||||
pageSize?: number;
|
||||
maximumDeletions?: number;
|
||||
clock?: { now(): number };
|
||||
}
|
||||
|
||||
export interface LocalArtifactRetentionEntry {
|
||||
attemptId: string;
|
||||
logArtifactId: string;
|
||||
outcome: 'deleted' | 'already_absent' | 'file_failed' | 'record_failed';
|
||||
bytesReclaimed: number;
|
||||
}
|
||||
|
||||
export interface LocalArtifactRetentionSweepResult {
|
||||
status: 'complete' | 'page_complete' | 'deletion_budget_exhausted';
|
||||
pressure: boolean;
|
||||
observedAtMs: number;
|
||||
retentionMs: number;
|
||||
availableBytes: bigint;
|
||||
totalBytes: bigint;
|
||||
candidatesScanned: number;
|
||||
deletionsAttempted: number;
|
||||
recordsWritten: number;
|
||||
failedCandidates: number;
|
||||
bytesReclaimed: number;
|
||||
entries: readonly LocalArtifactRetentionEntry[];
|
||||
nextCursor?: LocalArtifactRetentionCursor;
|
||||
}
|
||||
|
||||
export class InvalidLocalArtifactRetentionPageError extends Error {
|
||||
constructor(message: string) {
|
||||
super(`Local Artifact retention page is invalid: ${message}`);
|
||||
this.name = 'InvalidLocalArtifactRetentionPageError';
|
||||
}
|
||||
}
|
||||
|
||||
function assertIntegerBetween(
|
||||
name: string,
|
||||
value: number,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): void {
|
||||
if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
|
||||
throw new RangeError(`${name} must be between ${minimum} and ${maximum}`);
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalArtifactRetentionService {
|
||||
private readonly normalRetentionMs: number;
|
||||
private readonly pressureRetentionMs: number;
|
||||
private readonly minimumFreeBytes: number;
|
||||
private readonly pageSize: number;
|
||||
private readonly maximumDeletions: number;
|
||||
private readonly clock: { now(): number };
|
||||
|
||||
constructor(
|
||||
private readonly repository: LocalArtifactRetentionRepository,
|
||||
private readonly files: LocalArtifactFileRetirementStore,
|
||||
private readonly capacity: LocalArtifactCapacitySource,
|
||||
options: LocalArtifactRetentionServiceOptions,
|
||||
) {
|
||||
this.normalRetentionMs = options.normalRetentionMs;
|
||||
this.pressureRetentionMs = options.pressureRetentionMs;
|
||||
this.minimumFreeBytes = options.minimumFreeBytes;
|
||||
this.pageSize = options.pageSize ?? 16;
|
||||
this.maximumDeletions = options.maximumDeletions ?? 8;
|
||||
this.clock = options.clock ?? Date;
|
||||
assertIntegerBetween(
|
||||
'normalRetentionMs',
|
||||
this.normalRetentionMs,
|
||||
MIN_LOCAL_ARTIFACT_RETENTION_MS,
|
||||
MAX_LOCAL_ARTIFACT_RETENTION_MS,
|
||||
);
|
||||
assertIntegerBetween(
|
||||
'pressureRetentionMs',
|
||||
this.pressureRetentionMs,
|
||||
MIN_LOCAL_ARTIFACT_RETENTION_MS,
|
||||
this.normalRetentionMs,
|
||||
);
|
||||
assertIntegerBetween(
|
||||
'minimumFreeBytes',
|
||||
this.minimumFreeBytes,
|
||||
0,
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
);
|
||||
assertIntegerBetween(
|
||||
'pageSize',
|
||||
this.pageSize,
|
||||
1,
|
||||
MAX_LOCAL_ARTIFACT_RETENTION_PAGE_SIZE,
|
||||
);
|
||||
assertIntegerBetween(
|
||||
'maximumDeletions',
|
||||
this.maximumDeletions,
|
||||
1,
|
||||
this.pageSize,
|
||||
);
|
||||
}
|
||||
|
||||
async sweep(
|
||||
cursor?: LocalArtifactRetentionCursor,
|
||||
): Promise<LocalArtifactRetentionSweepResult> {
|
||||
const normalizedCursor = cursor
|
||||
? normalizeLocalArtifactRetentionCursor(cursor)
|
||||
: undefined;
|
||||
const observedAtMs = this.now();
|
||||
const capacity = await this.capacity.inspect();
|
||||
if (
|
||||
typeof capacity?.availableBytes !== 'bigint' ||
|
||||
typeof capacity.totalBytes !== 'bigint' ||
|
||||
capacity.availableBytes < BigInt(0) ||
|
||||
capacity.totalBytes < BigInt(1) ||
|
||||
capacity.availableBytes > capacity.totalBytes
|
||||
) {
|
||||
throw new TypeError('Local Artifact capacity snapshot is invalid');
|
||||
}
|
||||
const pressure = capacity.availableBytes < BigInt(this.minimumFreeBytes);
|
||||
const retentionMs = pressure
|
||||
? this.pressureRetentionMs
|
||||
: this.normalRetentionMs;
|
||||
const cutoffMs = Math.max(0, observedAtMs - retentionMs);
|
||||
const page = await this.repository.list({
|
||||
cutoffMs,
|
||||
...(normalizedCursor ? { cursor: normalizedCursor } : {}),
|
||||
limit: this.pageSize,
|
||||
});
|
||||
this.assertPage(page, normalizedCursor);
|
||||
|
||||
const entries: LocalArtifactRetentionEntry[] = [];
|
||||
let candidatesScanned = 0;
|
||||
let deletionsAttempted = 0;
|
||||
let recordsWritten = 0;
|
||||
let failedCandidates = 0;
|
||||
let bytesReclaimed = 0;
|
||||
let lastProcessed = normalizedCursor;
|
||||
|
||||
for (const candidate of page.candidates) {
|
||||
if (deletionsAttempted >= this.maximumDeletions) {
|
||||
return this.result({
|
||||
status: 'deletion_budget_exhausted',
|
||||
pressure,
|
||||
observedAtMs,
|
||||
retentionMs,
|
||||
availableBytes: capacity.availableBytes,
|
||||
totalBytes: capacity.totalBytes,
|
||||
candidatesScanned,
|
||||
deletionsAttempted,
|
||||
recordsWritten,
|
||||
failedCandidates,
|
||||
bytesReclaimed,
|
||||
entries,
|
||||
nextCursor: lastProcessed,
|
||||
});
|
||||
}
|
||||
candidatesScanned += 1;
|
||||
deletionsAttempted += 1;
|
||||
lastProcessed = this.cursor(candidate);
|
||||
let retired;
|
||||
try {
|
||||
retired = await this.files.retire(candidate.logArtifactId);
|
||||
} catch {
|
||||
failedCandidates += 1;
|
||||
entries.push({
|
||||
attemptId: candidate.attemptId,
|
||||
logArtifactId: candidate.logArtifactId,
|
||||
outcome: 'file_failed',
|
||||
bytesReclaimed: 0,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await this.repository.record({
|
||||
...candidate,
|
||||
eligibleAtMs: candidate.finishedAtMs + retentionMs,
|
||||
disposition: retired.disposition,
|
||||
bytesReclaimed: retired.bytesReclaimed,
|
||||
recordedAtMs: observedAtMs,
|
||||
});
|
||||
recordsWritten += 1;
|
||||
bytesReclaimed += retired.bytesReclaimed;
|
||||
entries.push({
|
||||
attemptId: candidate.attemptId,
|
||||
logArtifactId: candidate.logArtifactId,
|
||||
outcome: retired.disposition,
|
||||
bytesReclaimed: retired.bytesReclaimed,
|
||||
});
|
||||
} catch {
|
||||
failedCandidates += 1;
|
||||
entries.push({
|
||||
attemptId: candidate.attemptId,
|
||||
logArtifactId: candidate.logArtifactId,
|
||||
outcome: 'record_failed',
|
||||
bytesReclaimed: retired.bytesReclaimed,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return this.result({
|
||||
status: page.truncated ? 'page_complete' : 'complete',
|
||||
pressure,
|
||||
observedAtMs,
|
||||
retentionMs,
|
||||
availableBytes: capacity.availableBytes,
|
||||
totalBytes: capacity.totalBytes,
|
||||
candidatesScanned,
|
||||
deletionsAttempted,
|
||||
recordsWritten,
|
||||
failedCandidates,
|
||||
bytesReclaimed,
|
||||
entries,
|
||||
nextCursor: page.nextCursor,
|
||||
});
|
||||
}
|
||||
|
||||
private assertPage(
|
||||
page: LocalArtifactRetentionPage,
|
||||
cursor: Readonly<LocalArtifactRetentionCursor> | undefined,
|
||||
): void {
|
||||
if (
|
||||
!page ||
|
||||
!Array.isArray(page.candidates) ||
|
||||
page.candidates.length > this.pageSize ||
|
||||
typeof page.truncated !== 'boolean'
|
||||
) {
|
||||
throw new InvalidLocalArtifactRetentionPageError(
|
||||
'candidate count exceeds pageSize',
|
||||
);
|
||||
}
|
||||
let previous = cursor;
|
||||
for (const candidate of page.candidates) {
|
||||
normalizeLocalArtifactRetentionCandidate(candidate);
|
||||
if (
|
||||
previous &&
|
||||
(candidate.finishedAtMs < previous.finishedAtMs ||
|
||||
(candidate.finishedAtMs === previous.finishedAtMs &&
|
||||
candidate.attemptId <= previous.attemptId))
|
||||
) {
|
||||
throw new InvalidLocalArtifactRetentionPageError(
|
||||
'candidate cursor did not advance',
|
||||
);
|
||||
}
|
||||
previous = candidate;
|
||||
}
|
||||
const last = page.candidates[page.candidates.length - 1];
|
||||
if (
|
||||
page.truncated !== (page.nextCursor !== undefined) ||
|
||||
(page.nextCursor &&
|
||||
(!last ||
|
||||
page.nextCursor.finishedAtMs !== last.finishedAtMs ||
|
||||
page.nextCursor.attemptId !== last.attemptId))
|
||||
) {
|
||||
throw new InvalidLocalArtifactRetentionPageError(
|
||||
'resume cursor is inconsistent',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private cursor(
|
||||
candidate: LocalArtifactRetentionCandidate,
|
||||
): LocalArtifactRetentionCursor {
|
||||
return Object.freeze({
|
||||
finishedAtMs: candidate.finishedAtMs,
|
||||
attemptId: candidate.attemptId,
|
||||
});
|
||||
}
|
||||
|
||||
private now(): number {
|
||||
const value = this.clock.now();
|
||||
if (!Number.isSafeInteger(value) || value < 0) {
|
||||
throw new TypeError('Local Artifact retention clock is invalid');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private result(
|
||||
value: Omit<LocalArtifactRetentionSweepResult, 'entries'> & {
|
||||
entries: LocalArtifactRetentionEntry[];
|
||||
},
|
||||
): LocalArtifactRetentionSweepResult {
|
||||
const { nextCursor, ...rest } = value;
|
||||
return Object.freeze({
|
||||
...rest,
|
||||
entries: Object.freeze([...value.entries]),
|
||||
...(nextCursor ? { nextCursor: Object.freeze({ ...nextCursor }) } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
import { EXECUTOR_TYPES, type ExecutorType } from '../domain/execution';
|
||||
import {
|
||||
assertRunDispatchCandidate,
|
||||
assertRunDispatchCandidatePageSize,
|
||||
type RunDispatchCandidate,
|
||||
type RunDispatchCandidateCursor,
|
||||
} from '../domain/runDispatchCandidate';
|
||||
import { assertRunDispatchLeaseVersion } from '../domain/runDispatchLease';
|
||||
import { executionSpecForRunDispatchCandidate } from '../domain/runDispatchPlan';
|
||||
import type { RunDispatchCandidateSource } from '../ports/runDispatchCandidateSource';
|
||||
import type { LocalRunDispatchPlanSource } from '../ports/localRunDispatchPlanSource';
|
||||
import type {
|
||||
ActivePrimaryRun,
|
||||
PrimaryClaimedRunStartCommand,
|
||||
} from './primaryRunOrchestrator';
|
||||
import {
|
||||
PrimaryClaimedRunRejectedError,
|
||||
PrimaryRunLaunchError,
|
||||
} from './primaryRunOrchestrator';
|
||||
|
||||
const DEFAULT_LOCAL_DISPATCH_PAGE_SIZE = 8;
|
||||
const DEFAULT_LOCAL_DISPATCH_MAX_PAGES = 1;
|
||||
const MAX_LOCAL_DISPATCH_PAGES = 16;
|
||||
|
||||
export interface LocalClaimedRunActivator {
|
||||
activateClaimed(
|
||||
command: PrimaryClaimedRunStartCommand,
|
||||
): Promise<ActivePrimaryRun>;
|
||||
}
|
||||
|
||||
export interface LocalRunDispatcherOptions {
|
||||
executorType: ExecutorType;
|
||||
pageSize?: number;
|
||||
maxPages?: number;
|
||||
clock?: { now(): number };
|
||||
onDisposeError?: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export interface LocalRunDispatcherStats {
|
||||
pages: number;
|
||||
candidatesScanned: number;
|
||||
executorMismatches: number;
|
||||
plansUnavailable: number;
|
||||
activationRaces: number;
|
||||
}
|
||||
|
||||
export type LocalRunDispatcherIdleReason =
|
||||
| 'no_candidates'
|
||||
| 'no_matching_executor'
|
||||
| 'plans_unavailable'
|
||||
| 'activation_raced'
|
||||
| 'scan_budget_exhausted';
|
||||
|
||||
export type LocalRunDispatcherResult =
|
||||
| {
|
||||
status: 'activated';
|
||||
runId: string;
|
||||
attemptId: string;
|
||||
completion: ActivePrimaryRun['completion'];
|
||||
stats: LocalRunDispatcherStats;
|
||||
truncated: boolean;
|
||||
}
|
||||
| {
|
||||
status: 'activation_failed';
|
||||
runId: string;
|
||||
attemptId: string;
|
||||
stats: LocalRunDispatcherStats;
|
||||
truncated: boolean;
|
||||
}
|
||||
| {
|
||||
status: 'idle';
|
||||
reason: LocalRunDispatcherIdleReason;
|
||||
stats: LocalRunDispatcherStats;
|
||||
truncated: boolean;
|
||||
};
|
||||
|
||||
function cursorOf(candidate: RunDispatchCandidate): RunDispatchCandidateCursor {
|
||||
return {
|
||||
priority: candidate.priority,
|
||||
queuedAtMs: candidate.queuedAtMs,
|
||||
attemptCreatedAtMs: candidate.attemptCreatedAtMs,
|
||||
attemptId: candidate.attemptId,
|
||||
};
|
||||
}
|
||||
|
||||
function cursorAdvances(
|
||||
previous: RunDispatchCandidateCursor,
|
||||
next: RunDispatchCandidateCursor,
|
||||
): 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)))))
|
||||
);
|
||||
}
|
||||
|
||||
function emptyStats(): LocalRunDispatcherStats {
|
||||
return {
|
||||
pages: 0,
|
||||
candidatesScanned: 0,
|
||||
executorMismatches: 0,
|
||||
plansUnavailable: 0,
|
||||
activationRaces: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** One bounded local dispatch cycle. It owns neither a timer nor task storage. */
|
||||
export class LocalRunDispatcher {
|
||||
private readonly executorType: ExecutorType;
|
||||
private readonly pageSize: number;
|
||||
private readonly maxPages: number;
|
||||
private readonly clock: { now(): number };
|
||||
private readonly onDisposeError?: (error: unknown) => void;
|
||||
|
||||
constructor(
|
||||
private readonly candidates: RunDispatchCandidateSource,
|
||||
private readonly plans: LocalRunDispatchPlanSource,
|
||||
private readonly activator: LocalClaimedRunActivator,
|
||||
options: LocalRunDispatcherOptions,
|
||||
) {
|
||||
this.executorType = options.executorType;
|
||||
this.pageSize = options.pageSize ?? DEFAULT_LOCAL_DISPATCH_PAGE_SIZE;
|
||||
this.maxPages = options.maxPages ?? DEFAULT_LOCAL_DISPATCH_MAX_PAGES;
|
||||
this.clock = options.clock ?? Date;
|
||||
this.onDisposeError = options.onDisposeError;
|
||||
if (!EXECUTOR_TYPES.includes(this.executorType)) {
|
||||
throw new TypeError('Local Run Dispatcher executorType is invalid');
|
||||
}
|
||||
assertRunDispatchCandidatePageSize(this.pageSize);
|
||||
if (
|
||||
!Number.isSafeInteger(this.maxPages) ||
|
||||
this.maxPages < 1 ||
|
||||
this.maxPages > MAX_LOCAL_DISPATCH_PAGES
|
||||
) {
|
||||
throw new RangeError(
|
||||
`maxPages must be between 1 and ${MAX_LOCAL_DISPATCH_PAGES}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async dispatchOnce(): Promise<LocalRunDispatcherResult> {
|
||||
const observedAtMs = this.clock.now();
|
||||
assertRunDispatchLeaseVersion('observedAtMs', observedAtMs);
|
||||
const stats = emptyStats();
|
||||
const seen = new Set<string>();
|
||||
let after: RunDispatchCandidateCursor | undefined;
|
||||
|
||||
for (let pageIndex = 0; pageIndex < this.maxPages; pageIndex += 1) {
|
||||
const page = await this.candidates.listCandidates({
|
||||
observedAtMs,
|
||||
...(after === undefined ? {} : { after }),
|
||||
limit: this.pageSize,
|
||||
});
|
||||
if (page.length > this.pageSize) {
|
||||
throw new RangeError('Local Run candidate source exceeded page size');
|
||||
}
|
||||
stats.pages += 1;
|
||||
let previous = after;
|
||||
for (const candidate of page) {
|
||||
assertRunDispatchCandidate(candidate);
|
||||
const cursor = cursorOf(candidate);
|
||||
if (
|
||||
seen.has(candidate.attemptId) ||
|
||||
(previous !== undefined && !cursorAdvances(previous, cursor))
|
||||
) {
|
||||
throw new Error('Local Run candidate page is not strictly ordered');
|
||||
}
|
||||
seen.add(candidate.attemptId);
|
||||
previous = cursor;
|
||||
stats.candidatesScanned += 1;
|
||||
if (candidate.executorType !== this.executorType) {
|
||||
stats.executorMismatches += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const plan = await this.plans.prepare(Object.freeze({ ...candidate }));
|
||||
if (!plan) {
|
||||
stats.plansUnavailable += 1;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const spec = executionSpecForRunDispatchCandidate(
|
||||
candidate,
|
||||
plan.executionSpec,
|
||||
);
|
||||
const active = await this.activator.activateClaimed({
|
||||
runId: candidate.runId,
|
||||
attemptId: candidate.attemptId,
|
||||
...(spec.timeoutMs === undefined
|
||||
? {}
|
||||
: { timeoutMs: spec.timeoutMs }),
|
||||
createSpec: () => spec,
|
||||
context: plan.context,
|
||||
...(plan.logArtifactId === undefined
|
||||
? {}
|
||||
: { logArtifactId: plan.logArtifactId }),
|
||||
});
|
||||
this.disposeAfterCompletion(active, plan.dispose);
|
||||
return {
|
||||
status: 'activated',
|
||||
runId: active.run.id,
|
||||
attemptId: active.attempt.id,
|
||||
completion: active.completion,
|
||||
stats,
|
||||
truncated: page.length === this.pageSize,
|
||||
};
|
||||
} catch (error) {
|
||||
await this.dispose(plan.dispose);
|
||||
if (error instanceof PrimaryClaimedRunRejectedError) {
|
||||
if (
|
||||
error.reason === 'aggregate_mismatch' ||
|
||||
error.reason === 'executor_mismatch'
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
stats.activationRaces += 1;
|
||||
continue;
|
||||
}
|
||||
if (error instanceof PrimaryRunLaunchError) {
|
||||
return {
|
||||
status: 'activation_failed',
|
||||
runId: candidate.runId,
|
||||
attemptId: candidate.attemptId,
|
||||
stats,
|
||||
truncated: page.length === this.pageSize,
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (page.length < this.pageSize) {
|
||||
return this.idle(stats, false);
|
||||
}
|
||||
after = cursorOf(page[page.length - 1]);
|
||||
}
|
||||
return {
|
||||
status: 'idle',
|
||||
reason: 'scan_budget_exhausted',
|
||||
stats,
|
||||
truncated: true,
|
||||
};
|
||||
}
|
||||
|
||||
private idle(
|
||||
stats: LocalRunDispatcherStats,
|
||||
truncated: boolean,
|
||||
): LocalRunDispatcherResult {
|
||||
const eligible = stats.candidatesScanned - stats.executorMismatches;
|
||||
const reason: LocalRunDispatcherIdleReason =
|
||||
stats.candidatesScanned === 0
|
||||
? 'no_candidates'
|
||||
: eligible === 0
|
||||
? 'no_matching_executor'
|
||||
: stats.plansUnavailable === eligible
|
||||
? 'plans_unavailable'
|
||||
: 'activation_raced';
|
||||
return { status: 'idle', reason, stats, truncated };
|
||||
}
|
||||
|
||||
private disposeAfterCompletion(
|
||||
active: ActivePrimaryRun,
|
||||
dispose: (() => void | Promise<void>) | undefined,
|
||||
): void {
|
||||
if (!dispose) return;
|
||||
void active.completion.then(
|
||||
() => this.dispose(dispose),
|
||||
() => this.dispose(dispose),
|
||||
);
|
||||
}
|
||||
|
||||
private async dispose(
|
||||
dispose: (() => void | Promise<void>) | undefined,
|
||||
): Promise<void> {
|
||||
if (!dispose) return;
|
||||
try {
|
||||
await dispose();
|
||||
} catch (error) {
|
||||
try {
|
||||
this.onDisposeError?.(error);
|
||||
} catch {
|
||||
// Diagnostics must not change activation ownership.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { PinnedTaskExecutionRevision } from '../domain/taskExecutionRevision';
|
||||
import type { LocalExecutionContextRecipe } from '../domain/localExecutionContextRecipe';
|
||||
import type { LocalExecutionContextRecipeRepository } from '../ports/localExecutionContextRecipeRepository';
|
||||
import type { TaskExecutionRevisionRepository } from '../ports/taskExecutionRevisionRepository';
|
||||
|
||||
export interface PublishLocalTaskExecutionRevisionCommand {
|
||||
revision: PinnedTaskExecutionRevision;
|
||||
contextRecipe: LocalExecutionContextRecipe;
|
||||
createdAtMs: number;
|
||||
}
|
||||
|
||||
export interface PublishLocalTaskExecutionRevisionResult {
|
||||
contextRecipe: 'inserted' | 'idempotent';
|
||||
revision: 'inserted' | 'idempotent';
|
||||
}
|
||||
|
||||
/** Publishes the dependency first so a revision never points at a missing recipe. */
|
||||
export class LocalTaskExecutionRevisionPublisher {
|
||||
constructor(
|
||||
private readonly recipes: LocalExecutionContextRecipeRepository,
|
||||
private readonly revisions: TaskExecutionRevisionRepository,
|
||||
) {}
|
||||
|
||||
async publish(
|
||||
command: PublishLocalTaskExecutionRevisionCommand,
|
||||
): Promise<PublishLocalTaskExecutionRevisionResult> {
|
||||
if (command.revision.contextRef !== command.contextRecipe.contextRef) {
|
||||
throw new TypeError('Task revision contextRef does not match its recipe');
|
||||
}
|
||||
const contextRecipe = await this.recipes.insert(
|
||||
command.contextRecipe,
|
||||
command.createdAtMs,
|
||||
);
|
||||
const revision = await this.revisions.insert(
|
||||
command.revision,
|
||||
command.createdAtMs,
|
||||
);
|
||||
return { contextRecipe, revision };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import type {
|
||||
ExecutionOutcome,
|
||||
ExecutionOutputSink,
|
||||
} from '../domain/execution';
|
||||
import type { Executor } from '../ports/executor';
|
||||
import type { RunRepository } from '../ports/runRepository';
|
||||
import { buildLegacyCronExecutionSpec } from '../adapters/legacy/legacyCronExecutionSpec';
|
||||
import { createLegacyTaskRevision } from '../compatibility/legacyTaskRevision';
|
||||
import { createLegacyLogOutputRef } from '../compatibility/legacyLogOutputRef';
|
||||
import type {
|
||||
ManualPrimaryCompletion,
|
||||
ManualPrimaryExecutionRouter,
|
||||
ManualPrimaryStartInput,
|
||||
ManualPrimaryStartedExecution,
|
||||
ManualPrimaryStopResult,
|
||||
} from '../compatibility/manualPrimaryExecutionBridge';
|
||||
import type { RuntimeRolloutPolicy } from '../domain/runtimeRollout';
|
||||
import {
|
||||
PrimaryRunOrchestrator,
|
||||
type ActivePrimaryRun,
|
||||
type PrimaryRunClock,
|
||||
type PrimaryRunOrchestratorOptions,
|
||||
} from './primaryRunOrchestrator';
|
||||
|
||||
export interface PreparedManualPrimaryLog {
|
||||
logPath: string;
|
||||
output: ExecutionOutputSink;
|
||||
completionCommitted?(attemptId: string): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface ManualPrimaryLogFiles {
|
||||
prepare(input: ManualPrimaryStartInput): Promise<PreparedManualPrimaryLog>;
|
||||
}
|
||||
|
||||
export interface ManualPrimaryRuntimeOptions {
|
||||
clock?: PrimaryRunClock;
|
||||
orchestrator?: PrimaryRunOrchestratorOptions;
|
||||
}
|
||||
|
||||
interface ActiveManualExecution {
|
||||
cronId: number;
|
||||
attemptId: string;
|
||||
execution: ActivePrimaryRun;
|
||||
}
|
||||
|
||||
interface PendingManualExecution {
|
||||
cronId: number;
|
||||
controller: AbortController;
|
||||
}
|
||||
|
||||
export class ManualPrimaryOwnershipError extends Error {
|
||||
constructor() {
|
||||
super('Manual execution is not Runtime-owned');
|
||||
this.name = 'ManualPrimaryOwnershipError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process-local manual Primary owner. The active maps are bounded by actual
|
||||
* concurrent executions; durable restart/cross-worker ownership remains the
|
||||
* startup Reconciler and future supervisor's responsibility.
|
||||
*/
|
||||
export class ManualPrimaryRuntime implements ManualPrimaryExecutionRouter {
|
||||
private readonly orchestrator: PrimaryRunOrchestrator;
|
||||
private readonly clock: PrimaryRunClock;
|
||||
private readonly pending = new Map<symbol, PendingManualExecution>();
|
||||
private readonly active = new Map<string, ActiveManualExecution>();
|
||||
|
||||
constructor(
|
||||
repository: RunRepository,
|
||||
executor: Executor,
|
||||
private readonly rollout: RuntimeRolloutPolicy,
|
||||
private readonly logs: ManualPrimaryLogFiles,
|
||||
options: ManualPrimaryRuntimeOptions = {},
|
||||
) {
|
||||
this.clock = options.clock ?? { now: Date.now };
|
||||
this.orchestrator = new PrimaryRunOrchestrator(repository, executor, {
|
||||
...options.orchestrator,
|
||||
clock: this.clock,
|
||||
});
|
||||
}
|
||||
|
||||
ownsNewRuns(): boolean {
|
||||
return this.rollout.decide('manual').owner === 'runtime';
|
||||
}
|
||||
|
||||
async start(
|
||||
input: ManualPrimaryStartInput,
|
||||
): Promise<ManualPrimaryStartedExecution> {
|
||||
if (!this.ownsNewRuns()) throw new ManualPrimaryOwnershipError();
|
||||
const pendingId = Symbol('manual-primary-pending');
|
||||
const controller = new AbortController();
|
||||
this.pending.set(pendingId, { cronId: input.cron.id, controller });
|
||||
|
||||
let prepared: PreparedManualPrimaryLog | undefined;
|
||||
try {
|
||||
prepared = await this.logs.prepare(input);
|
||||
const taskRevision = createLegacyTaskRevision({
|
||||
command: input.cron.command,
|
||||
...(input.cron.schedule === undefined
|
||||
? {}
|
||||
: { schedule: input.cron.schedule }),
|
||||
extraSchedules: input.cron.extraSchedules,
|
||||
...(input.cron.taskBefore === undefined
|
||||
? {}
|
||||
: { taskBefore: input.cron.taskBefore }),
|
||||
...(input.cron.taskAfter === undefined
|
||||
? {}
|
||||
: { taskAfter: input.cron.taskAfter }),
|
||||
...(input.cron.workDirectory === undefined
|
||||
? {}
|
||||
: { workDirectory: input.cron.workDirectory }),
|
||||
...(input.cron.logName === undefined
|
||||
? {}
|
||||
: { logName: input.cron.logName }),
|
||||
});
|
||||
const outputRef = createLegacyLogOutputRef(prepared.logPath);
|
||||
const active = await this.orchestrator.start({
|
||||
definition: {
|
||||
projectId: 'default',
|
||||
taskId: 'legacy-cron:' + input.cron.id,
|
||||
taskRevision,
|
||||
...(input.cron.name === undefined
|
||||
? {}
|
||||
: { taskName: input.cron.name }),
|
||||
legacyCronId: input.cron.id,
|
||||
triggerType: 'manual',
|
||||
executionOrigin: 'manual',
|
||||
triggeredBy: 'legacy:manual-api',
|
||||
outputRef,
|
||||
acceptedAtMs: input.acceptedAtMs,
|
||||
actor: { type: 'compatibility', id: 'legacy:manual-api' },
|
||||
},
|
||||
createSpec: (reference) =>
|
||||
buildLegacyCronExecutionSpec({
|
||||
runId: reference.run.id,
|
||||
attemptId: reference.attempt.id,
|
||||
projectId: reference.run.projectId,
|
||||
taskRevision: reference.run.taskRevision,
|
||||
cron: {
|
||||
id: input.cron.id,
|
||||
command: input.cron.command,
|
||||
...(input.cron.taskBefore === undefined
|
||||
? {}
|
||||
: { taskBefore: input.cron.taskBefore }),
|
||||
...(input.cron.taskAfter === undefined
|
||||
? {}
|
||||
: { taskAfter: input.cron.taskAfter }),
|
||||
...(input.cron.workDirectory === undefined
|
||||
? {}
|
||||
: { workDirectory: input.cron.workDirectory }),
|
||||
...(input.cron.logName === undefined
|
||||
? {}
|
||||
: { logName: input.cron.logName }),
|
||||
},
|
||||
realTime: true,
|
||||
realLogPath: prepared!.logPath,
|
||||
noDelay: true,
|
||||
}),
|
||||
context: {
|
||||
environment: {},
|
||||
signal: controller.signal,
|
||||
output: prepared.output,
|
||||
},
|
||||
});
|
||||
|
||||
const entry: ActiveManualExecution = {
|
||||
cronId: input.cron.id,
|
||||
attemptId: active.attempt.id,
|
||||
execution: active,
|
||||
};
|
||||
this.active.set(active.run.id, entry);
|
||||
this.pending.delete(pendingId);
|
||||
const completion = this.completion(active, prepared).finally(() => {
|
||||
if (this.active.get(active.run.id) === entry) {
|
||||
this.active.delete(active.run.id);
|
||||
}
|
||||
});
|
||||
void completion.catch(() => undefined);
|
||||
return {
|
||||
runId: active.run.id,
|
||||
attemptId: active.attempt.id,
|
||||
...(active.handle.pid === undefined ? {} : { pid: active.handle.pid }),
|
||||
logPath: prepared.logPath,
|
||||
completion,
|
||||
};
|
||||
} catch (error) {
|
||||
if (prepared) await prepared.close().catch(() => undefined);
|
||||
throw error;
|
||||
} finally {
|
||||
this.pending.delete(pendingId);
|
||||
}
|
||||
}
|
||||
|
||||
async stopCron(
|
||||
cronId: number,
|
||||
requestedAtMs: number,
|
||||
): Promise<ManualPrimaryStopResult> {
|
||||
let matched = 0;
|
||||
for (const pending of this.pending.values()) {
|
||||
if (pending.cronId !== cronId) continue;
|
||||
matched += 1;
|
||||
pending.controller.abort();
|
||||
}
|
||||
const executions = [...this.active.values()].filter(
|
||||
(entry) => entry.cronId === cronId,
|
||||
);
|
||||
matched += executions.length;
|
||||
const failed = await this.stopActive(executions, requestedAtMs);
|
||||
return { matched, failed };
|
||||
}
|
||||
|
||||
async stopAttempt(
|
||||
attemptId: string,
|
||||
requestedAtMs: number,
|
||||
): Promise<ManualPrimaryStopResult> {
|
||||
const execution = [...this.active.values()].find(
|
||||
(entry) => entry.attemptId === attemptId,
|
||||
);
|
||||
if (!execution) return { matched: 0, failed: 0 };
|
||||
const failed = await this.stopActive([execution], requestedAtMs);
|
||||
return { matched: 1, failed };
|
||||
}
|
||||
|
||||
private async stopActive(
|
||||
executions: readonly ActiveManualExecution[],
|
||||
requestedAtMs: number,
|
||||
): Promise<number> {
|
||||
const results = await Promise.allSettled(
|
||||
executions.map((entry) =>
|
||||
entry.execution.cancel({ kind: 'user', requestedAtMs }),
|
||||
),
|
||||
);
|
||||
return results.filter((result) => result.status === 'rejected').length;
|
||||
}
|
||||
|
||||
private async completion(
|
||||
active: ActivePrimaryRun,
|
||||
prepared: PreparedManualPrimaryLog,
|
||||
): Promise<ManualPrimaryCompletion> {
|
||||
try {
|
||||
const completed = await active.completion;
|
||||
await prepared
|
||||
.completionCommitted?.(completed.attempt.id)
|
||||
.catch(() => undefined);
|
||||
const result = completed.result;
|
||||
return {
|
||||
runId: completed.run.id,
|
||||
attemptId: completed.attempt.id,
|
||||
outcome: result.outcome as ExecutionOutcome,
|
||||
...(result.exitCode === undefined ? {} : { exitCode: result.exitCode }),
|
||||
};
|
||||
} finally {
|
||||
await prepared.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import type { ManualPrimaryExecutionRouter } from '../compatibility/manualPrimaryExecutionBridge';
|
||||
import type { RuntimeRolloutPolicy } from '../domain/runtimeRollout';
|
||||
import type {
|
||||
RuntimeRolloutLoadAudit,
|
||||
RuntimeRolloutLoadResult,
|
||||
} from '../ports/runtimeRolloutLoader';
|
||||
import type { PrimaryCancellationStopResult } from './primaryCancellationLifecycle';
|
||||
import type { PrimaryCompletionReceiptStopResult } from './primaryCompletionReceiptLifecycle';
|
||||
import type { PrimaryRunStartupSummary } from './primaryRunStartupSupervisor';
|
||||
import type { PrimaryTimeoutStopResult } from './primaryTimeoutLifecycle';
|
||||
|
||||
export type ManualPrimaryActivationState =
|
||||
| 'not_activated'
|
||||
| 'selected'
|
||||
| 'reconciled'
|
||||
| 'activated'
|
||||
| 'failed'
|
||||
| 'stopped';
|
||||
|
||||
export interface ManualPrimaryActivationAudit extends RuntimeRolloutLoadAudit {
|
||||
activation: ManualPrimaryActivationState;
|
||||
recovery?: {
|
||||
scanned: number;
|
||||
verifiedRunning: number;
|
||||
recoveredRunning: number;
|
||||
completedFromReceipt: number;
|
||||
quarantinedReceipts: number;
|
||||
publishGraceWaits: number;
|
||||
markedLost: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ManualPrimaryActivationStack {
|
||||
router: ManualPrimaryExecutionRouter;
|
||||
reconcile(): Promise<PrimaryRunStartupSummary>;
|
||||
startCompletion(): boolean;
|
||||
stopCompletion(): Promise<PrimaryCompletionReceiptStopResult>;
|
||||
startTimeout(): boolean;
|
||||
stopTimeout(): Promise<PrimaryTimeoutStopResult>;
|
||||
startCancellation(): boolean;
|
||||
stopCancellation(): Promise<PrimaryCancellationStopResult>;
|
||||
}
|
||||
|
||||
export interface ManualPrimaryRuntimeActivationOptions {
|
||||
load(): Promise<RuntimeRolloutLoadResult>;
|
||||
create(policy: RuntimeRolloutPolicy): ManualPrimaryActivationStack;
|
||||
install(router: ManualPrimaryExecutionRouter): () => void;
|
||||
audit(record: ManualPrimaryActivationAudit): void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface ManualPrimaryRuntimeActivationResult {
|
||||
load: RuntimeRolloutLoadResult;
|
||||
active: boolean;
|
||||
recovery?: PrimaryRunStartupSummary;
|
||||
stop(): Promise<PrimaryCancellationStopResult>;
|
||||
}
|
||||
|
||||
const NOOP_STOP = async (): Promise<PrimaryCancellationStopResult> => 'drained';
|
||||
|
||||
async function stopLifecycles(
|
||||
stack: ManualPrimaryActivationStack,
|
||||
started: { completion: boolean; timeout: boolean; cancellation: boolean },
|
||||
): Promise<PrimaryCancellationStopResult> {
|
||||
let result: PrimaryCancellationStopResult = 'drained';
|
||||
let firstError: unknown;
|
||||
if (started.timeout) {
|
||||
try {
|
||||
if ((await stack.stopTimeout()) === 'timed_out') result = 'timed_out';
|
||||
} catch (error) {
|
||||
firstError = error;
|
||||
}
|
||||
}
|
||||
if (started.cancellation) {
|
||||
try {
|
||||
if ((await stack.stopCancellation()) === 'timed_out') {
|
||||
result = 'timed_out';
|
||||
}
|
||||
} catch (error) {
|
||||
firstError ??= error;
|
||||
}
|
||||
}
|
||||
if (started.completion) {
|
||||
try {
|
||||
if ((await stack.stopCompletion()) === 'timed_out') {
|
||||
result = 'timed_out';
|
||||
}
|
||||
} catch (error) {
|
||||
firstError ??= error;
|
||||
}
|
||||
}
|
||||
if (firstError !== undefined) throw firstError;
|
||||
return result;
|
||||
}
|
||||
|
||||
function assertSafeRecovery(summary: PrimaryRunStartupSummary): void {
|
||||
if (
|
||||
summary.remaining ||
|
||||
summary.stopReason !== 'complete' ||
|
||||
summary.skipped > 0 ||
|
||||
summary.ambiguous > 0 ||
|
||||
summary.failed > 0
|
||||
) {
|
||||
throw new Error('Primary startup reconciliation did not converge safely');
|
||||
}
|
||||
}
|
||||
|
||||
function recoveryAudit(summary: PrimaryRunStartupSummary) {
|
||||
return {
|
||||
scanned: summary.scanned,
|
||||
verifiedRunning: summary.verifiedRunning,
|
||||
recoveredRunning: summary.recoveredRunning,
|
||||
completedFromReceipt: summary.completedFromReceipt,
|
||||
quarantinedReceipts: summary.quarantinedReceipts,
|
||||
publishGraceWaits: summary.publishGraceWaits,
|
||||
markedLost: summary.markedLost,
|
||||
};
|
||||
}
|
||||
|
||||
export async function activateManualPrimaryRuntime(
|
||||
options: ManualPrimaryRuntimeActivationOptions,
|
||||
): Promise<ManualPrimaryRuntimeActivationResult> {
|
||||
const load = await options.load();
|
||||
const shouldActivate =
|
||||
load.status === 'accepted' && load.policy.modeFor('manual') === 'primary';
|
||||
if (!shouldActivate) {
|
||||
await options.audit({ ...load.audit, activation: 'not_activated' });
|
||||
return { load, active: false, stop: NOOP_STOP };
|
||||
}
|
||||
|
||||
let stack: ManualPrimaryActivationStack | undefined;
|
||||
let dispose: (() => void) | undefined;
|
||||
let completionStarted = false;
|
||||
let timeoutStarted = false;
|
||||
let cancellationStarted = false;
|
||||
try {
|
||||
await options.audit({ ...load.audit, activation: 'selected' });
|
||||
stack = options.create(load.policy);
|
||||
const recovery = await stack.reconcile();
|
||||
assertSafeRecovery(recovery);
|
||||
await options.audit({
|
||||
...load.audit,
|
||||
activation: 'reconciled',
|
||||
recovery: recoveryAudit(recovery),
|
||||
});
|
||||
completionStarted = stack.startCompletion();
|
||||
if (!completionStarted) {
|
||||
throw new Error('Primary completion lifecycle did not start');
|
||||
}
|
||||
timeoutStarted = stack.startTimeout();
|
||||
if (!timeoutStarted) {
|
||||
throw new Error('Primary timeout lifecycle did not start');
|
||||
}
|
||||
cancellationStarted = stack.startCancellation();
|
||||
if (!cancellationStarted) {
|
||||
throw new Error('Primary cancellation lifecycle did not start');
|
||||
}
|
||||
dispose = options.install(stack.router);
|
||||
await options.audit({
|
||||
...load.audit,
|
||||
activation: 'activated',
|
||||
recovery: recoveryAudit(recovery),
|
||||
});
|
||||
|
||||
let stopped = false;
|
||||
return {
|
||||
load,
|
||||
active: true,
|
||||
recovery,
|
||||
async stop() {
|
||||
if (stopped) return 'drained';
|
||||
stopped = true;
|
||||
dispose?.();
|
||||
const result = await stopLifecycles(stack!, {
|
||||
completion: completionStarted,
|
||||
timeout: timeoutStarted,
|
||||
cancellation: cancellationStarted,
|
||||
});
|
||||
try {
|
||||
await options.audit({
|
||||
...load.audit,
|
||||
activation: 'stopped',
|
||||
recovery: recoveryAudit(recovery),
|
||||
});
|
||||
} catch {
|
||||
// Cleanup must not be reversed by a diagnostic failure.
|
||||
}
|
||||
return result;
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
dispose?.();
|
||||
if (stack && (completionStarted || timeoutStarted || cancellationStarted)) {
|
||||
try {
|
||||
await stopLifecycles(stack, {
|
||||
completion: completionStarted,
|
||||
timeout: timeoutStarted,
|
||||
cancellation: cancellationStarted,
|
||||
});
|
||||
} catch {
|
||||
// Preserve the activation error after best-effort cleanup.
|
||||
}
|
||||
}
|
||||
try {
|
||||
await options.audit({ ...load.audit, activation: 'failed' });
|
||||
} catch {
|
||||
// Preserve the activation error while ownership remains uninstalled.
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { normalizeExecutionContext } from '../domain/executionContext';
|
||||
import type { RunDispatchCandidate } from '../domain/runDispatchCandidate';
|
||||
import {
|
||||
executionSpecFromPinnedTaskRevision,
|
||||
type PinnedTaskExecutionRevision,
|
||||
} from '../domain/taskExecutionRevision';
|
||||
import type { LocalExecutionContextMaterializer } from '../ports/localExecutionContextMaterializer';
|
||||
import type {
|
||||
LocalRunDispatchPlan,
|
||||
LocalRunDispatchPlanSource,
|
||||
} from '../ports/localRunDispatchPlanSource';
|
||||
import type { TaskExecutionRevisionSource } from '../ports/taskExecutionRevisionSource';
|
||||
|
||||
/** Composes immutable Task facts with fresh Attempt-scoped local capabilities. */
|
||||
export class PinnedTaskLocalRunDispatchPlanSource
|
||||
implements LocalRunDispatchPlanSource
|
||||
{
|
||||
constructor(
|
||||
private readonly revisions: TaskExecutionRevisionSource,
|
||||
private readonly contexts: LocalExecutionContextMaterializer,
|
||||
) {}
|
||||
|
||||
async prepare(
|
||||
candidate: Readonly<RunDispatchCandidate>,
|
||||
): Promise<LocalRunDispatchPlan | null> {
|
||||
const revision = await this.revisions.resolve(
|
||||
Object.freeze({
|
||||
projectId: candidate.projectId,
|
||||
taskId: candidate.taskId,
|
||||
taskRevision: candidate.taskRevision,
|
||||
}),
|
||||
);
|
||||
if (!revision) return null;
|
||||
const executionSpec = executionSpecFromPinnedTaskRevision(
|
||||
candidate,
|
||||
this.knownRevision(revision),
|
||||
);
|
||||
const context = await this.contexts.prepare(
|
||||
Object.freeze({
|
||||
candidate: Object.freeze({ ...candidate }),
|
||||
contextRef: revision.contextRef,
|
||||
}),
|
||||
);
|
||||
if (!context) return null;
|
||||
let normalizedContext;
|
||||
try {
|
||||
normalizedContext = normalizeExecutionContext(context.context);
|
||||
} catch (error) {
|
||||
try {
|
||||
await context.dispose?.();
|
||||
} catch {
|
||||
// Cleanup failure must not replace the validation failure.
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
executionSpec,
|
||||
context: normalizedContext,
|
||||
...(context.logArtifactId === undefined
|
||||
? {}
|
||||
: { logArtifactId: context.logArtifactId }),
|
||||
...(context.dispose === undefined ? {} : { dispose: context.dispose }),
|
||||
};
|
||||
}
|
||||
|
||||
private knownRevision(
|
||||
revision: PinnedTaskExecutionRevision,
|
||||
): PinnedTaskExecutionRevision {
|
||||
return {
|
||||
projectId: revision.projectId,
|
||||
taskId: revision.taskId,
|
||||
taskRevision: revision.taskRevision,
|
||||
executorType: revision.executorType,
|
||||
execution: revision.execution,
|
||||
contextRef: revision.contextRef,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import { v7 as uuidV7 } from 'uuid';
|
||||
import type { CancellationDispatchResult } from '../domain/cancellationDispatch';
|
||||
import { RUN_CANCELLATION_REASONS } from '../domain/run';
|
||||
import type { CancellationDispatchRepository } from '../ports/cancellationDispatchRepository';
|
||||
import type { PersistedExecutionController } from '../ports/persistedExecutionController';
|
||||
import type {
|
||||
PrimaryCancellationAttemptReference,
|
||||
PrimaryCancellationCursor,
|
||||
PrimaryCancellationSource,
|
||||
} from '../ports/primaryCancellationSource';
|
||||
|
||||
const DEFAULT_LEASE_DURATION_MS = 30_000;
|
||||
const DEFAULT_RETRY_BASE_MS = 1_000;
|
||||
const DEFAULT_RETRY_MAX_MS = 60_000;
|
||||
|
||||
export interface PrimaryCancellationDispatchSummary {
|
||||
scanned: number;
|
||||
claimed: number;
|
||||
terminationRequested: number;
|
||||
alreadyExited: number;
|
||||
pending: number;
|
||||
ambiguous: number;
|
||||
blocked: number;
|
||||
deferred: number;
|
||||
alreadyResolved: number;
|
||||
notEligible: number;
|
||||
failed: number;
|
||||
truncated: boolean;
|
||||
unsafeAttemptOverflow: boolean;
|
||||
nextCursor?: PrimaryCancellationCursor;
|
||||
}
|
||||
|
||||
export interface PrimaryCancellationDispatcherOptions {
|
||||
owner: string;
|
||||
leaseDurationMs?: number;
|
||||
retryBaseMs?: number;
|
||||
retryMaxMs?: number;
|
||||
clock?: () => number;
|
||||
createId?: () => string;
|
||||
}
|
||||
|
||||
function assertPositiveInteger(name: string, value: number): void {
|
||||
if (!Number.isSafeInteger(value) || value < 1) {
|
||||
throw new RangeError(`${name} must be a positive safe integer`);
|
||||
}
|
||||
}
|
||||
|
||||
/** One bounded pass. The caller owns scheduling and pagination. */
|
||||
export class PrimaryCancellationDispatcher {
|
||||
private readonly controllers = new Map<
|
||||
PersistedExecutionController['executorType'],
|
||||
PersistedExecutionController
|
||||
>();
|
||||
private readonly owner: string;
|
||||
private readonly leaseDurationMs: number;
|
||||
private readonly retryBaseMs: number;
|
||||
private readonly retryMaxMs: number;
|
||||
private readonly clock: () => number;
|
||||
private readonly createId: () => string;
|
||||
|
||||
constructor(
|
||||
private readonly source: PrimaryCancellationSource,
|
||||
private readonly dispatches: CancellationDispatchRepository,
|
||||
controllers: readonly PersistedExecutionController[],
|
||||
options: PrimaryCancellationDispatcherOptions,
|
||||
) {
|
||||
if (!options.owner || options.owner.length > 128) {
|
||||
throw new RangeError('owner must be between 1 and 128 characters');
|
||||
}
|
||||
this.owner = options.owner;
|
||||
this.leaseDurationMs = options.leaseDurationMs ?? DEFAULT_LEASE_DURATION_MS;
|
||||
this.retryBaseMs = options.retryBaseMs ?? DEFAULT_RETRY_BASE_MS;
|
||||
this.retryMaxMs = options.retryMaxMs ?? DEFAULT_RETRY_MAX_MS;
|
||||
this.clock = options.clock ?? Date.now;
|
||||
this.createId = options.createId ?? uuidV7;
|
||||
assertPositiveInteger('leaseDurationMs', this.leaseDurationMs);
|
||||
assertPositiveInteger('retryBaseMs', this.retryBaseMs);
|
||||
assertPositiveInteger('retryMaxMs', this.retryMaxMs);
|
||||
if (this.retryMaxMs < this.retryBaseMs) {
|
||||
throw new RangeError(
|
||||
'retryMaxMs must be greater than or equal to retryBaseMs',
|
||||
);
|
||||
}
|
||||
|
||||
for (const controller of controllers) {
|
||||
if (this.controllers.has(controller.executorType)) {
|
||||
throw new Error(
|
||||
`Duplicate persisted Executor controller: ${controller.executorType}`,
|
||||
);
|
||||
}
|
||||
this.controllers.set(controller.executorType, controller);
|
||||
}
|
||||
}
|
||||
|
||||
async dispatchBatch(
|
||||
options: { cursor?: PrimaryCancellationCursor; limit?: number } = {},
|
||||
): Promise<PrimaryCancellationDispatchSummary> {
|
||||
const page = await this.source.listCandidates(options);
|
||||
const summary: PrimaryCancellationDispatchSummary = {
|
||||
scanned: page.candidates.length,
|
||||
claimed: 0,
|
||||
terminationRequested: 0,
|
||||
alreadyExited: 0,
|
||||
pending: 0,
|
||||
ambiguous: 0,
|
||||
blocked: 0,
|
||||
deferred: 0,
|
||||
alreadyResolved: 0,
|
||||
notEligible: 0,
|
||||
failed: 0,
|
||||
truncated: page.truncated,
|
||||
unsafeAttemptOverflow: page.unsafeAttemptOverflow,
|
||||
...(page.nextCursor === undefined ? {} : { nextCursor: page.nextCursor }),
|
||||
};
|
||||
if (page.unsafeAttemptOverflow) return summary;
|
||||
|
||||
for (const candidate of page.candidates) {
|
||||
if (!RUN_CANCELLATION_REASONS.includes(candidate.reason)) {
|
||||
summary.pending += 1;
|
||||
continue;
|
||||
}
|
||||
if (candidate.attempts.length === 0) {
|
||||
summary.pending += 1;
|
||||
continue;
|
||||
}
|
||||
if (candidate.attempts.length > 1) {
|
||||
summary.ambiguous += 1;
|
||||
summary.pending += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const attempt = candidate.attempts[0];
|
||||
const claimedAtMs = this.now();
|
||||
let claim;
|
||||
try {
|
||||
claim = await this.dispatches.claim({
|
||||
runId: candidate.runId,
|
||||
attemptId: attempt.attemptId,
|
||||
requestedAtMs: candidate.requestedAtMs,
|
||||
owner: this.owner,
|
||||
leaseToken: this.createId(),
|
||||
nowMs: claimedAtMs,
|
||||
leaseDurationMs: this.leaseDurationMs,
|
||||
});
|
||||
} catch {
|
||||
summary.failed += 1;
|
||||
summary.pending += 1;
|
||||
continue;
|
||||
}
|
||||
if (claim.status === 'not_eligible') {
|
||||
summary.notEligible += 1;
|
||||
continue;
|
||||
}
|
||||
if (claim.status === 'leased' || claim.status === 'not_due') {
|
||||
summary.deferred += 1;
|
||||
summary.pending += 1;
|
||||
continue;
|
||||
}
|
||||
if (claim.status === 'dispatched') {
|
||||
summary.alreadyResolved += 1;
|
||||
continue;
|
||||
}
|
||||
if (claim.status === 'blocked') {
|
||||
summary.alreadyResolved += 1;
|
||||
summary.blocked += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
summary.claimed += 1;
|
||||
await this.dispatchClaimed(
|
||||
candidate.reason,
|
||||
candidate.requestedAtMs,
|
||||
attempt,
|
||||
claim.dispatch,
|
||||
summary,
|
||||
);
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
private async dispatchClaimed(
|
||||
reason: (typeof RUN_CANCELLATION_REASONS)[number],
|
||||
requestedAtMs: number,
|
||||
attempt: PrimaryCancellationAttemptReference,
|
||||
dispatch: Extract<
|
||||
Awaited<ReturnType<CancellationDispatchRepository['claim']>>,
|
||||
{ status: 'claimed' }
|
||||
>['dispatch'],
|
||||
summary: PrimaryCancellationDispatchSummary,
|
||||
): Promise<void> {
|
||||
const controller = this.controllers.get(attempt.executorType);
|
||||
if (!controller) {
|
||||
await this.record(attempt, dispatch, 'controller_missing', summary);
|
||||
return;
|
||||
}
|
||||
if (!attempt.executorHandle) {
|
||||
await this.record(attempt, dispatch, 'handle_missing', summary);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await controller.stop({
|
||||
durableHandle: attempt.executorHandle,
|
||||
...(attempt.pid === undefined ? {} : { expectedPid: attempt.pid }),
|
||||
reason: {
|
||||
kind: reason,
|
||||
requestedAtMs,
|
||||
},
|
||||
});
|
||||
await this.record(attempt, dispatch, result.status, summary);
|
||||
if (result.status === 'termination_requested') {
|
||||
summary.terminationRequested += 1;
|
||||
} else if (result.status === 'already_exited') {
|
||||
summary.alreadyExited += 1;
|
||||
} else {
|
||||
summary.blocked += 1;
|
||||
}
|
||||
} catch {
|
||||
summary.failed += 1;
|
||||
await this.record(attempt, dispatch, 'dispatch_error', summary);
|
||||
}
|
||||
}
|
||||
|
||||
private async record(
|
||||
attempt: PrimaryCancellationAttemptReference,
|
||||
dispatch: Extract<
|
||||
Awaited<ReturnType<CancellationDispatchRepository['claim']>>,
|
||||
{ status: 'claimed' }
|
||||
>['dispatch'],
|
||||
result: CancellationDispatchResult,
|
||||
summary: PrimaryCancellationDispatchSummary,
|
||||
): Promise<void> {
|
||||
const atMs = this.now();
|
||||
const retryable = [
|
||||
'controller_missing',
|
||||
'handle_missing',
|
||||
'dispatch_error',
|
||||
].includes(result);
|
||||
try {
|
||||
await this.dispatches.recordResult({
|
||||
runId: dispatch.runId,
|
||||
attemptId: attempt.attemptId,
|
||||
owner: this.owner,
|
||||
leaseToken: dispatch.leaseToken!,
|
||||
expectedVersion: dispatch.version,
|
||||
result,
|
||||
atMs,
|
||||
...(retryable
|
||||
? { nextAttemptAtMs: this.nextRetryAt(atMs, dispatch.dispatchCount) }
|
||||
: {}),
|
||||
eventId: this.createId(),
|
||||
});
|
||||
if (retryable) summary.pending += 1;
|
||||
} catch {
|
||||
summary.failed += 1;
|
||||
summary.pending += 1;
|
||||
}
|
||||
}
|
||||
|
||||
private nextRetryAt(atMs: number, dispatchCount: number): number {
|
||||
const exponent = Math.max(0, Math.min(dispatchCount - 1, 30));
|
||||
const delay = Math.min(this.retryMaxMs, this.retryBaseMs * 2 ** exponent);
|
||||
return Math.min(Number.MAX_SAFE_INTEGER, atMs + delay);
|
||||
}
|
||||
|
||||
private now(): number {
|
||||
const nowMs = this.clock();
|
||||
if (!Number.isSafeInteger(nowMs) || nowMs < 0) {
|
||||
throw new RangeError('clock must return a non-negative safe integer');
|
||||
}
|
||||
return nowMs;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import type {
|
||||
PrimaryCancellationCycleOptions,
|
||||
PrimaryCancellationCycleSummary,
|
||||
PrimaryCancellationSupervisor,
|
||||
} from './primaryCancellationSupervisor';
|
||||
|
||||
export const MIN_CANCELLATION_CYCLE_INTERVAL_MS = 250;
|
||||
export const MAX_CANCELLATION_CYCLE_INTERVAL_MS = 24 * 60 * 60 * 1_000;
|
||||
export const MAX_CANCELLATION_INITIAL_DELAY_MS = 24 * 60 * 60 * 1_000;
|
||||
export const MAX_CANCELLATION_STOP_TIMEOUT_MS = 60_000;
|
||||
|
||||
interface ScheduledTimer {
|
||||
unref?: () => void;
|
||||
}
|
||||
|
||||
export interface CancellationLifecycleScheduler {
|
||||
setTimeout(callback: () => void, delayMs: number): ScheduledTimer;
|
||||
clearTimeout(timer: ScheduledTimer): void;
|
||||
}
|
||||
|
||||
export interface PrimaryCancellationLifecycleOptions {
|
||||
intervalMs: number;
|
||||
initialDelayMs?: number;
|
||||
stopTimeoutMs?: number;
|
||||
cycle?: PrimaryCancellationCycleOptions;
|
||||
scheduler?: CancellationLifecycleScheduler;
|
||||
onCycle?: (summary: PrimaryCancellationCycleSummary) => void;
|
||||
onError?: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export type PrimaryCancellationStopResult = 'drained' | 'timed_out';
|
||||
|
||||
const defaultScheduler: CancellationLifecycleScheduler = {
|
||||
setTimeout(callback, delayMs) {
|
||||
return setTimeout(callback, delayMs);
|
||||
},
|
||||
clearTimeout(timer) {
|
||||
clearTimeout(timer as ReturnType<typeof setTimeout>);
|
||||
},
|
||||
};
|
||||
|
||||
function assertIntegerBetween(
|
||||
name: string,
|
||||
value: number,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): void {
|
||||
if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
|
||||
throw new RangeError(`${name} must be between ${minimum} and ${maximum}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicit lifecycle wrapper for a bounded supervisor cycle. It is inert until
|
||||
* start() is called and schedules the next cycle only after the current one
|
||||
* settles, so slow edge devices cannot accumulate overlapping scans.
|
||||
*/
|
||||
export class PrimaryCancellationLifecycle {
|
||||
private readonly intervalMs: number;
|
||||
private readonly initialDelayMs: number;
|
||||
private readonly stopTimeoutMs: number;
|
||||
private readonly cycleOptions: PrimaryCancellationCycleOptions;
|
||||
private readonly scheduler: CancellationLifecycleScheduler;
|
||||
private readonly onCycle?: (summary: PrimaryCancellationCycleSummary) => void;
|
||||
private readonly onError?: (error: unknown) => void;
|
||||
private started = false;
|
||||
private timer?: ScheduledTimer;
|
||||
private inFlight?: Promise<void>;
|
||||
|
||||
constructor(
|
||||
private readonly supervisor: Pick<
|
||||
PrimaryCancellationSupervisor,
|
||||
'runCycle'
|
||||
>,
|
||||
options: PrimaryCancellationLifecycleOptions,
|
||||
) {
|
||||
this.intervalMs = options.intervalMs;
|
||||
this.initialDelayMs = options.initialDelayMs ?? 0;
|
||||
this.stopTimeoutMs = options.stopTimeoutMs ?? 5_000;
|
||||
this.cycleOptions = {
|
||||
...(options.cycle?.cursor === undefined
|
||||
? {}
|
||||
: { cursor: { ...options.cycle.cursor } }),
|
||||
...(options.cycle?.pageSize === undefined
|
||||
? {}
|
||||
: { pageSize: options.cycle.pageSize }),
|
||||
...(options.cycle?.maxPages === undefined
|
||||
? {}
|
||||
: { maxPages: options.cycle.maxPages }),
|
||||
};
|
||||
this.scheduler = options.scheduler ?? defaultScheduler;
|
||||
this.onCycle = options.onCycle;
|
||||
this.onError = options.onError;
|
||||
assertIntegerBetween(
|
||||
'intervalMs',
|
||||
this.intervalMs,
|
||||
MIN_CANCELLATION_CYCLE_INTERVAL_MS,
|
||||
MAX_CANCELLATION_CYCLE_INTERVAL_MS,
|
||||
);
|
||||
assertIntegerBetween(
|
||||
'initialDelayMs',
|
||||
this.initialDelayMs,
|
||||
0,
|
||||
MAX_CANCELLATION_INITIAL_DELAY_MS,
|
||||
);
|
||||
assertIntegerBetween(
|
||||
'stopTimeoutMs',
|
||||
this.stopTimeoutMs,
|
||||
1,
|
||||
MAX_CANCELLATION_STOP_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
|
||||
start(): boolean {
|
||||
if (this.started || this.inFlight) return false;
|
||||
this.started = true;
|
||||
this.schedule(this.initialDelayMs);
|
||||
return true;
|
||||
}
|
||||
|
||||
async stop(): Promise<PrimaryCancellationStopResult> {
|
||||
this.started = false;
|
||||
if (this.timer) {
|
||||
this.scheduler.clearTimeout(this.timer);
|
||||
this.timer = undefined;
|
||||
}
|
||||
const inFlight = this.inFlight;
|
||||
if (!inFlight) return 'drained';
|
||||
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
const result = await Promise.race<PrimaryCancellationStopResult>([
|
||||
inFlight.then(() => 'drained' as const),
|
||||
new Promise<'timed_out'>((resolve) => {
|
||||
timeout = setTimeout(() => resolve('timed_out'), this.stopTimeoutMs);
|
||||
}),
|
||||
]);
|
||||
if (timeout) clearTimeout(timeout);
|
||||
return result;
|
||||
}
|
||||
|
||||
private schedule(delayMs: number): void {
|
||||
if (!this.started || this.timer) return;
|
||||
const timer = this.scheduler.setTimeout(() => {
|
||||
if (this.timer === timer) this.timer = undefined;
|
||||
this.run();
|
||||
}, delayMs);
|
||||
this.timer = timer;
|
||||
timer.unref?.();
|
||||
}
|
||||
|
||||
private run(): void {
|
||||
if (!this.started || this.inFlight) return;
|
||||
const inFlight = this.supervisor
|
||||
.runCycle(this.cycleOptions)
|
||||
.then((summary) => this.notifyCycle(summary))
|
||||
.catch((error) => this.notifyError(error))
|
||||
.then(() => undefined)
|
||||
.finally(() => {
|
||||
if (this.inFlight === inFlight) this.inFlight = undefined;
|
||||
if (this.started) this.schedule(this.intervalMs);
|
||||
});
|
||||
this.inFlight = inFlight;
|
||||
}
|
||||
|
||||
private notifyCycle(summary: PrimaryCancellationCycleSummary): void {
|
||||
try {
|
||||
this.onCycle?.(summary);
|
||||
} catch (error) {
|
||||
this.notifyError(error);
|
||||
}
|
||||
}
|
||||
|
||||
private notifyError(error: unknown): void {
|
||||
try {
|
||||
this.onError?.(error);
|
||||
} catch {
|
||||
// Diagnostics must never create another scheduler failure loop.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { MAX_PRIMARY_CANCELLATION_BATCH_SIZE } from '../ports/primaryCancellationSource';
|
||||
import type { PrimaryCancellationCursor } from '../ports/primaryCancellationSource';
|
||||
import type {
|
||||
PrimaryCancellationDispatcher,
|
||||
PrimaryCancellationDispatchSummary,
|
||||
} from './primaryCancellationDispatcher';
|
||||
|
||||
export const MAX_PRIMARY_CANCELLATION_PAGES_PER_CYCLE = 64;
|
||||
|
||||
export type PrimaryCancellationCycleStopReason =
|
||||
| 'complete'
|
||||
| 'page_limit'
|
||||
| 'unsafe_attempt_overflow'
|
||||
| 'cursor_stalled';
|
||||
|
||||
export interface PrimaryCancellationCycleSummary
|
||||
extends Omit<
|
||||
PrimaryCancellationDispatchSummary,
|
||||
'truncated' | 'unsafeAttemptOverflow' | 'nextCursor'
|
||||
> {
|
||||
pages: number;
|
||||
stopReason: PrimaryCancellationCycleStopReason;
|
||||
remaining: boolean;
|
||||
nextCursor?: PrimaryCancellationCursor;
|
||||
}
|
||||
|
||||
export interface PrimaryCancellationCycleOptions {
|
||||
cursor?: PrimaryCancellationCursor;
|
||||
pageSize?: number;
|
||||
maxPages?: number;
|
||||
}
|
||||
|
||||
function sameCursor(
|
||||
left: PrimaryCancellationCursor | undefined,
|
||||
right: PrimaryCancellationCursor,
|
||||
): boolean {
|
||||
return (
|
||||
left !== undefined &&
|
||||
left.requestedAtMs === right.requestedAtMs &&
|
||||
left.runId === right.runId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a bounded recovery cycle. It deliberately owns no timer or process hook;
|
||||
* edge and cluster deployments choose their own cadence and lifecycle.
|
||||
*/
|
||||
export class PrimaryCancellationSupervisor {
|
||||
constructor(
|
||||
private readonly dispatcher: Pick<
|
||||
PrimaryCancellationDispatcher,
|
||||
'dispatchBatch'
|
||||
>,
|
||||
) {}
|
||||
|
||||
async runCycle(
|
||||
options: PrimaryCancellationCycleOptions = {},
|
||||
): Promise<PrimaryCancellationCycleSummary> {
|
||||
const pageSize = options.pageSize ?? 32;
|
||||
const maxPages = options.maxPages ?? 4;
|
||||
if (
|
||||
!Number.isSafeInteger(pageSize) ||
|
||||
pageSize < 1 ||
|
||||
pageSize > MAX_PRIMARY_CANCELLATION_BATCH_SIZE
|
||||
) {
|
||||
throw new RangeError(
|
||||
'pageSize must be between 1 and MAX_PRIMARY_CANCELLATION_BATCH_SIZE',
|
||||
);
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(maxPages) ||
|
||||
maxPages < 1 ||
|
||||
maxPages > MAX_PRIMARY_CANCELLATION_PAGES_PER_CYCLE
|
||||
) {
|
||||
throw new RangeError(
|
||||
'maxPages must be between 1 and MAX_PRIMARY_CANCELLATION_PAGES_PER_CYCLE',
|
||||
);
|
||||
}
|
||||
|
||||
const total: PrimaryCancellationCycleSummary = {
|
||||
pages: 0,
|
||||
scanned: 0,
|
||||
claimed: 0,
|
||||
terminationRequested: 0,
|
||||
alreadyExited: 0,
|
||||
pending: 0,
|
||||
ambiguous: 0,
|
||||
blocked: 0,
|
||||
deferred: 0,
|
||||
alreadyResolved: 0,
|
||||
notEligible: 0,
|
||||
failed: 0,
|
||||
stopReason: 'complete',
|
||||
remaining: false,
|
||||
};
|
||||
let cursor = options.cursor;
|
||||
|
||||
for (let pageNumber = 0; pageNumber < maxPages; pageNumber += 1) {
|
||||
const page = await this.dispatcher.dispatchBatch({
|
||||
...(cursor === undefined ? {} : { cursor }),
|
||||
limit: pageSize,
|
||||
});
|
||||
total.pages += 1;
|
||||
total.scanned += page.scanned;
|
||||
total.claimed += page.claimed;
|
||||
total.terminationRequested += page.terminationRequested;
|
||||
total.alreadyExited += page.alreadyExited;
|
||||
total.pending += page.pending;
|
||||
total.ambiguous += page.ambiguous;
|
||||
total.blocked += page.blocked;
|
||||
total.deferred += page.deferred;
|
||||
total.alreadyResolved += page.alreadyResolved;
|
||||
total.notEligible += page.notEligible;
|
||||
total.failed += page.failed;
|
||||
|
||||
if (page.unsafeAttemptOverflow) {
|
||||
total.stopReason = 'unsafe_attempt_overflow';
|
||||
total.remaining = true;
|
||||
return total;
|
||||
}
|
||||
if (!page.truncated) return total;
|
||||
if (!page.nextCursor || sameCursor(cursor, page.nextCursor)) {
|
||||
total.stopReason = 'cursor_stalled';
|
||||
total.remaining = true;
|
||||
return total;
|
||||
}
|
||||
cursor = page.nextCursor;
|
||||
if (pageNumber === maxPages - 1) {
|
||||
total.stopReason = 'page_limit';
|
||||
total.remaining = true;
|
||||
total.nextCursor = cursor;
|
||||
return total;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import type { CompletionReceipt } from '../domain/completionReceipt';
|
||||
import { InvalidCompletionReceiptError } from '../domain/completionReceipt';
|
||||
import type { CompletionReceiptStore } from '../ports/completionReceiptStore';
|
||||
import type { CompletionReceiptJournal } from '../ports/completionReceiptJournal';
|
||||
import type {
|
||||
PrimaryRunCompletionResult,
|
||||
PrimaryRunCompletionService,
|
||||
} from './primaryRunCompletionService';
|
||||
import {
|
||||
PrimaryCompletionNotFoundError,
|
||||
PrimaryCompletionSequenceError,
|
||||
PrimaryCompletionStateError,
|
||||
PrimaryCompletionUnauthorizedError,
|
||||
} from './primaryRunCompletionService';
|
||||
|
||||
export interface PrimaryCompletionReceiptConsumeResult {
|
||||
status: 'missing' | 'quarantined' | PrimaryRunCompletionResult['status'];
|
||||
cleaned: boolean;
|
||||
quarantineRef?: string;
|
||||
completion?: PrimaryRunCompletionResult;
|
||||
}
|
||||
|
||||
export interface PrimaryCompletionReceiptConsumerOptions {
|
||||
journal?: Pick<CompletionReceiptJournal, 'markQuarantined' | 'resolve'>;
|
||||
quarantineRetentionMs?: number;
|
||||
clock?: { now(): number };
|
||||
}
|
||||
|
||||
function mustQuarantine(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof InvalidCompletionReceiptError ||
|
||||
error instanceof PrimaryCompletionNotFoundError ||
|
||||
error instanceof PrimaryCompletionUnauthorizedError ||
|
||||
error instanceof PrimaryCompletionSequenceError ||
|
||||
error instanceof PrimaryCompletionStateError
|
||||
);
|
||||
}
|
||||
|
||||
function receiptResult(receipt: CompletionReceipt) {
|
||||
return {
|
||||
outcome:
|
||||
receipt.exitCode === 0 ? ('succeeded' as const) : ('failed' as const),
|
||||
startedAtMs: receipt.startedAtMs,
|
||||
finishedAtMs: receipt.finishedAtMs,
|
||||
exitCode: receipt.exitCode,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads only a database-discovered Attempt receipt. Cleanup happens after the
|
||||
* terminal transaction; failures leave the immutable receipt replayable.
|
||||
*/
|
||||
export class PrimaryCompletionReceiptConsumer {
|
||||
private readonly journal?: Pick<
|
||||
CompletionReceiptJournal,
|
||||
'markQuarantined' | 'resolve'
|
||||
>;
|
||||
private readonly quarantineRetentionMs: number;
|
||||
private readonly clock: { now(): number };
|
||||
|
||||
constructor(
|
||||
private readonly store: CompletionReceiptStore,
|
||||
private readonly completions: Pick<PrimaryRunCompletionService, 'complete'>,
|
||||
options: PrimaryCompletionReceiptConsumerOptions = {},
|
||||
) {
|
||||
this.journal = options.journal;
|
||||
this.quarantineRetentionMs = options.quarantineRetentionMs ?? 60 * 60_000;
|
||||
this.clock = options.clock ?? { now: Date.now };
|
||||
if (
|
||||
!Number.isSafeInteger(this.quarantineRetentionMs) ||
|
||||
this.quarantineRetentionMs < 1 ||
|
||||
this.quarantineRetentionMs > 30 * 24 * 60 * 60_000
|
||||
) {
|
||||
throw new RangeError(
|
||||
'quarantineRetentionMs must be between 1 and 30 days',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async consume(
|
||||
attemptId: string,
|
||||
): Promise<PrimaryCompletionReceiptConsumeResult> {
|
||||
let completion: PrimaryRunCompletionResult;
|
||||
try {
|
||||
const receipt = await this.store.read(attemptId);
|
||||
if (!receipt) return { status: 'missing', cleaned: false };
|
||||
completion = await this.completions.complete({
|
||||
runId: receipt.runId,
|
||||
attemptId: receipt.attemptId,
|
||||
callbackSequence: receipt.callbackSequence,
|
||||
result: receiptResult(receipt),
|
||||
source: { kind: 'receipt', token: receipt.token },
|
||||
});
|
||||
} catch (error) {
|
||||
if (!mustQuarantine(error)) throw error;
|
||||
const quarantineRef = this.store.quarantineReference(attemptId);
|
||||
if (this.journal) {
|
||||
const updatedAtMs = this.clock.now();
|
||||
const purgeAfterMs = updatedAtMs + this.quarantineRetentionMs;
|
||||
if (
|
||||
!Number.isSafeInteger(updatedAtMs) ||
|
||||
updatedAtMs < 0 ||
|
||||
!Number.isSafeInteger(purgeAfterMs)
|
||||
) {
|
||||
throw new RangeError('Completion receipt quarantine time is invalid');
|
||||
}
|
||||
await this.journal.markQuarantined({
|
||||
attemptId,
|
||||
quarantineRef,
|
||||
updatedAtMs,
|
||||
purgeAfterMs,
|
||||
});
|
||||
}
|
||||
const quarantined = await this.store.quarantine(attemptId);
|
||||
if (!quarantined) return { status: 'missing', cleaned: false };
|
||||
return {
|
||||
status: 'quarantined',
|
||||
cleaned: true,
|
||||
quarantineRef: quarantined,
|
||||
};
|
||||
}
|
||||
const cleaned = await this.store.remove(attemptId);
|
||||
if (cleaned) await this.journal?.resolve(attemptId);
|
||||
return { status: completion.status, cleaned, completion };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import type { CompletionReceiptJournal } from '../ports/completionReceiptJournal';
|
||||
import type { CompletionReceiptStore } from '../ports/completionReceiptStore';
|
||||
import type { PrimaryRunRecoveryCursor } from '../ports/primaryRunRecoverySource';
|
||||
import { isTerminalRunAttemptStatus } from '../domain/runStateMachine';
|
||||
import type { PrimaryCompletionReceiptConsumer } from './primaryCompletionReceiptConsumer';
|
||||
import type { PrimaryCompletionReceiptScanSummary } from './primaryCompletionReceiptScanner';
|
||||
|
||||
export interface PrimaryCompletionReceiptJournalScannerOptions {
|
||||
terminalMissingRetentionMs?: number;
|
||||
clock?: { now(): number };
|
||||
}
|
||||
|
||||
/**
|
||||
* Database-indexed receipt retention. The supervisor cursor is only a transport
|
||||
* shape here: createdAtMs carries journal.updatedAtMs and runId carries Attempt
|
||||
* id. No directory enumeration is performed.
|
||||
*/
|
||||
export class PrimaryCompletionReceiptJournalScanner {
|
||||
private readonly terminalMissingRetentionMs: number;
|
||||
private readonly clock: { now(): number };
|
||||
|
||||
constructor(
|
||||
private readonly journal: CompletionReceiptJournal,
|
||||
private readonly store: CompletionReceiptStore,
|
||||
private readonly consumer: Pick<
|
||||
PrimaryCompletionReceiptConsumer,
|
||||
'consume'
|
||||
>,
|
||||
options: PrimaryCompletionReceiptJournalScannerOptions = {},
|
||||
) {
|
||||
this.terminalMissingRetentionMs =
|
||||
options.terminalMissingRetentionMs ?? 60_000;
|
||||
this.clock = options.clock ?? { now: Date.now };
|
||||
if (
|
||||
!Number.isSafeInteger(this.terminalMissingRetentionMs) ||
|
||||
this.terminalMissingRetentionMs < 0 ||
|
||||
this.terminalMissingRetentionMs > 24 * 60 * 60_000
|
||||
) {
|
||||
throw new RangeError(
|
||||
'terminalMissingRetentionMs must be between 0 and 24 hours',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async scanBatch(
|
||||
options: { cursor?: PrimaryRunRecoveryCursor; limit?: number } = {},
|
||||
): Promise<PrimaryCompletionReceiptScanSummary> {
|
||||
const observedAtMs = this.clock.now();
|
||||
if (!Number.isSafeInteger(observedAtMs) || observedAtMs < 0) {
|
||||
throw new RangeError('Completion receipt observation time is invalid');
|
||||
}
|
||||
const page = await this.journal.listCandidates({
|
||||
observedAtMs,
|
||||
...(options.limit === undefined ? {} : { limit: options.limit }),
|
||||
...(options.cursor === undefined
|
||||
? {}
|
||||
: {
|
||||
cursor: {
|
||||
updatedAtMs: options.cursor.createdAtMs,
|
||||
attemptId: options.cursor.runId,
|
||||
},
|
||||
}),
|
||||
});
|
||||
const summary: PrimaryCompletionReceiptScanSummary = {
|
||||
scanned: page.candidates.length,
|
||||
applied: 0,
|
||||
alreadyTerminal: 0,
|
||||
quarantined: 0,
|
||||
purgedQuarantines: 0,
|
||||
expiredMissing: 0,
|
||||
missing: 0,
|
||||
cleanupPending: 0,
|
||||
skipped: 0,
|
||||
ambiguous: 0,
|
||||
failed: 0,
|
||||
truncated: page.truncated,
|
||||
unsafeAttemptOverflow: false,
|
||||
...(page.nextCursor === undefined
|
||||
? {}
|
||||
: {
|
||||
nextCursor: {
|
||||
createdAtMs: page.nextCursor.updatedAtMs,
|
||||
runId: page.nextCursor.attemptId,
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
for (const candidate of page.candidates) {
|
||||
if (candidate.executorType !== 'local_process') {
|
||||
summary.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
if (candidate.state === 'quarantined') {
|
||||
await this.store.quarantine(candidate.attemptId);
|
||||
await this.store.purgeQuarantine(candidate.attemptId);
|
||||
await this.journal.resolve(candidate.attemptId);
|
||||
summary.purgedQuarantines += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = await this.consumer.consume(candidate.attemptId);
|
||||
if (result.status === 'quarantined') {
|
||||
summary.quarantined += 1;
|
||||
continue;
|
||||
}
|
||||
if (result.status === 'missing') {
|
||||
if (
|
||||
isTerminalRunAttemptStatus(candidate.attemptStatus) &&
|
||||
candidate.finishedAtMs !== undefined &&
|
||||
candidate.finishedAtMs + this.terminalMissingRetentionMs <=
|
||||
observedAtMs
|
||||
) {
|
||||
await this.journal.resolve(candidate.attemptId);
|
||||
summary.expiredMissing += 1;
|
||||
} else {
|
||||
summary.missing += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (result.status === 'applied') summary.applied += 1;
|
||||
else summary.alreadyTerminal += 1;
|
||||
if (!result.cleaned) summary.cleanupPending += 1;
|
||||
} catch {
|
||||
summary.failed += 1;
|
||||
}
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import type {
|
||||
PrimaryCompletionReceiptSupervisor,
|
||||
PrimaryCompletionReceiptSupervisorOptions,
|
||||
PrimaryCompletionReceiptSupervisorSummary,
|
||||
} from './primaryCompletionReceiptSupervisor';
|
||||
import type { PrimaryRunRecoveryCursor } from '../ports/primaryRunRecoverySource';
|
||||
|
||||
export const MIN_COMPLETION_RECEIPT_INTERVAL_MS = 250;
|
||||
export const MAX_COMPLETION_RECEIPT_INTERVAL_MS = 24 * 60 * 60 * 1_000;
|
||||
export const MAX_COMPLETION_RECEIPT_INITIAL_DELAY_MS = 24 * 60 * 60 * 1_000;
|
||||
export const MAX_COMPLETION_RECEIPT_STOP_TIMEOUT_MS = 60_000;
|
||||
|
||||
interface ScheduledTimer {
|
||||
unref?: () => void;
|
||||
}
|
||||
|
||||
export interface CompletionReceiptLifecycleScheduler {
|
||||
setTimeout(callback: () => void, delayMs: number): ScheduledTimer;
|
||||
clearTimeout(timer: ScheduledTimer): void;
|
||||
}
|
||||
|
||||
export interface PrimaryCompletionReceiptLifecycleOptions {
|
||||
intervalMs: number;
|
||||
initialDelayMs?: number;
|
||||
stopTimeoutMs?: number;
|
||||
cycle?: PrimaryCompletionReceiptSupervisorOptions;
|
||||
scheduler?: CompletionReceiptLifecycleScheduler;
|
||||
onCycle?: (summary: PrimaryCompletionReceiptSupervisorSummary) => void;
|
||||
onError?: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export type PrimaryCompletionReceiptStopResult = 'drained' | 'timed_out';
|
||||
|
||||
const defaultScheduler: CompletionReceiptLifecycleScheduler = {
|
||||
setTimeout(callback, delayMs) {
|
||||
return setTimeout(callback, delayMs);
|
||||
},
|
||||
clearTimeout(timer) {
|
||||
clearTimeout(timer as ReturnType<typeof setTimeout>);
|
||||
},
|
||||
};
|
||||
|
||||
function assertIntegerBetween(
|
||||
name: string,
|
||||
value: number,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): void {
|
||||
if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
|
||||
throw new RangeError(`${name} must be between ${minimum} and ${maximum}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Explicit, non-overlapping receipt polling for small edge deployments. */
|
||||
export class PrimaryCompletionReceiptLifecycle {
|
||||
private readonly intervalMs: number;
|
||||
private readonly initialDelayMs: number;
|
||||
private readonly stopTimeoutMs: number;
|
||||
private readonly cycleOptions: Omit<
|
||||
PrimaryCompletionReceiptSupervisorOptions,
|
||||
'cursor'
|
||||
>;
|
||||
private readonly scheduler: CompletionReceiptLifecycleScheduler;
|
||||
private readonly onCycle?: (
|
||||
summary: PrimaryCompletionReceiptSupervisorSummary,
|
||||
) => void;
|
||||
private readonly onError?: (error: unknown) => void;
|
||||
private started = false;
|
||||
private timer?: ScheduledTimer;
|
||||
private inFlight?: Promise<void>;
|
||||
private resumeCursor?: PrimaryRunRecoveryCursor;
|
||||
|
||||
constructor(
|
||||
private readonly supervisor: Pick<
|
||||
PrimaryCompletionReceiptSupervisor,
|
||||
'run'
|
||||
>,
|
||||
options: PrimaryCompletionReceiptLifecycleOptions,
|
||||
) {
|
||||
this.intervalMs = options.intervalMs;
|
||||
this.initialDelayMs = options.initialDelayMs ?? 0;
|
||||
this.stopTimeoutMs = options.stopTimeoutMs ?? 5_000;
|
||||
this.cycleOptions = {
|
||||
...(options.cycle?.pageSize === undefined
|
||||
? {}
|
||||
: { pageSize: options.cycle.pageSize }),
|
||||
...(options.cycle?.maxPages === undefined
|
||||
? {}
|
||||
: { maxPages: options.cycle.maxPages }),
|
||||
};
|
||||
this.resumeCursor =
|
||||
options.cycle?.cursor === undefined
|
||||
? undefined
|
||||
: { ...options.cycle.cursor };
|
||||
this.scheduler = options.scheduler ?? defaultScheduler;
|
||||
this.onCycle = options.onCycle;
|
||||
this.onError = options.onError;
|
||||
assertIntegerBetween(
|
||||
'intervalMs',
|
||||
this.intervalMs,
|
||||
MIN_COMPLETION_RECEIPT_INTERVAL_MS,
|
||||
MAX_COMPLETION_RECEIPT_INTERVAL_MS,
|
||||
);
|
||||
assertIntegerBetween(
|
||||
'initialDelayMs',
|
||||
this.initialDelayMs,
|
||||
0,
|
||||
MAX_COMPLETION_RECEIPT_INITIAL_DELAY_MS,
|
||||
);
|
||||
assertIntegerBetween(
|
||||
'stopTimeoutMs',
|
||||
this.stopTimeoutMs,
|
||||
1,
|
||||
MAX_COMPLETION_RECEIPT_STOP_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
|
||||
start(): boolean {
|
||||
if (this.started || this.inFlight) return false;
|
||||
this.started = true;
|
||||
this.schedule(this.initialDelayMs);
|
||||
return true;
|
||||
}
|
||||
|
||||
async stop(): Promise<PrimaryCompletionReceiptStopResult> {
|
||||
this.started = false;
|
||||
if (this.timer) {
|
||||
this.scheduler.clearTimeout(this.timer);
|
||||
this.timer = undefined;
|
||||
}
|
||||
const inFlight = this.inFlight;
|
||||
if (!inFlight) return 'drained';
|
||||
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
const result = await Promise.race<PrimaryCompletionReceiptStopResult>([
|
||||
inFlight.then(() => 'drained' as const),
|
||||
new Promise<'timed_out'>((resolve) => {
|
||||
timeout = setTimeout(() => resolve('timed_out'), this.stopTimeoutMs);
|
||||
}),
|
||||
]);
|
||||
if (timeout) clearTimeout(timeout);
|
||||
return result;
|
||||
}
|
||||
|
||||
private schedule(delayMs: number): void {
|
||||
if (!this.started || this.timer) return;
|
||||
const timer = this.scheduler.setTimeout(() => {
|
||||
if (this.timer === timer) this.timer = undefined;
|
||||
this.run();
|
||||
}, delayMs);
|
||||
this.timer = timer;
|
||||
timer.unref?.();
|
||||
}
|
||||
|
||||
private run(): void {
|
||||
if (!this.started || this.inFlight) return;
|
||||
const inFlight = this.supervisor
|
||||
.run({
|
||||
...this.cycleOptions,
|
||||
...(this.resumeCursor === undefined
|
||||
? {}
|
||||
: { cursor: { ...this.resumeCursor } }),
|
||||
})
|
||||
.then((summary) => {
|
||||
this.resumeCursor =
|
||||
summary.remaining && summary.nextCursor
|
||||
? { ...summary.nextCursor }
|
||||
: undefined;
|
||||
this.notifyCycle(summary);
|
||||
})
|
||||
.catch((error) => this.notifyError(error))
|
||||
.then(() => undefined)
|
||||
.finally(() => {
|
||||
if (this.inFlight === inFlight) this.inFlight = undefined;
|
||||
if (this.started) this.schedule(this.intervalMs);
|
||||
});
|
||||
this.inFlight = inFlight;
|
||||
}
|
||||
|
||||
private notifyCycle(
|
||||
summary: PrimaryCompletionReceiptSupervisorSummary,
|
||||
): void {
|
||||
try {
|
||||
this.onCycle?.(summary);
|
||||
} catch (error) {
|
||||
this.notifyError(error);
|
||||
}
|
||||
}
|
||||
|
||||
private notifyError(error: unknown): void {
|
||||
try {
|
||||
this.onError?.(error);
|
||||
} catch {
|
||||
// Diagnostics must never create another scheduler failure loop.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import type {
|
||||
PrimaryRunRecoveryCursor,
|
||||
PrimaryRunRecoverySource,
|
||||
} from '../ports/primaryRunRecoverySource';
|
||||
import type { PrimaryCompletionReceiptConsumer } from './primaryCompletionReceiptConsumer';
|
||||
|
||||
export interface PrimaryCompletionReceiptScanSummary {
|
||||
scanned: number;
|
||||
applied: number;
|
||||
alreadyTerminal: number;
|
||||
quarantined: number;
|
||||
purgedQuarantines: number;
|
||||
expiredMissing: number;
|
||||
missing: number;
|
||||
cleanupPending: number;
|
||||
skipped: number;
|
||||
ambiguous: number;
|
||||
failed: number;
|
||||
truncated: boolean;
|
||||
unsafeAttemptOverflow: boolean;
|
||||
nextCursor?: PrimaryRunRecoveryCursor;
|
||||
}
|
||||
|
||||
/**
|
||||
* One bounded database-driven receipt pass. The database is the index: this
|
||||
* scanner never watches or enumerates the receipt directory.
|
||||
*/
|
||||
export class PrimaryCompletionReceiptScanner {
|
||||
constructor(
|
||||
private readonly source: PrimaryRunRecoverySource,
|
||||
private readonly consumer: Pick<
|
||||
PrimaryCompletionReceiptConsumer,
|
||||
'consume'
|
||||
>,
|
||||
) {}
|
||||
|
||||
async scanBatch(
|
||||
options: {
|
||||
cursor?: PrimaryRunRecoveryCursor;
|
||||
limit?: number;
|
||||
} = {},
|
||||
): Promise<PrimaryCompletionReceiptScanSummary> {
|
||||
const page = await this.source.listCandidates(options);
|
||||
const summary: PrimaryCompletionReceiptScanSummary = {
|
||||
scanned: page.candidates.length,
|
||||
applied: 0,
|
||||
alreadyTerminal: 0,
|
||||
quarantined: 0,
|
||||
purgedQuarantines: 0,
|
||||
expiredMissing: 0,
|
||||
missing: 0,
|
||||
cleanupPending: 0,
|
||||
skipped: 0,
|
||||
ambiguous: 0,
|
||||
failed: 0,
|
||||
truncated: page.truncated,
|
||||
unsafeAttemptOverflow: page.unsafeAttemptOverflow,
|
||||
...(page.nextCursor === undefined ? {} : { nextCursor: page.nextCursor }),
|
||||
};
|
||||
if (page.unsafeAttemptOverflow) return summary;
|
||||
|
||||
for (const candidate of page.candidates) {
|
||||
if (candidate.attempts.length !== 1) {
|
||||
summary.ambiguous += 1;
|
||||
continue;
|
||||
}
|
||||
const attempt = candidate.attempts[0];
|
||||
if (attempt.executorType !== 'local_process') {
|
||||
summary.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const result = await this.consumer.consume(attempt.attemptId);
|
||||
if (result.status === 'missing') {
|
||||
summary.missing += 1;
|
||||
continue;
|
||||
}
|
||||
if (result.status === 'quarantined') {
|
||||
summary.quarantined += 1;
|
||||
continue;
|
||||
}
|
||||
if (result.status === 'applied') summary.applied += 1;
|
||||
else summary.alreadyTerminal += 1;
|
||||
if (!result.cleaned) summary.cleanupPending += 1;
|
||||
} catch {
|
||||
summary.failed += 1;
|
||||
}
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import {
|
||||
MAX_PRIMARY_RECOVERY_BATCH_SIZE,
|
||||
type PrimaryRunRecoveryCursor,
|
||||
} from '../ports/primaryRunRecoverySource';
|
||||
import type {
|
||||
PrimaryCompletionReceiptScanner,
|
||||
PrimaryCompletionReceiptScanSummary,
|
||||
} from './primaryCompletionReceiptScanner';
|
||||
|
||||
export const MAX_PRIMARY_COMPLETION_RECEIPT_PAGES = 64;
|
||||
|
||||
export type PrimaryCompletionReceiptStopReason =
|
||||
| 'complete'
|
||||
| 'page_limit'
|
||||
| 'unsafe_attempt_overflow'
|
||||
| 'cursor_stalled';
|
||||
|
||||
export interface PrimaryCompletionReceiptSupervisorOptions {
|
||||
cursor?: PrimaryRunRecoveryCursor;
|
||||
pageSize?: number;
|
||||
maxPages?: number;
|
||||
}
|
||||
|
||||
export interface PrimaryCompletionReceiptSupervisorSummary
|
||||
extends Omit<
|
||||
PrimaryCompletionReceiptScanSummary,
|
||||
'truncated' | 'unsafeAttemptOverflow' | 'nextCursor'
|
||||
> {
|
||||
pages: number;
|
||||
stopReason: PrimaryCompletionReceiptStopReason;
|
||||
remaining: boolean;
|
||||
nextCursor?: PrimaryRunRecoveryCursor;
|
||||
}
|
||||
|
||||
function sameCursor(
|
||||
left: PrimaryRunRecoveryCursor | undefined,
|
||||
right: PrimaryRunRecoveryCursor,
|
||||
): boolean {
|
||||
return (
|
||||
left !== undefined &&
|
||||
left.createdAtMs === right.createdAtMs &&
|
||||
left.runId === right.runId
|
||||
);
|
||||
}
|
||||
|
||||
export class PrimaryCompletionReceiptSupervisor {
|
||||
constructor(
|
||||
private readonly scanner: Pick<
|
||||
PrimaryCompletionReceiptScanner,
|
||||
'scanBatch'
|
||||
>,
|
||||
) {}
|
||||
|
||||
async run(
|
||||
options: PrimaryCompletionReceiptSupervisorOptions = {},
|
||||
): Promise<PrimaryCompletionReceiptSupervisorSummary> {
|
||||
const pageSize = options.pageSize ?? 32;
|
||||
const maxPages = options.maxPages ?? 4;
|
||||
if (
|
||||
!Number.isSafeInteger(pageSize) ||
|
||||
pageSize < 1 ||
|
||||
pageSize > MAX_PRIMARY_RECOVERY_BATCH_SIZE
|
||||
) {
|
||||
throw new RangeError(
|
||||
'pageSize must be between 1 and MAX_PRIMARY_RECOVERY_BATCH_SIZE',
|
||||
);
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(maxPages) ||
|
||||
maxPages < 1 ||
|
||||
maxPages > MAX_PRIMARY_COMPLETION_RECEIPT_PAGES
|
||||
) {
|
||||
throw new RangeError(
|
||||
'maxPages must be between 1 and MAX_PRIMARY_COMPLETION_RECEIPT_PAGES',
|
||||
);
|
||||
}
|
||||
|
||||
const total: PrimaryCompletionReceiptSupervisorSummary = {
|
||||
pages: 0,
|
||||
scanned: 0,
|
||||
applied: 0,
|
||||
alreadyTerminal: 0,
|
||||
quarantined: 0,
|
||||
purgedQuarantines: 0,
|
||||
expiredMissing: 0,
|
||||
missing: 0,
|
||||
cleanupPending: 0,
|
||||
skipped: 0,
|
||||
ambiguous: 0,
|
||||
failed: 0,
|
||||
stopReason: 'complete',
|
||||
remaining: false,
|
||||
};
|
||||
let cursor = options.cursor;
|
||||
|
||||
for (let pageNumber = 0; pageNumber < maxPages; pageNumber += 1) {
|
||||
const page = await this.scanner.scanBatch({
|
||||
...(cursor === undefined ? {} : { cursor }),
|
||||
limit: pageSize,
|
||||
});
|
||||
total.pages += 1;
|
||||
total.scanned += page.scanned;
|
||||
total.applied += page.applied;
|
||||
total.alreadyTerminal += page.alreadyTerminal;
|
||||
total.quarantined += page.quarantined;
|
||||
total.purgedQuarantines += page.purgedQuarantines;
|
||||
total.expiredMissing += page.expiredMissing;
|
||||
total.missing += page.missing;
|
||||
total.cleanupPending += page.cleanupPending;
|
||||
total.skipped += page.skipped;
|
||||
total.ambiguous += page.ambiguous;
|
||||
total.failed += page.failed;
|
||||
|
||||
if (page.unsafeAttemptOverflow) {
|
||||
total.stopReason = 'unsafe_attempt_overflow';
|
||||
total.remaining = true;
|
||||
return total;
|
||||
}
|
||||
if (!page.truncated) return total;
|
||||
if (!page.nextCursor || sameCursor(cursor, page.nextCursor)) {
|
||||
total.stopReason = 'cursor_stalled';
|
||||
total.remaining = true;
|
||||
if (page.nextCursor) total.nextCursor = page.nextCursor;
|
||||
return total;
|
||||
}
|
||||
cursor = page.nextCursor;
|
||||
if (pageNumber === maxPages - 1) {
|
||||
total.stopReason = 'page_limit';
|
||||
total.remaining = true;
|
||||
total.nextCursor = cursor;
|
||||
return total;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
import { createHash, timingSafeEqual } from 'crypto';
|
||||
import { v7 as uuidV7 } from 'uuid';
|
||||
import type { ExecutionOutcome, ExecutionResult } from '../domain/execution';
|
||||
import type {
|
||||
RunAttemptRecord,
|
||||
RunEventRecord,
|
||||
RunRecord,
|
||||
RunStatus,
|
||||
} from '../domain/run';
|
||||
import {
|
||||
isTerminalRunAttemptStatus,
|
||||
transitionRun,
|
||||
transitionRunAttempt,
|
||||
type RunDomainEventDraft,
|
||||
} from '../domain/runStateMachine';
|
||||
import type { RunRepository } from '../ports/runRepository';
|
||||
|
||||
interface TerminalMapping {
|
||||
attemptStatus: Exclude<
|
||||
RunAttemptRecord['status'],
|
||||
'claimed' | 'starting' | 'running'
|
||||
>;
|
||||
runStatus: Exclude<
|
||||
RunStatus,
|
||||
| 'created'
|
||||
| 'queued'
|
||||
| 'dispatching'
|
||||
| 'running'
|
||||
| 'waiting_approval'
|
||||
| 'retry_wait'
|
||||
>;
|
||||
errorCode?: string;
|
||||
errorSummary?: string;
|
||||
}
|
||||
|
||||
const TERMINAL_MAPPING: Readonly<Record<ExecutionOutcome, TerminalMapping>> = {
|
||||
succeeded: {
|
||||
attemptStatus: 'succeeded',
|
||||
runStatus: 'succeeded',
|
||||
},
|
||||
failed: {
|
||||
attemptStatus: 'failed',
|
||||
runStatus: 'failed',
|
||||
errorCode: 'EXECUTION_FAILED',
|
||||
errorSummary: 'Execution completed without success',
|
||||
},
|
||||
cancelled: {
|
||||
attemptStatus: 'cancelled',
|
||||
runStatus: 'cancelled',
|
||||
errorCode: 'EXECUTION_CANCELLED',
|
||||
errorSummary: 'Execution was cancelled',
|
||||
},
|
||||
timed_out: {
|
||||
attemptStatus: 'timed_out',
|
||||
runStatus: 'timed_out',
|
||||
errorCode: 'EXECUTION_TIMED_OUT',
|
||||
errorSummary: 'Execution exceeded its configured timeout',
|
||||
},
|
||||
lost: {
|
||||
attemptStatus: 'lost',
|
||||
runStatus: 'lost',
|
||||
errorCode: 'EXECUTION_LOST',
|
||||
errorSummary: 'Execution ownership was lost',
|
||||
},
|
||||
};
|
||||
|
||||
export const MAX_PRIMARY_COMPLETION_RETRIES = 4;
|
||||
const TOKEN_PATTERN = /^[A-Za-z0-9_-]{32,128}$/;
|
||||
|
||||
export type PrimaryCompletionSource =
|
||||
| { kind: 'executor'; executorType: string }
|
||||
| { kind: 'receipt'; token: string };
|
||||
|
||||
export interface PrimaryRunCompletionCommand {
|
||||
runId: string;
|
||||
attemptId: string;
|
||||
callbackSequence: number;
|
||||
result: ExecutionResult;
|
||||
source: PrimaryCompletionSource;
|
||||
}
|
||||
|
||||
export interface PrimaryRunCompletionResult {
|
||||
status: 'applied' | 'already_terminal';
|
||||
run: RunRecord;
|
||||
attempt: RunAttemptRecord;
|
||||
result: ExecutionResult;
|
||||
}
|
||||
|
||||
export type PrimaryCompletionEventIdFactory = () => string;
|
||||
|
||||
export class PrimaryCompletionNotFoundError extends Error {
|
||||
constructor() {
|
||||
super('Primary completion target was not found');
|
||||
this.name = 'PrimaryCompletionNotFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
export class PrimaryCompletionUnauthorizedError extends Error {
|
||||
constructor() {
|
||||
super('Primary completion source is not authorized');
|
||||
this.name = 'PrimaryCompletionUnauthorizedError';
|
||||
}
|
||||
}
|
||||
|
||||
export class PrimaryCompletionSequenceError extends Error {
|
||||
constructor() {
|
||||
super('Primary completion callback sequence is invalid');
|
||||
this.name = 'PrimaryCompletionSequenceError';
|
||||
}
|
||||
}
|
||||
|
||||
export class PrimaryCompletionStateError extends Error {
|
||||
constructor() {
|
||||
super('Primary completion target state is inconsistent');
|
||||
this.name = 'PrimaryCompletionStateError';
|
||||
}
|
||||
}
|
||||
|
||||
class PrimaryCompletionConcurrentWriteError extends Error {}
|
||||
|
||||
export function hashPrimaryCompletionToken(token: string): string {
|
||||
if (!TOKEN_PATTERN.test(token)) {
|
||||
throw new TypeError('Primary completion token is invalid');
|
||||
}
|
||||
return createHash('sha256').update(token, 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
function validateResult(result: ExecutionResult): void {
|
||||
if (!Object.hasOwn(TERMINAL_MAPPING, result.outcome)) {
|
||||
throw new TypeError('Primary completion outcome is invalid');
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(result.startedAtMs) ||
|
||||
result.startedAtMs < 0 ||
|
||||
!Number.isSafeInteger(result.finishedAtMs) ||
|
||||
result.finishedAtMs < result.startedAtMs
|
||||
) {
|
||||
throw new TypeError('Primary completion timestamps are invalid');
|
||||
}
|
||||
if (
|
||||
result.exitCode !== undefined &&
|
||||
(!Number.isInteger(result.exitCode) ||
|
||||
result.exitCode < 0 ||
|
||||
result.exitCode > 255)
|
||||
) {
|
||||
throw new TypeError('Primary completion exitCode is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
function mappingFor(run: RunRecord, result: ExecutionResult): TerminalMapping {
|
||||
if (run.cancelRequestedAtMs !== undefined) {
|
||||
return run.cancelReason === 'timeout'
|
||||
? TERMINAL_MAPPING.timed_out
|
||||
: TERMINAL_MAPPING.cancelled;
|
||||
}
|
||||
return TERMINAL_MAPPING[result.outcome];
|
||||
}
|
||||
|
||||
function sameTerminalState(
|
||||
run: RunRecord,
|
||||
attempt: RunAttemptRecord,
|
||||
mapping: TerminalMapping,
|
||||
): boolean {
|
||||
return (
|
||||
attempt.status === mapping.attemptStatus && run.status === mapping.runStatus
|
||||
);
|
||||
}
|
||||
|
||||
function authorize(
|
||||
run: RunRecord,
|
||||
attempt: RunAttemptRecord,
|
||||
source: PrimaryCompletionSource,
|
||||
): void {
|
||||
if (run.executionOwner !== 'runtime') {
|
||||
throw new PrimaryCompletionUnauthorizedError();
|
||||
}
|
||||
if (source.kind === 'executor') {
|
||||
if (!source.executorType || attempt.executorType !== source.executorType) {
|
||||
throw new PrimaryCompletionUnauthorizedError();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let actualHash: string;
|
||||
try {
|
||||
actualHash = hashPrimaryCompletionToken(source.token);
|
||||
} catch {
|
||||
throw new PrimaryCompletionUnauthorizedError();
|
||||
}
|
||||
const expectedHash = attempt.callbackTokenHash;
|
||||
if (!expectedHash || !/^[a-f0-9]{64}$/.test(expectedHash)) {
|
||||
throw new PrimaryCompletionUnauthorizedError();
|
||||
}
|
||||
const expected = Buffer.from(expectedHash, 'hex');
|
||||
const actual = Buffer.from(actualHash, 'hex');
|
||||
if (!timingSafeEqual(expected, actual)) {
|
||||
throw new PrimaryCompletionUnauthorizedError();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The only terminal completion transaction for both live Executor callbacks
|
||||
* and durable receipt replay. Attempt, Run and both events commit atomically.
|
||||
*/
|
||||
export class PrimaryRunCompletionService {
|
||||
constructor(
|
||||
private readonly repository: RunRepository,
|
||||
private readonly createEventId: PrimaryCompletionEventIdFactory = uuidV7,
|
||||
) {}
|
||||
|
||||
async complete(
|
||||
command: PrimaryRunCompletionCommand,
|
||||
): Promise<PrimaryRunCompletionResult> {
|
||||
if (
|
||||
!Number.isSafeInteger(command.callbackSequence) ||
|
||||
command.callbackSequence < 1
|
||||
) {
|
||||
throw new PrimaryCompletionSequenceError();
|
||||
}
|
||||
validateResult(command.result);
|
||||
|
||||
for (let retry = 0; retry <= MAX_PRIMARY_COMPLETION_RETRIES; retry += 1) {
|
||||
try {
|
||||
return await this.repository.transaction(async (transaction) => {
|
||||
const run = await transaction.findRunById(command.runId);
|
||||
const attempt = await transaction.findAttemptById(command.attemptId);
|
||||
if (!run || !attempt || attempt.runId !== run.id) {
|
||||
throw new PrimaryCompletionNotFoundError();
|
||||
}
|
||||
authorize(run, attempt, command.source);
|
||||
const mapping = mappingFor(run, command.result);
|
||||
|
||||
if (isTerminalRunAttemptStatus(attempt.status)) {
|
||||
if (attempt.callbackSequence !== command.callbackSequence) {
|
||||
throw new PrimaryCompletionSequenceError();
|
||||
}
|
||||
if (!sameTerminalState(run, attempt, mapping)) {
|
||||
throw new PrimaryCompletionStateError();
|
||||
}
|
||||
return {
|
||||
status: 'already_terminal',
|
||||
run,
|
||||
attempt,
|
||||
result: command.result,
|
||||
};
|
||||
}
|
||||
if (command.callbackSequence !== attempt.callbackSequence + 1) {
|
||||
throw new PrimaryCompletionSequenceError();
|
||||
}
|
||||
if (
|
||||
run.status === 'succeeded' ||
|
||||
run.status === 'failed' ||
|
||||
run.status === 'cancelled' ||
|
||||
run.status === 'timed_out'
|
||||
) {
|
||||
throw new PrimaryCompletionStateError();
|
||||
}
|
||||
|
||||
const atMs = Math.max(
|
||||
run.createdAtMs,
|
||||
run.startedAtMs ?? 0,
|
||||
attempt.createdAtMs,
|
||||
attempt.startedAtMs ?? 0,
|
||||
command.result.finishedAtMs,
|
||||
);
|
||||
const attemptDecision = transitionRunAttempt(run, attempt, {
|
||||
to: mapping.attemptStatus,
|
||||
expectedRunVersion: run.version,
|
||||
atMs,
|
||||
callbackSequence: command.callbackSequence,
|
||||
...(command.result.exitCode === undefined
|
||||
? {}
|
||||
: { exitCode: command.result.exitCode }),
|
||||
...(mapping.errorCode === undefined
|
||||
? {}
|
||||
: { errorCode: mapping.errorCode }),
|
||||
...(mapping.errorSummary === undefined
|
||||
? {}
|
||||
: { errorSummary: mapping.errorSummary }),
|
||||
});
|
||||
const runDecision = transitionRun(attemptDecision.run, {
|
||||
to: mapping.runStatus,
|
||||
expectedVersion: attemptDecision.run.version,
|
||||
atMs,
|
||||
...(mapping.errorCode === undefined
|
||||
? {}
|
||||
: { errorCode: mapping.errorCode }),
|
||||
...(mapping.errorSummary === undefined
|
||||
? {}
|
||||
: { errorSummary: mapping.errorSummary }),
|
||||
});
|
||||
|
||||
if (
|
||||
!(await transaction.compareAndSetRun(
|
||||
attemptDecision.run,
|
||||
run.version,
|
||||
))
|
||||
) {
|
||||
throw new PrimaryCompletionConcurrentWriteError();
|
||||
}
|
||||
if (
|
||||
!(await transaction.compareAndSetAttempt(attemptDecision.attempt, {
|
||||
status: attempt.status,
|
||||
callbackSequence: attempt.callbackSequence,
|
||||
}))
|
||||
) {
|
||||
throw new PrimaryCompletionConcurrentWriteError();
|
||||
}
|
||||
await transaction.appendEvent(
|
||||
this.event(
|
||||
attemptDecision.run,
|
||||
attemptDecision.event,
|
||||
attempt.id,
|
||||
command.source,
|
||||
`primary-completion:${attempt.id}:${command.callbackSequence}:attempt`,
|
||||
atMs,
|
||||
),
|
||||
);
|
||||
if (
|
||||
!(await transaction.compareAndSetRun(
|
||||
runDecision.run,
|
||||
attemptDecision.run.version,
|
||||
))
|
||||
) {
|
||||
throw new PrimaryCompletionConcurrentWriteError();
|
||||
}
|
||||
await transaction.appendEvent(
|
||||
this.event(
|
||||
runDecision.run,
|
||||
runDecision.event,
|
||||
attempt.id,
|
||||
command.source,
|
||||
`primary-completion:${attempt.id}:${command.callbackSequence}:run`,
|
||||
atMs,
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
status: 'applied',
|
||||
run: runDecision.run,
|
||||
attempt: attemptDecision.attempt,
|
||||
result: command.result,
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
!(error instanceof PrimaryCompletionConcurrentWriteError) ||
|
||||
retry === MAX_PRIMARY_COMPLETION_RETRIES
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error('Primary completion retry budget was exhausted');
|
||||
}
|
||||
|
||||
private event(
|
||||
run: RunRecord,
|
||||
draft: RunDomainEventDraft,
|
||||
attemptId: string,
|
||||
source: PrimaryCompletionSource,
|
||||
dedupeKey: string,
|
||||
createdAtMs: number,
|
||||
): RunEventRecord {
|
||||
return {
|
||||
id: this.createEventId(),
|
||||
runId: run.id,
|
||||
sequence: draft.sequence,
|
||||
type: draft.type,
|
||||
dedupeKey,
|
||||
actorType: 'executor',
|
||||
actorId:
|
||||
source.kind === 'executor' ? source.executorType : 'completion-receipt',
|
||||
attemptId,
|
||||
payload: draft.payload,
|
||||
createdAtMs,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import { v7 as uuidV7 } from 'uuid';
|
||||
import type {
|
||||
ExecutionOrigin,
|
||||
RunAttemptRecord,
|
||||
RunEventRecord,
|
||||
RunRecord,
|
||||
} from '../domain/run';
|
||||
import {
|
||||
reserveRunEvent,
|
||||
transitionRun,
|
||||
type RunDomainEventDraft,
|
||||
} from '../domain/runStateMachine';
|
||||
import { RunVersionConflictError } from '../domain/stateMachineErrors';
|
||||
import type { ExecutorType } from '../domain/execution';
|
||||
import {
|
||||
assertAdmittedRunRetryPolicy,
|
||||
type RunRetryPolicyDefinition,
|
||||
type RunRetryPolicyRecord,
|
||||
} from '../domain/runRetryPolicy';
|
||||
import type { RunRepository } from '../ports/runRepository';
|
||||
import type { RunCommandActor } from './runCommandService';
|
||||
|
||||
export interface PrimaryRunDefinition {
|
||||
projectId: string;
|
||||
taskId: string;
|
||||
taskRevision: string;
|
||||
taskName?: string;
|
||||
taskSnapshotRef?: string;
|
||||
legacyCronId?: number;
|
||||
triggerId?: string;
|
||||
triggerType: string;
|
||||
executionOrigin: ExecutionOrigin;
|
||||
triggeredBy?: string;
|
||||
requestId?: string;
|
||||
scheduledForMs?: number;
|
||||
priority?: number;
|
||||
idempotencyKey?: string;
|
||||
inputRef?: string;
|
||||
outputRef?: string;
|
||||
acceptedAtMs: number;
|
||||
actor: RunCommandActor;
|
||||
retryPolicy?: RunRetryPolicyDefinition;
|
||||
}
|
||||
|
||||
export interface PrimaryRunReference {
|
||||
run: RunRecord;
|
||||
attempt: RunAttemptRecord;
|
||||
}
|
||||
|
||||
export type PrimaryRunIdFactory = () => string;
|
||||
|
||||
/**
|
||||
* Creates the durable runtime-owned aggregate before an Executor can observe it.
|
||||
*/
|
||||
export class PrimaryRunCreator {
|
||||
constructor(
|
||||
private readonly repository: RunRepository,
|
||||
private readonly createId: PrimaryRunIdFactory = uuidV7,
|
||||
) {}
|
||||
|
||||
async create(
|
||||
definition: PrimaryRunDefinition,
|
||||
executorType: ExecutorType,
|
||||
): Promise<PrimaryRunReference> {
|
||||
const initialRun: RunRecord = {
|
||||
id: this.createId(),
|
||||
projectId: definition.projectId,
|
||||
taskId: definition.taskId,
|
||||
taskRevision: definition.taskRevision,
|
||||
...(definition.taskName === undefined
|
||||
? {}
|
||||
: { taskName: definition.taskName }),
|
||||
...(definition.taskSnapshotRef === undefined
|
||||
? {}
|
||||
: { taskSnapshotRef: definition.taskSnapshotRef }),
|
||||
...(definition.legacyCronId === undefined
|
||||
? {}
|
||||
: { legacyCronId: definition.legacyCronId }),
|
||||
...(definition.triggerId === undefined
|
||||
? {}
|
||||
: { triggerId: definition.triggerId }),
|
||||
triggerType: definition.triggerType,
|
||||
executionOrigin: definition.executionOrigin,
|
||||
executionOwner: 'runtime',
|
||||
...(definition.triggeredBy === undefined
|
||||
? {}
|
||||
: { triggeredBy: definition.triggeredBy }),
|
||||
...(definition.requestId === undefined
|
||||
? {}
|
||||
: { requestId: definition.requestId }),
|
||||
...(definition.scheduledForMs === undefined
|
||||
? {}
|
||||
: { scheduledForMs: definition.scheduledForMs }),
|
||||
status: 'created',
|
||||
version: 0,
|
||||
eventSequence: 0,
|
||||
priority: definition.priority ?? 0,
|
||||
...(definition.idempotencyKey === undefined
|
||||
? {}
|
||||
: { idempotencyKey: definition.idempotencyKey }),
|
||||
...(definition.inputRef === undefined
|
||||
? {}
|
||||
: { inputRef: definition.inputRef }),
|
||||
...(definition.outputRef === undefined
|
||||
? {}
|
||||
: { outputRef: definition.outputRef }),
|
||||
createdAtMs: definition.acceptedAtMs,
|
||||
};
|
||||
const initialAttempt: RunAttemptRecord = {
|
||||
id: this.createId(),
|
||||
runId: initialRun.id,
|
||||
attempt: 1,
|
||||
status: 'claimed',
|
||||
executorType,
|
||||
callbackSequence: 0,
|
||||
createdAtMs: definition.acceptedAtMs,
|
||||
};
|
||||
let retryPolicy: RunRetryPolicyRecord | undefined;
|
||||
if (definition.retryPolicy !== undefined) {
|
||||
assertAdmittedRunRetryPolicy(definition.retryPolicy);
|
||||
retryPolicy = {
|
||||
runId: initialRun.id,
|
||||
...definition.retryPolicy,
|
||||
version: 0,
|
||||
createdAtMs: definition.acceptedAtMs,
|
||||
updatedAtMs: definition.acceptedAtMs,
|
||||
};
|
||||
}
|
||||
|
||||
const run = await this.repository.transaction(async (transaction) => {
|
||||
await transaction.insertRun(initialRun);
|
||||
await transaction.insertAttempt(initialAttempt);
|
||||
if (retryPolicy !== undefined) {
|
||||
await transaction.insertRetryPolicy(retryPolicy);
|
||||
}
|
||||
|
||||
const created = reserveRunEvent(initialRun, 0);
|
||||
const createdUpdated = await transaction.compareAndSetRun(created.run, 0);
|
||||
if (!createdUpdated) {
|
||||
throw new RunVersionConflictError(initialRun.id, 0, initialRun.version);
|
||||
}
|
||||
await transaction.appendEvent(
|
||||
this.event(
|
||||
created.run,
|
||||
{
|
||||
sequence: created.sequence,
|
||||
type: 'run.created',
|
||||
payload: {
|
||||
status: 'created',
|
||||
version: created.run.version,
|
||||
execution_owner: 'runtime',
|
||||
},
|
||||
},
|
||||
definition.actor,
|
||||
`primary-run-created:${initialRun.id}`,
|
||||
definition.acceptedAtMs,
|
||||
),
|
||||
);
|
||||
|
||||
const queued = transitionRun(created.run, {
|
||||
to: 'queued',
|
||||
expectedVersion: created.run.version,
|
||||
atMs: definition.acceptedAtMs,
|
||||
});
|
||||
const queuedUpdated = await transaction.compareAndSetRun(
|
||||
queued.run,
|
||||
created.run.version,
|
||||
);
|
||||
if (!queuedUpdated) {
|
||||
throw new RunVersionConflictError(
|
||||
initialRun.id,
|
||||
created.run.version,
|
||||
created.run.version,
|
||||
);
|
||||
}
|
||||
await transaction.appendEvent(
|
||||
this.event(
|
||||
queued.run,
|
||||
queued.event,
|
||||
definition.actor,
|
||||
`primary-run-queued:${initialRun.id}`,
|
||||
definition.acceptedAtMs,
|
||||
),
|
||||
);
|
||||
return queued.run;
|
||||
});
|
||||
|
||||
return { run, attempt: initialAttempt };
|
||||
}
|
||||
|
||||
private event(
|
||||
run: RunRecord,
|
||||
draft: RunDomainEventDraft,
|
||||
actor: RunCommandActor,
|
||||
dedupeKey: string,
|
||||
createdAtMs: number,
|
||||
): RunEventRecord {
|
||||
return {
|
||||
id: this.createId(),
|
||||
runId: run.id,
|
||||
sequence: draft.sequence,
|
||||
type: draft.type,
|
||||
dedupeKey,
|
||||
actorType: actor.type,
|
||||
...(actor.id === undefined ? {} : { actorId: actor.id }),
|
||||
payload: draft.payload,
|
||||
createdAtMs,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,706 @@
|
||||
import { randomBytes } from 'crypto';
|
||||
import type {
|
||||
ExecutionContext,
|
||||
ExecutionHandle,
|
||||
ExecutionResult,
|
||||
ExecutionSpec,
|
||||
ExecutionStopReason,
|
||||
ExecutionStopResult,
|
||||
} from '../domain/execution';
|
||||
import type { RunAttemptRecord, RunRecord } from '../domain/run';
|
||||
import {
|
||||
assertAdmittedRunRetryPolicy,
|
||||
type RunRetryPolicyDefinition,
|
||||
} from '../domain/runRetryPolicy';
|
||||
import {
|
||||
MAX_LOG_ARTIFACT_ID_LENGTH,
|
||||
isTerminalRunAttemptStatus,
|
||||
isTerminalRunStatus,
|
||||
} from '../domain/runStateMachine';
|
||||
import type { Executor } from '../ports/executor';
|
||||
import type { CompletionReceiptJournal } from '../ports/completionReceiptJournal';
|
||||
import type { PrimaryRunIdempotencyLookup } from '../ports/primaryRunIdempotencyLookup';
|
||||
import type { RunRepository } from '../ports/runRepository';
|
||||
import type { RunRetryPolicyAdmission } from '../ports/runRetryPolicyAdmission';
|
||||
import { DuplicateIdempotencyKeyError } from '../domain/repositoryErrors';
|
||||
import {
|
||||
PrimaryRunCreator,
|
||||
type PrimaryRunDefinition,
|
||||
type PrimaryRunIdFactory,
|
||||
type PrimaryRunReference,
|
||||
} from './primaryRunCreator';
|
||||
import { RunCommandService } from './runCommandService';
|
||||
import {
|
||||
hashPrimaryCompletionToken,
|
||||
PrimaryRunCompletionService,
|
||||
} from './primaryRunCompletionService';
|
||||
|
||||
export interface PrimaryRunClock {
|
||||
now(): number;
|
||||
}
|
||||
|
||||
export interface PrimaryRunOrchestratorOptions {
|
||||
clock?: PrimaryRunClock;
|
||||
createId?: PrimaryRunIdFactory;
|
||||
idempotencyLookup?: PrimaryRunIdempotencyLookup;
|
||||
createCallbackToken?: () => string;
|
||||
completionReceiptJournal?: Pick<CompletionReceiptJournal, 'register'>;
|
||||
retryPolicyAdmission?: RunRetryPolicyAdmission;
|
||||
}
|
||||
|
||||
export interface PrimaryRunStartCommand {
|
||||
definition: Omit<PrimaryRunDefinition, 'acceptedAtMs' | 'retryPolicy'> & {
|
||||
acceptedAtMs?: number;
|
||||
};
|
||||
timeoutMs?: number;
|
||||
createSpec(reference: PrimaryRunReference): ExecutionSpec;
|
||||
context: ExecutionContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trusted local-dispatch input for an aggregate that already owns a claimed
|
||||
* Attempt. Callers must materialize the spec from the persisted Task revision.
|
||||
*/
|
||||
export interface PrimaryClaimedRunStartCommand {
|
||||
runId: string;
|
||||
attemptId: string;
|
||||
timeoutMs?: number;
|
||||
createSpec(reference: PrimaryRunReference): ExecutionSpec;
|
||||
context: ExecutionContext;
|
||||
logArtifactId?: string;
|
||||
}
|
||||
|
||||
export interface PrimaryRunCompletion {
|
||||
run: RunRecord;
|
||||
attempt: RunAttemptRecord;
|
||||
result: ExecutionResult;
|
||||
}
|
||||
|
||||
export interface ActivePrimaryRun extends PrimaryRunReference {
|
||||
handle: ExecutionHandle;
|
||||
completion: Promise<PrimaryRunCompletion>;
|
||||
cancel(reason: ExecutionStopReason): Promise<ExecutionStopResult>;
|
||||
}
|
||||
|
||||
export class PrimaryRunLaunchError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly reference: PrimaryRunReference,
|
||||
options?: ErrorOptions,
|
||||
) {
|
||||
super(message, options);
|
||||
this.name = 'PrimaryRunLaunchError';
|
||||
}
|
||||
}
|
||||
|
||||
export class PrimaryRunNotActiveError extends Error {
|
||||
constructor(readonly runId: string) {
|
||||
super(`Primary Run is not active: ${runId}`);
|
||||
this.name = 'PrimaryRunNotActiveError';
|
||||
}
|
||||
}
|
||||
|
||||
export class PrimaryRunDuplicateRequestError extends Error {
|
||||
constructor(
|
||||
readonly projectId: string,
|
||||
readonly idempotencyKey: string,
|
||||
readonly existingRunId: string,
|
||||
) {
|
||||
super('A Primary Run already exists for this idempotent request');
|
||||
this.name = 'PrimaryRunDuplicateRequestError';
|
||||
}
|
||||
}
|
||||
|
||||
export class PrimaryRunIdempotencyUnavailableError extends Error {
|
||||
constructor() {
|
||||
super('Primary Run idempotency lookup is not configured');
|
||||
this.name = 'PrimaryRunIdempotencyUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
export class PrimaryRunRetryPolicyAuthorityError extends Error {
|
||||
readonly code = 'PRIMARY_RUN_RETRY_POLICY_AUTHORITY_REQUIRED';
|
||||
|
||||
constructor() {
|
||||
super('Run requests cannot self-assert automatic retry safety');
|
||||
this.name = 'PrimaryRunRetryPolicyAuthorityError';
|
||||
}
|
||||
}
|
||||
|
||||
export type PrimaryClaimedRunRejectionReason =
|
||||
| 'run_not_found'
|
||||
| 'attempt_not_found'
|
||||
| 'aggregate_mismatch'
|
||||
| 'not_latest_attempt'
|
||||
| 'not_queued'
|
||||
| 'not_claimed'
|
||||
| 'stale_execution_authority'
|
||||
| 'executor_mismatch'
|
||||
| 'cancellation_requested'
|
||||
| 'already_active';
|
||||
|
||||
export class PrimaryClaimedRunRejectedError extends Error {
|
||||
readonly code = 'PRIMARY_CLAIMED_RUN_REJECTED';
|
||||
|
||||
constructor(readonly reason: PrimaryClaimedRunRejectionReason) {
|
||||
super(`Claimed Primary Run activation was rejected: ${reason}`);
|
||||
this.name = 'PrimaryClaimedRunRejectedError';
|
||||
}
|
||||
}
|
||||
|
||||
interface ActiveExecution {
|
||||
handle: ExecutionHandle;
|
||||
completion: Promise<PrimaryRunCompletion>;
|
||||
}
|
||||
|
||||
const ALREADY_EXITED_STOP_RESULT: ExecutionStopResult = {
|
||||
status: 'already_exited',
|
||||
termSignalSent: false,
|
||||
killSignalSent: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Serializes durable Run transitions around a single Executor side effect.
|
||||
* It deliberately owns no scheduler or HTTP routing policy.
|
||||
*/
|
||||
export class PrimaryRunOrchestrator {
|
||||
private readonly clock: PrimaryRunClock;
|
||||
private readonly creator: PrimaryRunCreator;
|
||||
private readonly commands: RunCommandService;
|
||||
private readonly completions: PrimaryRunCompletionService;
|
||||
private readonly createCallbackToken: () => string;
|
||||
private readonly idempotencyLookup?: PrimaryRunIdempotencyLookup;
|
||||
private readonly completionReceiptJournal?: Pick<
|
||||
CompletionReceiptJournal,
|
||||
'register'
|
||||
>;
|
||||
private readonly retryPolicyAdmission?: RunRetryPolicyAdmission;
|
||||
private readonly active = new Map<string, ActiveExecution>();
|
||||
|
||||
constructor(
|
||||
private readonly repository: RunRepository,
|
||||
private readonly executor: Executor,
|
||||
options: PrimaryRunOrchestratorOptions = {},
|
||||
) {
|
||||
this.clock = options.clock ?? { now: Date.now };
|
||||
this.creator = new PrimaryRunCreator(repository, options.createId);
|
||||
this.commands = new RunCommandService(repository, options.createId);
|
||||
this.completions = new PrimaryRunCompletionService(
|
||||
repository,
|
||||
options.createId,
|
||||
);
|
||||
this.createCallbackToken =
|
||||
options.createCallbackToken ??
|
||||
(() => randomBytes(32).toString('base64url'));
|
||||
this.idempotencyLookup = options.idempotencyLookup;
|
||||
this.completionReceiptJournal = options.completionReceiptJournal;
|
||||
this.retryPolicyAdmission = options.retryPolicyAdmission;
|
||||
}
|
||||
|
||||
async start(command: PrimaryRunStartCommand): Promise<ActivePrimaryRun> {
|
||||
this.assertTimeout(command.timeoutMs);
|
||||
if (
|
||||
Object.prototype.hasOwnProperty.call(command.definition, 'retryPolicy')
|
||||
) {
|
||||
throw new PrimaryRunRetryPolicyAuthorityError();
|
||||
}
|
||||
const retryPolicy = await this.admitRetryPolicy(command.definition);
|
||||
const acceptedAtMs = command.definition.acceptedAtMs ?? this.clock.now();
|
||||
const reference = await this.createPrimaryRun(
|
||||
{
|
||||
...command.definition,
|
||||
acceptedAtMs,
|
||||
...(retryPolicy === undefined ? {} : { retryPolicy }),
|
||||
},
|
||||
this.executor.type,
|
||||
);
|
||||
return this.activateReference(reference, command);
|
||||
}
|
||||
|
||||
async activateClaimed(
|
||||
command: PrimaryClaimedRunStartCommand,
|
||||
): Promise<ActivePrimaryRun> {
|
||||
this.assertTimeout(command.timeoutMs);
|
||||
this.assertLogArtifactId(command.logArtifactId);
|
||||
const reference = await this.loadClaimedReference(
|
||||
command.runId,
|
||||
command.attemptId,
|
||||
);
|
||||
return this.activateReference(reference, command);
|
||||
}
|
||||
|
||||
private async activateReference(
|
||||
initialReference: PrimaryRunReference,
|
||||
command: Pick<
|
||||
PrimaryClaimedRunStartCommand,
|
||||
'timeoutMs' | 'createSpec' | 'context' | 'logArtifactId'
|
||||
>,
|
||||
): Promise<ActivePrimaryRun> {
|
||||
const callbackToken = this.createCallbackToken();
|
||||
const callbackTokenHash = hashPrimaryCompletionToken(callbackToken);
|
||||
const callbackSequence = initialReference.attempt.callbackSequence + 1;
|
||||
let reference = initialReference;
|
||||
|
||||
reference = await this.prepareForSpawn(
|
||||
reference,
|
||||
command.timeoutMs,
|
||||
callbackTokenHash,
|
||||
command.logArtifactId,
|
||||
);
|
||||
|
||||
let spec: ExecutionSpec;
|
||||
let handle: ExecutionHandle;
|
||||
try {
|
||||
await this.completionReceiptJournal?.register({
|
||||
runId: reference.run.id,
|
||||
attemptId: reference.attempt.id,
|
||||
registeredAtMs: reference.attempt.createdAtMs,
|
||||
});
|
||||
spec = command.createSpec(reference);
|
||||
this.assertSpecMatches(reference, spec, command.timeoutMs);
|
||||
handle = await this.executor.start(spec, {
|
||||
...command.context,
|
||||
completionCallback: {
|
||||
token: callbackToken,
|
||||
callbackSequence,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
reference = await this.recordStartFailure(reference);
|
||||
throw new PrimaryRunLaunchError(
|
||||
'Primary Run could not start its Executor',
|
||||
reference,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
this.assertHandleMatches(reference, handle);
|
||||
reference = await this.recordRunning(reference, handle);
|
||||
} catch (error) {
|
||||
void handle.completion.catch(() => undefined);
|
||||
await this.compensateActivationFailure(reference, handle);
|
||||
const latest = await this.loadReference(reference);
|
||||
throw new PrimaryRunLaunchError(
|
||||
'Primary Run could not persist Executor ownership',
|
||||
latest,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
|
||||
const completion = handle.completion.then(
|
||||
(result) =>
|
||||
this.completions.complete({
|
||||
runId: reference.run.id,
|
||||
attemptId: reference.attempt.id,
|
||||
callbackSequence,
|
||||
result,
|
||||
source: { kind: 'executor', executorType: this.executor.type },
|
||||
}),
|
||||
() =>
|
||||
this.completions.complete({
|
||||
runId: reference.run.id,
|
||||
attemptId: reference.attempt.id,
|
||||
callbackSequence,
|
||||
result: {
|
||||
outcome: 'lost',
|
||||
startedAtMs: handle.startedAtMs,
|
||||
finishedAtMs: this.atOrAfter(handle.startedAtMs),
|
||||
errorCode: 'EXECUTOR_COMPLETION_REJECTED',
|
||||
errorSummary: 'Executor completion channel rejected',
|
||||
},
|
||||
source: { kind: 'executor', executorType: this.executor.type },
|
||||
}),
|
||||
);
|
||||
this.active.set(reference.run.id, { handle, completion });
|
||||
void completion.then(
|
||||
() => this.deleteActive(reference.run.id, handle),
|
||||
() => this.deleteActive(reference.run.id, handle),
|
||||
);
|
||||
|
||||
return {
|
||||
...reference,
|
||||
handle,
|
||||
completion,
|
||||
cancel: (reason) => this.cancel(reference.run.id, reason),
|
||||
};
|
||||
}
|
||||
|
||||
private async loadClaimedReference(
|
||||
runId: string,
|
||||
attemptId: string,
|
||||
): Promise<PrimaryRunReference> {
|
||||
const [run, attempt, latestAttempt] = await Promise.all([
|
||||
this.repository.findRunById(runId),
|
||||
this.repository.findAttemptById(attemptId),
|
||||
this.repository.findLatestAttemptByRunId(runId),
|
||||
]);
|
||||
if (!run) throw new PrimaryClaimedRunRejectedError('run_not_found');
|
||||
if (!attempt) {
|
||||
throw new PrimaryClaimedRunRejectedError('attempt_not_found');
|
||||
}
|
||||
if (
|
||||
attempt.runId !== run.id ||
|
||||
run.executionOwner !== 'runtime' ||
|
||||
latestAttempt?.runId !== run.id
|
||||
) {
|
||||
throw new PrimaryClaimedRunRejectedError('aggregate_mismatch');
|
||||
}
|
||||
if (latestAttempt.id !== attempt.id) {
|
||||
throw new PrimaryClaimedRunRejectedError('not_latest_attempt');
|
||||
}
|
||||
if (run.status !== 'queued') {
|
||||
throw new PrimaryClaimedRunRejectedError('not_queued');
|
||||
}
|
||||
if (attempt.status !== 'claimed') {
|
||||
throw new PrimaryClaimedRunRejectedError('not_claimed');
|
||||
}
|
||||
if (
|
||||
attempt.callbackSequence !== 0 ||
|
||||
attempt.callbackTokenHash !== undefined ||
|
||||
attempt.workerId !== undefined ||
|
||||
attempt.executorHandle !== undefined ||
|
||||
attempt.pid !== undefined ||
|
||||
attempt.logArtifactId !== undefined ||
|
||||
attempt.leaseToken !== undefined ||
|
||||
attempt.leaseExpiresAtMs !== undefined ||
|
||||
attempt.startedAtMs !== undefined ||
|
||||
attempt.finishedAtMs !== undefined ||
|
||||
attempt.exitCode !== undefined ||
|
||||
attempt.errorCode !== undefined ||
|
||||
attempt.errorSummary !== undefined
|
||||
) {
|
||||
throw new PrimaryClaimedRunRejectedError('stale_execution_authority');
|
||||
}
|
||||
if (attempt.executorType !== this.executor.type) {
|
||||
throw new PrimaryClaimedRunRejectedError('executor_mismatch');
|
||||
}
|
||||
if (run.cancelRequestedAtMs !== undefined) {
|
||||
throw new PrimaryClaimedRunRejectedError('cancellation_requested');
|
||||
}
|
||||
if (this.active.has(run.id)) {
|
||||
throw new PrimaryClaimedRunRejectedError('already_active');
|
||||
}
|
||||
return { run, attempt };
|
||||
}
|
||||
|
||||
private async admitRetryPolicy(
|
||||
definition: PrimaryRunStartCommand['definition'],
|
||||
): Promise<RunRetryPolicyDefinition | undefined> {
|
||||
if (!this.retryPolicyAdmission) return undefined;
|
||||
const admitted = await this.retryPolicyAdmission.resolve(
|
||||
Object.freeze({
|
||||
projectId: definition.projectId,
|
||||
taskId: definition.taskId,
|
||||
taskRevision: definition.taskRevision,
|
||||
triggerType: definition.triggerType,
|
||||
executionOrigin: definition.executionOrigin,
|
||||
}),
|
||||
);
|
||||
if (admitted === undefined) return undefined;
|
||||
const policy: RunRetryPolicyDefinition = {
|
||||
maxAttempts: admitted.maxAttempts,
|
||||
retryOnLost: admitted.retryOnLost,
|
||||
safety: admitted.safety,
|
||||
backoffBaseMs: admitted.backoffBaseMs,
|
||||
backoffMaxMs: admitted.backoffMaxMs,
|
||||
};
|
||||
assertAdmittedRunRetryPolicy(policy);
|
||||
return policy;
|
||||
}
|
||||
|
||||
async cancel(
|
||||
runId: string,
|
||||
reason: ExecutionStopReason,
|
||||
): Promise<ExecutionStopResult> {
|
||||
const active = this.active.get(runId);
|
||||
if (!active) throw new PrimaryRunNotActiveError(runId);
|
||||
const request = await this.commands.requestCancellation({
|
||||
runId,
|
||||
attemptId: active.handle.attemptId,
|
||||
atMs: this.atOrAfter(reason.requestedAtMs, active.handle.startedAtMs),
|
||||
reason: reason.kind,
|
||||
actor: this.cancellationActor(reason),
|
||||
});
|
||||
if (request.status === 'already_terminal') {
|
||||
return ALREADY_EXITED_STOP_RESULT;
|
||||
}
|
||||
return this.executor.stop(active.handle, reason);
|
||||
}
|
||||
|
||||
isActive(runId: string): boolean {
|
||||
return this.active.has(runId);
|
||||
}
|
||||
|
||||
private async createPrimaryRun(
|
||||
definition: PrimaryRunDefinition,
|
||||
executorType: Executor['type'],
|
||||
): Promise<PrimaryRunReference> {
|
||||
const key = definition.idempotencyKey;
|
||||
if (key === undefined) {
|
||||
return this.creator.create(definition, executorType);
|
||||
}
|
||||
if (!this.idempotencyLookup) {
|
||||
throw new PrimaryRunIdempotencyUnavailableError();
|
||||
}
|
||||
|
||||
const existingRunId = await this.idempotencyLookup.findRunId(
|
||||
definition.projectId,
|
||||
key,
|
||||
);
|
||||
if (existingRunId) {
|
||||
throw new PrimaryRunDuplicateRequestError(
|
||||
definition.projectId,
|
||||
key,
|
||||
existingRunId,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.creator.create(definition, executorType);
|
||||
} catch (error) {
|
||||
if (!(error instanceof DuplicateIdempotencyKeyError)) throw error;
|
||||
const racedRunId = await this.idempotencyLookup.findRunId(
|
||||
definition.projectId,
|
||||
key,
|
||||
);
|
||||
if (!racedRunId) throw error;
|
||||
throw new PrimaryRunDuplicateRequestError(
|
||||
definition.projectId,
|
||||
key,
|
||||
racedRunId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async prepareForSpawn(
|
||||
reference: PrimaryRunReference,
|
||||
timeoutMs: number | undefined,
|
||||
callbackTokenHash: string,
|
||||
logArtifactId?: string,
|
||||
): Promise<PrimaryRunReference> {
|
||||
const startingAtMs = this.atOrAfter(
|
||||
reference.run.createdAtMs,
|
||||
reference.attempt.createdAtMs,
|
||||
);
|
||||
const deadlineAtMs =
|
||||
timeoutMs === undefined ? undefined : startingAtMs + timeoutMs;
|
||||
if (deadlineAtMs !== undefined && !Number.isSafeInteger(deadlineAtMs)) {
|
||||
throw new RangeError('Primary Run deadline exceeds the supported range');
|
||||
}
|
||||
const dispatching = await this.commands.transitionRun({
|
||||
runId: reference.run.id,
|
||||
to: 'dispatching',
|
||||
expectedVersion: reference.run.version,
|
||||
atMs: startingAtMs,
|
||||
actor: { type: 'scheduler' },
|
||||
});
|
||||
const starting = await this.commands.transitionRunAttempt({
|
||||
runId: reference.run.id,
|
||||
attemptId: reference.attempt.id,
|
||||
to: 'starting',
|
||||
expectedRunVersion: dispatching.run.version,
|
||||
atMs: startingAtMs,
|
||||
...(deadlineAtMs === undefined ? {} : { deadlineAtMs }),
|
||||
callbackTokenHash,
|
||||
...(logArtifactId === undefined ? {} : { logArtifactId }),
|
||||
actor: { type: 'worker', id: this.executor.type },
|
||||
});
|
||||
return { run: starting.run, attempt: starting.attempt };
|
||||
}
|
||||
|
||||
private assertLogArtifactId(value: string | undefined): void {
|
||||
if (value === undefined) return;
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length < 1 ||
|
||||
value.length > MAX_LOG_ARTIFACT_ID_LENGTH ||
|
||||
!/^[A-Za-z0-9._:-]+$/.test(value)
|
||||
) {
|
||||
throw new TypeError('Primary Run logArtifactId is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
private async recordRunning(
|
||||
reference: PrimaryRunReference,
|
||||
handle: ExecutionHandle,
|
||||
): Promise<PrimaryRunReference> {
|
||||
const runningAttempt = await this.commands.transitionRunAttempt({
|
||||
runId: reference.run.id,
|
||||
attemptId: reference.attempt.id,
|
||||
to: 'running',
|
||||
expectedRunVersion: reference.run.version,
|
||||
atMs: this.atOrAfter(
|
||||
reference.run.createdAtMs,
|
||||
reference.attempt.createdAtMs,
|
||||
handle.startedAtMs,
|
||||
),
|
||||
executorHandle: handle.durableHandle ?? handle.id,
|
||||
...(handle.pid === undefined ? {} : { pid: handle.pid }),
|
||||
actor: { type: 'executor', id: this.executor.type },
|
||||
});
|
||||
const runningRun = await this.commands.transitionRun({
|
||||
runId: reference.run.id,
|
||||
to: 'running',
|
||||
expectedVersion: runningAttempt.run.version,
|
||||
atMs: this.atOrAfter(
|
||||
runningAttempt.run.createdAtMs,
|
||||
runningAttempt.attempt.startedAtMs,
|
||||
),
|
||||
actor: { type: 'executor', id: this.executor.type },
|
||||
});
|
||||
return { run: runningRun.run, attempt: runningAttempt.attempt };
|
||||
}
|
||||
|
||||
private async recordStartFailure(
|
||||
reference: PrimaryRunReference,
|
||||
): Promise<PrimaryRunReference> {
|
||||
const atMs = this.atOrAfter(
|
||||
reference.run.createdAtMs,
|
||||
reference.attempt.createdAtMs,
|
||||
);
|
||||
const failedAttempt = await this.commands.transitionRunAttempt({
|
||||
runId: reference.run.id,
|
||||
attemptId: reference.attempt.id,
|
||||
to: 'failed',
|
||||
expectedRunVersion: reference.run.version,
|
||||
atMs,
|
||||
errorCode: 'EXECUTOR_START_FAILED',
|
||||
errorSummary: 'Executor failed before ownership was established',
|
||||
actor: { type: 'executor', id: this.executor.type },
|
||||
});
|
||||
const failedRun = await this.commands.transitionRun({
|
||||
runId: reference.run.id,
|
||||
to: 'failed',
|
||||
expectedVersion: failedAttempt.run.version,
|
||||
atMs,
|
||||
errorCode: 'EXECUTOR_START_FAILED',
|
||||
errorSummary: 'Executor failed before ownership was established',
|
||||
actor: { type: 'executor', id: this.executor.type },
|
||||
});
|
||||
return { run: failedRun.run, attempt: failedAttempt.attempt };
|
||||
}
|
||||
|
||||
private async compensateActivationFailure(
|
||||
reference: PrimaryRunReference,
|
||||
handle: ExecutionHandle,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.executor.stop(handle, {
|
||||
kind: 'reconcile',
|
||||
requestedAtMs: this.atOrAfter(reference.run.createdAtMs),
|
||||
});
|
||||
} catch {
|
||||
// The durable state below remains lost when process ownership is unknown.
|
||||
}
|
||||
|
||||
try {
|
||||
let latest = await this.loadReference(reference);
|
||||
const atMs = this.atOrAfter(
|
||||
latest.run.createdAtMs,
|
||||
latest.run.startedAtMs,
|
||||
latest.attempt.createdAtMs,
|
||||
latest.attempt.startedAtMs,
|
||||
);
|
||||
if (!isTerminalRunAttemptStatus(latest.attempt.status)) {
|
||||
const attempt = await this.commands.transitionRunAttempt({
|
||||
runId: latest.run.id,
|
||||
attemptId: latest.attempt.id,
|
||||
to: 'lost',
|
||||
expectedRunVersion: latest.run.version,
|
||||
atMs,
|
||||
errorCode: 'EXECUTION_ACTIVATION_PERSISTENCE_FAILED',
|
||||
errorSummary: 'Executor ownership could not be persisted',
|
||||
actor: { type: 'reconciler' },
|
||||
});
|
||||
latest = { run: attempt.run, attempt: attempt.attempt };
|
||||
}
|
||||
if (!isTerminalRunStatus(latest.run.status)) {
|
||||
await this.commands.transitionRun({
|
||||
runId: latest.run.id,
|
||||
to: 'lost',
|
||||
expectedVersion: latest.run.version,
|
||||
atMs,
|
||||
errorCode: 'EXECUTION_ACTIVATION_PERSISTENCE_FAILED',
|
||||
errorSummary: 'Executor ownership could not be persisted',
|
||||
actor: { type: 'reconciler' },
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Original persistence failure is reported to the caller.
|
||||
}
|
||||
}
|
||||
|
||||
private async loadReference(
|
||||
fallback: PrimaryRunReference,
|
||||
): Promise<PrimaryRunReference> {
|
||||
const [run, attempt] = await Promise.all([
|
||||
this.repository.findRunById(fallback.run.id),
|
||||
this.repository.findAttemptById(fallback.attempt.id),
|
||||
]);
|
||||
return {
|
||||
run: run ?? fallback.run,
|
||||
attempt: attempt ?? fallback.attempt,
|
||||
};
|
||||
}
|
||||
|
||||
private assertSpecMatches(
|
||||
reference: PrimaryRunReference,
|
||||
spec: ExecutionSpec,
|
||||
timeoutMs?: number,
|
||||
): void {
|
||||
if (
|
||||
spec.runId !== reference.run.id ||
|
||||
spec.attemptId !== reference.attempt.id ||
|
||||
spec.projectId !== reference.run.projectId ||
|
||||
spec.taskId !== reference.run.taskId ||
|
||||
spec.taskRevision !== reference.run.taskRevision ||
|
||||
spec.timeoutMs !== timeoutMs
|
||||
) {
|
||||
throw new Error('ExecutionSpec does not match its persisted Primary Run');
|
||||
}
|
||||
}
|
||||
|
||||
private assertTimeout(timeoutMs: number | undefined): void {
|
||||
if (
|
||||
timeoutMs !== undefined &&
|
||||
(!Number.isSafeInteger(timeoutMs) || timeoutMs < 1)
|
||||
) {
|
||||
throw new RangeError('Primary Run timeoutMs must be a positive integer');
|
||||
}
|
||||
}
|
||||
|
||||
private assertHandleMatches(
|
||||
reference: PrimaryRunReference,
|
||||
handle: ExecutionHandle,
|
||||
): void {
|
||||
if (
|
||||
handle.runId !== reference.run.id ||
|
||||
handle.attemptId !== reference.attempt.id ||
|
||||
handle.executorType !== this.executor.type
|
||||
) {
|
||||
throw new Error(
|
||||
'Executor handle does not match its persisted Primary Run',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private atOrAfter(...timestamps: Array<number | undefined>): number {
|
||||
return Math.max(
|
||||
this.clock.now(),
|
||||
...timestamps.filter((value): value is number => value !== undefined),
|
||||
);
|
||||
}
|
||||
|
||||
private cancellationActor(reason: ExecutionStopReason): {
|
||||
type: 'user' | 'reconciler' | 'system';
|
||||
} {
|
||||
if (reason.kind === 'user') return { type: 'user' };
|
||||
if (reason.kind === 'reconcile') return { type: 'reconciler' };
|
||||
return { type: 'system' };
|
||||
}
|
||||
|
||||
private deleteActive(runId: string, handle: ExecutionHandle): void {
|
||||
if (this.active.get(runId)?.handle === handle) this.active.delete(runId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
import { v7 as uuidV7 } from 'uuid';
|
||||
import type {
|
||||
RunAttemptRecord,
|
||||
RunEventRecord,
|
||||
RunRecord,
|
||||
} from '../domain/run';
|
||||
import {
|
||||
isTerminalRunAttemptStatus,
|
||||
reserveRunEvent,
|
||||
} from '../domain/runStateMachine';
|
||||
import { RunVersionConflictError } from '../domain/stateMachineErrors';
|
||||
import type { PersistedExecutionInspector } from '../ports/persistedExecutionInspector';
|
||||
import type { CompletionReceiptJournal } from '../ports/completionReceiptJournal';
|
||||
import type {
|
||||
PrimaryRunRecoveryCandidate,
|
||||
PrimaryRunRecoveryCursor,
|
||||
PrimaryRunRecoverySource,
|
||||
} from '../ports/primaryRunRecoverySource';
|
||||
import type { RunRepository } from '../ports/runRepository';
|
||||
import type { PrimaryCompletionReceiptConsumer } from './primaryCompletionReceiptConsumer';
|
||||
import { RunCommandService } from './runCommandService';
|
||||
|
||||
export interface PrimaryRunStartupReconcilerClock {
|
||||
now(): number;
|
||||
}
|
||||
|
||||
export interface PrimaryRunStartupReconcilerOptions {
|
||||
clock?: PrimaryRunStartupReconcilerClock;
|
||||
createEventId?: () => string;
|
||||
completionReceipts?: Pick<PrimaryCompletionReceiptConsumer, 'consume'>;
|
||||
completionReceiptJournal?: Pick<CompletionReceiptJournal, 'register'>;
|
||||
receiptPublishGraceMs?: number;
|
||||
wait?: (delayMs: number) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface PrimaryRunStartupReconcileSummary {
|
||||
scanned: number;
|
||||
verifiedRunning: number;
|
||||
recoveredRunning: number;
|
||||
completedFromReceipt: number;
|
||||
quarantinedReceipts: number;
|
||||
publishGraceWaits: number;
|
||||
markedLost: number;
|
||||
skipped: number;
|
||||
ambiguous: number;
|
||||
failed: number;
|
||||
truncated: boolean;
|
||||
unsafeAttemptOverflow: boolean;
|
||||
nextCursor?: PrimaryRunRecoveryCursor;
|
||||
}
|
||||
|
||||
type RecoveryLossReason =
|
||||
| 'attempt_missing'
|
||||
| 'attempt_incomplete'
|
||||
| 'handle_missing'
|
||||
| 'handle_invalid'
|
||||
| 'identity_mismatch'
|
||||
| 'identity_pid_mismatch'
|
||||
| 'identity_unsupported'
|
||||
| 'process_exited_unobserved';
|
||||
|
||||
const LOSS_METADATA: Readonly<
|
||||
Record<RecoveryLossReason, { errorCode: string; errorSummary: string }>
|
||||
> = {
|
||||
attempt_missing: {
|
||||
errorCode: 'RECOVERY_ATTEMPT_MISSING',
|
||||
errorSummary: 'Active Run has no recoverable Attempt',
|
||||
},
|
||||
attempt_incomplete: {
|
||||
errorCode: 'RECOVERY_ATTEMPT_INCOMPLETE',
|
||||
errorSummary: 'Attempt did not persist executable ownership',
|
||||
},
|
||||
handle_missing: {
|
||||
errorCode: 'RECOVERY_HANDLE_MISSING',
|
||||
errorSummary: 'Attempt has no durable Executor handle',
|
||||
},
|
||||
handle_invalid: {
|
||||
errorCode: 'RECOVERY_HANDLE_INVALID',
|
||||
errorSummary: 'Attempt durable Executor handle is invalid',
|
||||
},
|
||||
identity_mismatch: {
|
||||
errorCode: 'RECOVERY_IDENTITY_MISMATCH',
|
||||
errorSummary: 'Operating system process identity does not match',
|
||||
},
|
||||
identity_pid_mismatch: {
|
||||
errorCode: 'RECOVERY_IDENTITY_PID_MISMATCH',
|
||||
errorSummary: 'Persisted PID does not match the durable handle',
|
||||
},
|
||||
identity_unsupported: {
|
||||
errorCode: 'RECOVERY_IDENTITY_UNSUPPORTED',
|
||||
errorSummary: 'Process identity cannot be verified on this platform',
|
||||
},
|
||||
process_exited_unobserved: {
|
||||
errorCode: 'RECOVERY_PROCESS_EXITED_UNOBSERVED',
|
||||
errorSummary: 'Process exited without a durable completion result',
|
||||
},
|
||||
};
|
||||
|
||||
/** One bounded startup pass. The caller owns scheduling and pagination. */
|
||||
export class PrimaryRunStartupReconciler {
|
||||
private readonly clock: PrimaryRunStartupReconcilerClock;
|
||||
private readonly createEventId: () => string;
|
||||
private readonly commands: RunCommandService;
|
||||
private readonly completionReceipts?: Pick<
|
||||
PrimaryCompletionReceiptConsumer,
|
||||
'consume'
|
||||
>;
|
||||
private readonly receiptPublishGraceMs: number;
|
||||
private readonly wait: (delayMs: number) => Promise<void>;
|
||||
private readonly completionReceiptJournal?: Pick<
|
||||
CompletionReceiptJournal,
|
||||
'register'
|
||||
>;
|
||||
private readonly inspectors = new Map<
|
||||
PersistedExecutionInspector['executorType'],
|
||||
PersistedExecutionInspector
|
||||
>();
|
||||
|
||||
constructor(
|
||||
private readonly repository: RunRepository,
|
||||
private readonly source: PrimaryRunRecoverySource,
|
||||
inspectors: readonly PersistedExecutionInspector[],
|
||||
options: PrimaryRunStartupReconcilerOptions = {},
|
||||
) {
|
||||
this.clock = options.clock ?? { now: Date.now };
|
||||
this.createEventId = options.createEventId ?? uuidV7;
|
||||
this.commands = new RunCommandService(repository, this.createEventId);
|
||||
this.completionReceipts = options.completionReceipts;
|
||||
this.completionReceiptJournal = options.completionReceiptJournal;
|
||||
this.receiptPublishGraceMs = options.receiptPublishGraceMs ?? 0;
|
||||
if (
|
||||
!Number.isSafeInteger(this.receiptPublishGraceMs) ||
|
||||
this.receiptPublishGraceMs < 0 ||
|
||||
this.receiptPublishGraceMs > 5_000
|
||||
) {
|
||||
throw new RangeError('receiptPublishGraceMs must be between 0 and 5000');
|
||||
}
|
||||
this.wait =
|
||||
options.wait ??
|
||||
((delayMs) =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(resolve, delayMs);
|
||||
}));
|
||||
for (const inspector of inspectors) {
|
||||
if (this.inspectors.has(inspector.executorType)) {
|
||||
throw new Error(
|
||||
`Duplicate persisted Executor inspector: ${inspector.executorType}`,
|
||||
);
|
||||
}
|
||||
this.inspectors.set(inspector.executorType, inspector);
|
||||
}
|
||||
}
|
||||
|
||||
async reconcileBatch(
|
||||
options: {
|
||||
cursor?: PrimaryRunRecoveryCursor;
|
||||
limit?: number;
|
||||
} = {},
|
||||
): Promise<PrimaryRunStartupReconcileSummary> {
|
||||
const page = await this.source.listCandidates(options);
|
||||
const summary: PrimaryRunStartupReconcileSummary = {
|
||||
scanned: page.candidates.length,
|
||||
verifiedRunning: 0,
|
||||
recoveredRunning: 0,
|
||||
completedFromReceipt: 0,
|
||||
quarantinedReceipts: 0,
|
||||
publishGraceWaits: 0,
|
||||
markedLost: 0,
|
||||
skipped: 0,
|
||||
ambiguous: 0,
|
||||
failed: 0,
|
||||
truncated: page.truncated,
|
||||
unsafeAttemptOverflow: page.unsafeAttemptOverflow,
|
||||
...(page.nextCursor === undefined ? {} : { nextCursor: page.nextCursor }),
|
||||
};
|
||||
if (page.unsafeAttemptOverflow) return summary;
|
||||
|
||||
for (const candidate of page.candidates) {
|
||||
try {
|
||||
await this.reconcileCandidate(candidate, summary);
|
||||
} catch {
|
||||
summary.failed += 1;
|
||||
}
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
private async reconcileCandidate(
|
||||
candidate: PrimaryRunRecoveryCandidate,
|
||||
summary: PrimaryRunStartupReconcileSummary,
|
||||
): Promise<void> {
|
||||
const run = await this.repository.findRunById(candidate.runId);
|
||||
if (
|
||||
!run ||
|
||||
run.executionOwner !== 'runtime' ||
|
||||
!['dispatching', 'running'].includes(run.status)
|
||||
) {
|
||||
summary.skipped += 1;
|
||||
return;
|
||||
}
|
||||
if (candidate.attempts.length > 1) {
|
||||
summary.ambiguous += 1;
|
||||
return;
|
||||
}
|
||||
if (candidate.attempts.length === 0) {
|
||||
await this.markLost(run, undefined, 'attempt_missing');
|
||||
summary.markedLost += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const attemptReference = candidate.attempts[0];
|
||||
const attempt = await this.repository.findAttemptById(
|
||||
attemptReference.attemptId,
|
||||
);
|
||||
if (
|
||||
!attempt ||
|
||||
attempt.runId !== run.id ||
|
||||
!['claimed', 'starting', 'running'].includes(attempt.status)
|
||||
) {
|
||||
summary.skipped += 1;
|
||||
return;
|
||||
}
|
||||
if (
|
||||
attempt.executorType === 'local_process' &&
|
||||
this.completionReceiptJournal
|
||||
) {
|
||||
await this.completionReceiptJournal.register({
|
||||
runId: run.id,
|
||||
attemptId: attempt.id,
|
||||
registeredAtMs: attempt.createdAtMs,
|
||||
});
|
||||
}
|
||||
if (await this.consumeCompletionReceipt(attempt, summary)) return;
|
||||
const inspector = this.inspectors.get(attemptReference.executorType);
|
||||
if (!inspector || attempt.executorType !== inspector.executorType) {
|
||||
summary.skipped += 1;
|
||||
return;
|
||||
}
|
||||
if (attempt.status !== 'running') {
|
||||
await this.markLost(run, attempt, 'attempt_incomplete');
|
||||
summary.markedLost += 1;
|
||||
return;
|
||||
}
|
||||
if (!attempt.executorHandle) {
|
||||
await this.markLost(run, attempt, 'handle_missing');
|
||||
summary.markedLost += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const inspection = await inspector.inspect(attempt.executorHandle);
|
||||
if (
|
||||
inspection.status !== 'running' ||
|
||||
(inspection.identityPid !== undefined &&
|
||||
inspection.identityPid !== attempt.pid)
|
||||
) {
|
||||
if (await this.consumeCompletionReceipt(attempt, summary)) return;
|
||||
if (
|
||||
inspection.status === 'exited' &&
|
||||
(inspection.identityPid === undefined ||
|
||||
inspection.identityPid === attempt.pid) &&
|
||||
this.receiptPublishGraceMs > 0
|
||||
) {
|
||||
summary.publishGraceWaits += 1;
|
||||
await this.wait(this.receiptPublishGraceMs);
|
||||
if (await this.consumeCompletionReceipt(attempt, summary)) return;
|
||||
}
|
||||
}
|
||||
if (
|
||||
inspection.identityPid !== undefined &&
|
||||
inspection.identityPid !== attempt.pid
|
||||
) {
|
||||
await this.markLost(run, attempt, 'identity_pid_mismatch');
|
||||
summary.markedLost += 1;
|
||||
return;
|
||||
}
|
||||
if (inspection.status === 'running') {
|
||||
if (run.status === 'dispatching') {
|
||||
await this.commands.transitionRun({
|
||||
runId: run.id,
|
||||
to: 'running',
|
||||
expectedVersion: run.version,
|
||||
atMs: this.atOrAfter(
|
||||
run.createdAtMs,
|
||||
attempt.createdAtMs,
|
||||
attempt.startedAtMs,
|
||||
),
|
||||
actor: { type: 'reconciler' },
|
||||
});
|
||||
summary.recoveredRunning += 1;
|
||||
} else {
|
||||
await this.appendVerifiedRunningEvent(run, attempt);
|
||||
summary.verifiedRunning += 1;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const reason: RecoveryLossReason =
|
||||
inspection.status === 'invalid'
|
||||
? 'handle_invalid'
|
||||
: inspection.status === 'identity_mismatch'
|
||||
? 'identity_mismatch'
|
||||
: inspection.status === 'unsupported'
|
||||
? 'identity_unsupported'
|
||||
: 'process_exited_unobserved';
|
||||
await this.markLost(run, attempt, reason);
|
||||
summary.markedLost += 1;
|
||||
}
|
||||
|
||||
private async consumeCompletionReceipt(
|
||||
attempt: RunAttemptRecord,
|
||||
summary: PrimaryRunStartupReconcileSummary,
|
||||
): Promise<boolean> {
|
||||
if (!this.completionReceipts || attempt.executorType !== 'local_process') {
|
||||
return false;
|
||||
}
|
||||
const result = await this.completionReceipts.consume(attempt.id);
|
||||
if (result.status === 'missing') return false;
|
||||
if (result.status === 'quarantined') {
|
||||
summary.quarantinedReceipts += 1;
|
||||
return false;
|
||||
}
|
||||
summary.completedFromReceipt += 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
private async markLost(
|
||||
initialRun: RunRecord,
|
||||
initialAttempt: RunAttemptRecord | undefined,
|
||||
reason: RecoveryLossReason,
|
||||
): Promise<void> {
|
||||
const metadata = LOSS_METADATA[reason];
|
||||
let run = initialRun;
|
||||
const atMs = this.atOrAfter(
|
||||
run.createdAtMs,
|
||||
run.startedAtMs,
|
||||
initialAttempt?.createdAtMs,
|
||||
initialAttempt?.startedAtMs,
|
||||
);
|
||||
if (initialAttempt && !isTerminalRunAttemptStatus(initialAttempt.status)) {
|
||||
const attempt = await this.commands.transitionRunAttempt({
|
||||
runId: run.id,
|
||||
attemptId: initialAttempt.id,
|
||||
to: 'lost',
|
||||
expectedRunVersion: run.version,
|
||||
atMs,
|
||||
errorCode: metadata.errorCode,
|
||||
errorSummary: metadata.errorSummary,
|
||||
actor: { type: 'reconciler' },
|
||||
});
|
||||
run = attempt.run;
|
||||
}
|
||||
await this.commands.transitionRun({
|
||||
runId: run.id,
|
||||
to: 'lost',
|
||||
expectedVersion: run.version,
|
||||
atMs,
|
||||
errorCode: metadata.errorCode,
|
||||
errorSummary: metadata.errorSummary,
|
||||
actor: { type: 'reconciler' },
|
||||
});
|
||||
}
|
||||
|
||||
private async appendVerifiedRunningEvent(
|
||||
expectedRun: RunRecord,
|
||||
attempt: RunAttemptRecord,
|
||||
): Promise<void> {
|
||||
const atMs = this.atOrAfter(
|
||||
expectedRun.createdAtMs,
|
||||
expectedRun.startedAtMs,
|
||||
attempt.createdAtMs,
|
||||
attempt.startedAtMs,
|
||||
);
|
||||
await this.repository.transaction(async (transaction) => {
|
||||
const current = await transaction.findRunById(expectedRun.id);
|
||||
if (!current) throw new Error('Primary Run disappeared during recovery');
|
||||
if (current.version !== expectedRun.version) {
|
||||
throw new RunVersionConflictError(
|
||||
current.id,
|
||||
expectedRun.version,
|
||||
current.version,
|
||||
);
|
||||
}
|
||||
const reserved = reserveRunEvent(current, current.version);
|
||||
const updated = await transaction.compareAndSetRun(
|
||||
reserved.run,
|
||||
current.version,
|
||||
);
|
||||
if (!updated) {
|
||||
throw new RunVersionConflictError(
|
||||
current.id,
|
||||
current.version,
|
||||
current.version,
|
||||
);
|
||||
}
|
||||
const event: RunEventRecord = {
|
||||
id: this.createEventId(),
|
||||
runId: current.id,
|
||||
attemptId: attempt.id,
|
||||
sequence: reserved.sequence,
|
||||
type: 'run.reconciled',
|
||||
dedupeKey: `primary-running-reconciled:${attempt.id}:${current.version}`,
|
||||
actorType: 'reconciler',
|
||||
payload: {
|
||||
status: 'running',
|
||||
executor_type: attempt.executorType,
|
||||
evidence: 'durable_handle',
|
||||
version: reserved.run.version,
|
||||
},
|
||||
createdAtMs: atMs,
|
||||
};
|
||||
await transaction.appendEvent(event);
|
||||
});
|
||||
}
|
||||
|
||||
private atOrAfter(...timestamps: Array<number | undefined>): number {
|
||||
return Math.max(
|
||||
this.clock.now(),
|
||||
...timestamps.filter((value): value is number => value !== undefined),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { MAX_PRIMARY_RECOVERY_BATCH_SIZE } from '../ports/primaryRunRecoverySource';
|
||||
import type { PrimaryRunRecoveryCursor } from '../ports/primaryRunRecoverySource';
|
||||
import type {
|
||||
PrimaryRunStartupReconcileSummary,
|
||||
PrimaryRunStartupReconciler,
|
||||
} from './primaryRunStartupReconciler';
|
||||
|
||||
export const MAX_PRIMARY_RECOVERY_PAGES_PER_STARTUP = 64;
|
||||
|
||||
export type PrimaryRunStartupStopReason =
|
||||
| 'complete'
|
||||
| 'page_limit'
|
||||
| 'unsafe_attempt_overflow'
|
||||
| 'cursor_stalled';
|
||||
|
||||
export interface PrimaryRunStartupSummary
|
||||
extends Omit<
|
||||
PrimaryRunStartupReconcileSummary,
|
||||
'truncated' | 'unsafeAttemptOverflow' | 'nextCursor'
|
||||
> {
|
||||
pages: number;
|
||||
stopReason: PrimaryRunStartupStopReason;
|
||||
remaining: boolean;
|
||||
nextCursor?: PrimaryRunRecoveryCursor;
|
||||
}
|
||||
|
||||
export interface PrimaryRunStartupOptions {
|
||||
cursor?: PrimaryRunRecoveryCursor;
|
||||
pageSize?: number;
|
||||
maxPages?: number;
|
||||
}
|
||||
|
||||
function sameCursor(
|
||||
left: PrimaryRunRecoveryCursor | undefined,
|
||||
right: PrimaryRunRecoveryCursor,
|
||||
): boolean {
|
||||
return (
|
||||
left !== undefined &&
|
||||
left.createdAtMs === right.createdAtMs &&
|
||||
left.runId === right.runId
|
||||
);
|
||||
}
|
||||
|
||||
/** Runs a complete but bounded startup reconciliation before Primary activates. */
|
||||
export class PrimaryRunStartupSupervisor {
|
||||
constructor(
|
||||
private readonly reconciler: Pick<
|
||||
PrimaryRunStartupReconciler,
|
||||
'reconcileBatch'
|
||||
>,
|
||||
) {}
|
||||
|
||||
async run(
|
||||
options: PrimaryRunStartupOptions = {},
|
||||
): Promise<PrimaryRunStartupSummary> {
|
||||
const pageSize = options.pageSize ?? 32;
|
||||
const maxPages = options.maxPages ?? 4;
|
||||
if (
|
||||
!Number.isSafeInteger(pageSize) ||
|
||||
pageSize < 1 ||
|
||||
pageSize > MAX_PRIMARY_RECOVERY_BATCH_SIZE
|
||||
) {
|
||||
throw new RangeError(
|
||||
'pageSize must be between 1 and MAX_PRIMARY_RECOVERY_BATCH_SIZE',
|
||||
);
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(maxPages) ||
|
||||
maxPages < 1 ||
|
||||
maxPages > MAX_PRIMARY_RECOVERY_PAGES_PER_STARTUP
|
||||
) {
|
||||
throw new RangeError(
|
||||
'maxPages must be between 1 and MAX_PRIMARY_RECOVERY_PAGES_PER_STARTUP',
|
||||
);
|
||||
}
|
||||
|
||||
const total: PrimaryRunStartupSummary = {
|
||||
pages: 0,
|
||||
scanned: 0,
|
||||
verifiedRunning: 0,
|
||||
recoveredRunning: 0,
|
||||
completedFromReceipt: 0,
|
||||
quarantinedReceipts: 0,
|
||||
publishGraceWaits: 0,
|
||||
markedLost: 0,
|
||||
skipped: 0,
|
||||
ambiguous: 0,
|
||||
failed: 0,
|
||||
stopReason: 'complete',
|
||||
remaining: false,
|
||||
};
|
||||
let cursor = options.cursor;
|
||||
|
||||
for (let pageNumber = 0; pageNumber < maxPages; pageNumber += 1) {
|
||||
const page = await this.reconciler.reconcileBatch({
|
||||
...(cursor === undefined ? {} : { cursor }),
|
||||
limit: pageSize,
|
||||
});
|
||||
total.pages += 1;
|
||||
total.scanned += page.scanned;
|
||||
total.verifiedRunning += page.verifiedRunning;
|
||||
total.recoveredRunning += page.recoveredRunning;
|
||||
total.completedFromReceipt += page.completedFromReceipt;
|
||||
total.quarantinedReceipts += page.quarantinedReceipts;
|
||||
total.publishGraceWaits += page.publishGraceWaits;
|
||||
total.markedLost += page.markedLost;
|
||||
total.skipped += page.skipped;
|
||||
total.ambiguous += page.ambiguous;
|
||||
total.failed += page.failed;
|
||||
|
||||
if (page.unsafeAttemptOverflow) {
|
||||
total.stopReason = 'unsafe_attempt_overflow';
|
||||
total.remaining = true;
|
||||
return total;
|
||||
}
|
||||
if (!page.truncated) return total;
|
||||
if (!page.nextCursor || sameCursor(cursor, page.nextCursor)) {
|
||||
total.stopReason = 'cursor_stalled';
|
||||
total.remaining = true;
|
||||
return total;
|
||||
}
|
||||
cursor = page.nextCursor;
|
||||
if (pageNumber === maxPages - 1) {
|
||||
total.stopReason = 'page_limit';
|
||||
total.remaining = true;
|
||||
total.nextCursor = cursor;
|
||||
return total;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import type {
|
||||
PrimaryTimeoutSupervisor,
|
||||
PrimaryTimeoutSupervisorOptions,
|
||||
PrimaryTimeoutSupervisorSummary,
|
||||
} from './primaryTimeoutSupervisor';
|
||||
|
||||
export const MIN_TIMEOUT_CYCLE_INTERVAL_MS = 250;
|
||||
export const MAX_TIMEOUT_CYCLE_INTERVAL_MS = 24 * 60 * 60 * 1_000;
|
||||
export const MAX_TIMEOUT_INITIAL_DELAY_MS = 24 * 60 * 60 * 1_000;
|
||||
export const MAX_TIMEOUT_STOP_TIMEOUT_MS = 60_000;
|
||||
|
||||
interface ScheduledTimer {
|
||||
unref?: () => void;
|
||||
}
|
||||
|
||||
export interface TimeoutLifecycleScheduler {
|
||||
setTimeout(callback: () => void, delayMs: number): ScheduledTimer;
|
||||
clearTimeout(timer: ScheduledTimer): void;
|
||||
}
|
||||
|
||||
export interface PrimaryTimeoutLifecycleOptions {
|
||||
intervalMs: number;
|
||||
initialDelayMs?: number;
|
||||
stopTimeoutMs?: number;
|
||||
cycle?: Omit<PrimaryTimeoutSupervisorOptions, 'nowMs'>;
|
||||
scheduler?: TimeoutLifecycleScheduler;
|
||||
onCycle?: (summary: PrimaryTimeoutSupervisorSummary) => void;
|
||||
onError?: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export type PrimaryTimeoutStopResult = 'drained' | 'timed_out';
|
||||
|
||||
const defaultScheduler: TimeoutLifecycleScheduler = {
|
||||
setTimeout(callback, delayMs) {
|
||||
return setTimeout(callback, delayMs);
|
||||
},
|
||||
clearTimeout(timer) {
|
||||
clearTimeout(timer as ReturnType<typeof setTimeout>);
|
||||
},
|
||||
};
|
||||
|
||||
function assertIntegerBetween(
|
||||
name: string,
|
||||
value: number,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): void {
|
||||
if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
|
||||
throw new RangeError(`${name} must be between ${minimum} and ${maximum}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicit, non-overlapping timeout-intent lifecycle. It owns no process
|
||||
* signal path: each cycle only asks the supervisor to persist timeout intent.
|
||||
*/
|
||||
export class PrimaryTimeoutLifecycle {
|
||||
private readonly intervalMs: number;
|
||||
private readonly initialDelayMs: number;
|
||||
private readonly stopTimeoutMs: number;
|
||||
private readonly cycleOptions: Omit<PrimaryTimeoutSupervisorOptions, 'nowMs'>;
|
||||
private readonly scheduler: TimeoutLifecycleScheduler;
|
||||
private readonly onCycle?: (summary: PrimaryTimeoutSupervisorSummary) => void;
|
||||
private readonly onError?: (error: unknown) => void;
|
||||
private started = false;
|
||||
private timer?: ScheduledTimer;
|
||||
private inFlight?: Promise<void>;
|
||||
|
||||
constructor(
|
||||
private readonly supervisor: Pick<PrimaryTimeoutSupervisor, 'run'>,
|
||||
options: PrimaryTimeoutLifecycleOptions,
|
||||
) {
|
||||
this.intervalMs = options.intervalMs;
|
||||
this.initialDelayMs = options.initialDelayMs ?? 0;
|
||||
this.stopTimeoutMs = options.stopTimeoutMs ?? 5_000;
|
||||
this.cycleOptions = {
|
||||
...(options.cycle?.cursor === undefined
|
||||
? {}
|
||||
: { cursor: { ...options.cycle.cursor } }),
|
||||
...(options.cycle?.pageSize === undefined
|
||||
? {}
|
||||
: { pageSize: options.cycle.pageSize }),
|
||||
...(options.cycle?.maxPages === undefined
|
||||
? {}
|
||||
: { maxPages: options.cycle.maxPages }),
|
||||
};
|
||||
this.scheduler = options.scheduler ?? defaultScheduler;
|
||||
this.onCycle = options.onCycle;
|
||||
this.onError = options.onError;
|
||||
assertIntegerBetween(
|
||||
'intervalMs',
|
||||
this.intervalMs,
|
||||
MIN_TIMEOUT_CYCLE_INTERVAL_MS,
|
||||
MAX_TIMEOUT_CYCLE_INTERVAL_MS,
|
||||
);
|
||||
assertIntegerBetween(
|
||||
'initialDelayMs',
|
||||
this.initialDelayMs,
|
||||
0,
|
||||
MAX_TIMEOUT_INITIAL_DELAY_MS,
|
||||
);
|
||||
assertIntegerBetween(
|
||||
'stopTimeoutMs',
|
||||
this.stopTimeoutMs,
|
||||
1,
|
||||
MAX_TIMEOUT_STOP_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
|
||||
start(): boolean {
|
||||
if (this.started || this.inFlight) return false;
|
||||
this.started = true;
|
||||
this.schedule(this.initialDelayMs);
|
||||
return true;
|
||||
}
|
||||
|
||||
async stop(): Promise<PrimaryTimeoutStopResult> {
|
||||
this.started = false;
|
||||
if (this.timer) {
|
||||
this.scheduler.clearTimeout(this.timer);
|
||||
this.timer = undefined;
|
||||
}
|
||||
const inFlight = this.inFlight;
|
||||
if (!inFlight) return 'drained';
|
||||
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
const result = await Promise.race<PrimaryTimeoutStopResult>([
|
||||
inFlight.then(() => 'drained' as const),
|
||||
new Promise<'timed_out'>((resolve) => {
|
||||
timeout = setTimeout(() => resolve('timed_out'), this.stopTimeoutMs);
|
||||
}),
|
||||
]);
|
||||
if (timeout) clearTimeout(timeout);
|
||||
return result;
|
||||
}
|
||||
|
||||
private schedule(delayMs: number): void {
|
||||
if (!this.started || this.timer) return;
|
||||
const timer = this.scheduler.setTimeout(() => {
|
||||
if (this.timer === timer) this.timer = undefined;
|
||||
this.run();
|
||||
}, delayMs);
|
||||
this.timer = timer;
|
||||
timer.unref?.();
|
||||
}
|
||||
|
||||
private run(): void {
|
||||
if (!this.started || this.inFlight) return;
|
||||
const inFlight = this.supervisor
|
||||
.run(this.cycleOptions)
|
||||
.then((summary) => this.notifyCycle(summary))
|
||||
.catch((error) => this.notifyError(error))
|
||||
.then(() => undefined)
|
||||
.finally(() => {
|
||||
if (this.inFlight === inFlight) this.inFlight = undefined;
|
||||
if (this.started) this.schedule(this.intervalMs);
|
||||
});
|
||||
this.inFlight = inFlight;
|
||||
}
|
||||
|
||||
private notifyCycle(summary: PrimaryTimeoutSupervisorSummary): void {
|
||||
try {
|
||||
this.onCycle?.(summary);
|
||||
} catch (error) {
|
||||
this.notifyError(error);
|
||||
}
|
||||
}
|
||||
|
||||
private notifyError(error: unknown): void {
|
||||
try {
|
||||
this.onError?.(error);
|
||||
} catch {
|
||||
// Diagnostics must never create another scheduler failure loop.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { RunCancellationReason } from '../domain/run';
|
||||
import type {
|
||||
PrimaryTimeoutCursor,
|
||||
PrimaryTimeoutSource,
|
||||
} from '../ports/primaryTimeoutSource';
|
||||
import type {
|
||||
RequestRunCancellationCommand,
|
||||
RequestRunCancellationResult,
|
||||
} from './runCommandService';
|
||||
|
||||
export interface PrimaryTimeoutClock {
|
||||
now(): number;
|
||||
}
|
||||
|
||||
export interface PrimaryTimeoutCommandPort {
|
||||
requestCancellation(
|
||||
command: RequestRunCancellationCommand,
|
||||
): Promise<RequestRunCancellationResult>;
|
||||
}
|
||||
|
||||
export interface PrimaryTimeoutRequestSummary {
|
||||
scanned: number;
|
||||
accepted: number;
|
||||
alreadyRequested: number;
|
||||
alreadyTerminal: number;
|
||||
failed: number;
|
||||
truncated: boolean;
|
||||
nextCursor?: PrimaryTimeoutCursor;
|
||||
}
|
||||
|
||||
/** One bounded timeout-intent pass. It never calls an Executor or sends signal. */
|
||||
export class PrimaryTimeoutRequester {
|
||||
private readonly clock: PrimaryTimeoutClock;
|
||||
|
||||
constructor(
|
||||
private readonly source: PrimaryTimeoutSource,
|
||||
private readonly commands: PrimaryTimeoutCommandPort,
|
||||
clock: PrimaryTimeoutClock = { now: Date.now },
|
||||
) {
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
async requestBatch(
|
||||
options: {
|
||||
nowMs?: number;
|
||||
cursor?: PrimaryTimeoutCursor;
|
||||
limit?: number;
|
||||
} = {},
|
||||
): Promise<PrimaryTimeoutRequestSummary> {
|
||||
const nowMs = options.nowMs ?? this.clock.now();
|
||||
if (!Number.isSafeInteger(nowMs) || nowMs < 0) {
|
||||
throw new RangeError('nowMs must be a non-negative safe integer');
|
||||
}
|
||||
const page = await this.source.listOverdue({
|
||||
nowMs,
|
||||
...(options.cursor === undefined ? {} : { cursor: options.cursor }),
|
||||
...(options.limit === undefined ? {} : { limit: options.limit }),
|
||||
});
|
||||
const summary: PrimaryTimeoutRequestSummary = {
|
||||
scanned: page.candidates.length,
|
||||
accepted: 0,
|
||||
alreadyRequested: 0,
|
||||
alreadyTerminal: 0,
|
||||
failed: 0,
|
||||
truncated: page.truncated,
|
||||
...(page.nextCursor === undefined ? {} : { nextCursor: page.nextCursor }),
|
||||
};
|
||||
|
||||
for (const candidate of page.candidates) {
|
||||
if (candidate.deadlineAtMs > nowMs) {
|
||||
summary.failed += 1;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const result = await this.commands.requestCancellation({
|
||||
runId: candidate.runId,
|
||||
attemptId: candidate.attemptId,
|
||||
atMs: nowMs,
|
||||
reason: 'timeout' satisfies RunCancellationReason,
|
||||
actor: { type: 'system', id: 'runtime:timeout' },
|
||||
});
|
||||
if (result.status === 'accepted') summary.accepted += 1;
|
||||
else if (result.status === 'already_requested') {
|
||||
summary.alreadyRequested += 1;
|
||||
} else {
|
||||
summary.alreadyTerminal += 1;
|
||||
}
|
||||
} catch {
|
||||
summary.failed += 1;
|
||||
}
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import {
|
||||
MAX_PRIMARY_TIMEOUT_BATCH_SIZE,
|
||||
type PrimaryTimeoutCursor,
|
||||
} from '../ports/primaryTimeoutSource';
|
||||
import type {
|
||||
PrimaryTimeoutRequester,
|
||||
PrimaryTimeoutRequestSummary,
|
||||
} from './primaryTimeoutRequester';
|
||||
|
||||
export const MAX_PRIMARY_TIMEOUT_SUPERVISOR_PAGES = 64;
|
||||
|
||||
export type PrimaryTimeoutStopReason =
|
||||
| 'complete'
|
||||
| 'page_limit'
|
||||
| 'cursor_stalled';
|
||||
|
||||
export interface PrimaryTimeoutSupervisorSummary {
|
||||
pages: number;
|
||||
scanned: number;
|
||||
accepted: number;
|
||||
alreadyRequested: number;
|
||||
alreadyTerminal: number;
|
||||
failed: number;
|
||||
stopReason: PrimaryTimeoutStopReason;
|
||||
remaining: boolean;
|
||||
nextCursor?: PrimaryTimeoutCursor;
|
||||
}
|
||||
|
||||
export interface PrimaryTimeoutSupervisorOptions {
|
||||
nowMs?: number;
|
||||
pageSize?: number;
|
||||
maxPages?: number;
|
||||
cursor?: PrimaryTimeoutCursor;
|
||||
}
|
||||
|
||||
export interface PrimaryTimeoutSupervisorClock {
|
||||
now(): number;
|
||||
}
|
||||
|
||||
function sameCursor(
|
||||
left: PrimaryTimeoutCursor | undefined,
|
||||
right: PrimaryTimeoutCursor | undefined,
|
||||
): boolean {
|
||||
return (
|
||||
left !== undefined &&
|
||||
right !== undefined &&
|
||||
left.deadlineAtMs === right.deadlineAtMs &&
|
||||
left.attemptId === right.attemptId
|
||||
);
|
||||
}
|
||||
|
||||
export class PrimaryTimeoutSupervisor {
|
||||
constructor(
|
||||
private readonly requester: Pick<PrimaryTimeoutRequester, 'requestBatch'>,
|
||||
private readonly clock: PrimaryTimeoutSupervisorClock = { now: Date.now },
|
||||
) {}
|
||||
|
||||
async run(
|
||||
options: PrimaryTimeoutSupervisorOptions = {},
|
||||
): Promise<PrimaryTimeoutSupervisorSummary> {
|
||||
const pageSize = options.pageSize ?? 32;
|
||||
const maxPages = options.maxPages ?? 4;
|
||||
const nowMs = options.nowMs ?? this.clock.now();
|
||||
if (!Number.isSafeInteger(nowMs) || nowMs < 0) {
|
||||
throw new RangeError('nowMs must be a non-negative safe integer');
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(pageSize) ||
|
||||
pageSize < 1 ||
|
||||
pageSize > MAX_PRIMARY_TIMEOUT_BATCH_SIZE
|
||||
) {
|
||||
throw new RangeError(
|
||||
'pageSize must be between 1 and MAX_PRIMARY_TIMEOUT_BATCH_SIZE',
|
||||
);
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(maxPages) ||
|
||||
maxPages < 1 ||
|
||||
maxPages > MAX_PRIMARY_TIMEOUT_SUPERVISOR_PAGES
|
||||
) {
|
||||
throw new RangeError(
|
||||
'maxPages must be between 1 and MAX_PRIMARY_TIMEOUT_SUPERVISOR_PAGES',
|
||||
);
|
||||
}
|
||||
|
||||
const aggregate: PrimaryTimeoutSupervisorSummary = {
|
||||
pages: 0,
|
||||
scanned: 0,
|
||||
accepted: 0,
|
||||
alreadyRequested: 0,
|
||||
alreadyTerminal: 0,
|
||||
failed: 0,
|
||||
stopReason: 'complete',
|
||||
remaining: false,
|
||||
};
|
||||
let cursor = options.cursor;
|
||||
for (let pageNumber = 0; pageNumber < maxPages; pageNumber += 1) {
|
||||
const page: PrimaryTimeoutRequestSummary =
|
||||
await this.requester.requestBatch({
|
||||
nowMs,
|
||||
...(cursor === undefined ? {} : { cursor }),
|
||||
limit: pageSize,
|
||||
});
|
||||
aggregate.pages += 1;
|
||||
aggregate.scanned += page.scanned;
|
||||
aggregate.accepted += page.accepted;
|
||||
aggregate.alreadyRequested += page.alreadyRequested;
|
||||
aggregate.alreadyTerminal += page.alreadyTerminal;
|
||||
aggregate.failed += page.failed;
|
||||
|
||||
if (!page.truncated) return aggregate;
|
||||
if (!page.nextCursor || sameCursor(cursor, page.nextCursor)) {
|
||||
aggregate.stopReason = 'cursor_stalled';
|
||||
aggregate.remaining = true;
|
||||
if (page.nextCursor) aggregate.nextCursor = page.nextCursor;
|
||||
return aggregate;
|
||||
}
|
||||
cursor = page.nextCursor;
|
||||
}
|
||||
aggregate.stopReason = 'page_limit';
|
||||
aggregate.remaining = true;
|
||||
if (cursor) aggregate.nextCursor = cursor;
|
||||
return aggregate;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { randomBytes } from 'crypto';
|
||||
import {
|
||||
assertAuthenticatedPrincipalActive,
|
||||
normalizeAuthenticatedPrincipal,
|
||||
type AuthenticatedPrincipal,
|
||||
} from '../domain/authenticatedPrincipal';
|
||||
import { assertProjectPolicyProjectId } from '../domain/projectPolicy';
|
||||
import {
|
||||
OWNER_BOOTSTRAP_CHALLENGE_ID_BYTES,
|
||||
OWNER_BOOTSTRAP_DEFAULT_TTL_MS,
|
||||
OWNER_BOOTSTRAP_SYSTEM_SUBJECT,
|
||||
OWNER_BOOTSTRAP_TOKEN_BYTES,
|
||||
ProjectOwnerBootstrapUnauthorizedError,
|
||||
assertProjectOwnerBootstrapChallengeId,
|
||||
assertProjectOwnerBootstrapToken,
|
||||
assertProjectOwnerBootstrapTtl,
|
||||
digestProjectOwnerBootstrapToken,
|
||||
} from '../domain/projectOwnerBootstrap';
|
||||
import type { ProjectOwnerBootstrapRepository } from '../ports/projectOwnerBootstrapRepository';
|
||||
|
||||
export interface IssueProjectOwnerBootstrapRequest {
|
||||
projectId: string;
|
||||
issuer: AuthenticatedPrincipal;
|
||||
nowMs: number;
|
||||
ttlMs?: number;
|
||||
}
|
||||
|
||||
export interface IssuedProjectOwnerBootstrapChallenge {
|
||||
projectId: string;
|
||||
challengeId: string;
|
||||
token: string;
|
||||
expiresAtMs: number;
|
||||
}
|
||||
|
||||
export interface ClaimProjectOwnerBootstrapRequest {
|
||||
projectId: string;
|
||||
challengeId: string;
|
||||
token: string;
|
||||
principal: AuthenticatedPrincipal;
|
||||
nowMs: number;
|
||||
}
|
||||
|
||||
export interface ProjectOwnerBootstrapRandomSource {
|
||||
bytes(size: number): Uint8Array;
|
||||
}
|
||||
|
||||
const CRYPTO_RANDOM_SOURCE: ProjectOwnerBootstrapRandomSource = {
|
||||
bytes: randomBytes,
|
||||
};
|
||||
|
||||
function encodeRandom(
|
||||
source: ProjectOwnerBootstrapRandomSource,
|
||||
size: number,
|
||||
): string {
|
||||
const bytes = source.bytes(size);
|
||||
if (!(bytes instanceof Uint8Array) || bytes.byteLength !== size) {
|
||||
throw new TypeError('Project owner bootstrap random source is invalid');
|
||||
}
|
||||
try {
|
||||
return Buffer.from(bytes).toString('base64url');
|
||||
} finally {
|
||||
bytes.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
function assertExactRequestKeys(
|
||||
value: object,
|
||||
expected: readonly string[],
|
||||
): void {
|
||||
const keys = Object.keys(value).sort();
|
||||
const canonical = [...expected].sort();
|
||||
if (
|
||||
keys.length !== canonical.length ||
|
||||
keys.some((key, index) => key !== canonical[index])
|
||||
) {
|
||||
throw new TypeError('Project owner bootstrap request shape is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
export class ProjectOwnerBootstrapService {
|
||||
constructor(
|
||||
private readonly repository: ProjectOwnerBootstrapRepository,
|
||||
private readonly randomSource: ProjectOwnerBootstrapRandomSource = CRYPTO_RANDOM_SOURCE,
|
||||
) {}
|
||||
|
||||
async issue(
|
||||
request: IssueProjectOwnerBootstrapRequest,
|
||||
): Promise<Readonly<IssuedProjectOwnerBootstrapChallenge>> {
|
||||
if (!request || typeof request !== 'object' || Array.isArray(request)) {
|
||||
throw new TypeError(
|
||||
'Project owner bootstrap issue request must be an object',
|
||||
);
|
||||
}
|
||||
assertExactRequestKeys(
|
||||
request,
|
||||
request.ttlMs === undefined
|
||||
? ['projectId', 'issuer', 'nowMs']
|
||||
: ['projectId', 'issuer', 'nowMs', 'ttlMs'],
|
||||
);
|
||||
assertProjectPolicyProjectId(request.projectId);
|
||||
const issuer = normalizeAuthenticatedPrincipal(request.issuer);
|
||||
assertAuthenticatedPrincipalActive(issuer, request.nowMs);
|
||||
if (
|
||||
issuer.subject.type !== OWNER_BOOTSTRAP_SYSTEM_SUBJECT.type ||
|
||||
issuer.subject.id !== OWNER_BOOTSTRAP_SYSTEM_SUBJECT.id ||
|
||||
issuer.assurance !== 'local_console'
|
||||
) {
|
||||
throw new ProjectOwnerBootstrapUnauthorizedError();
|
||||
}
|
||||
const ttlMs = request.ttlMs ?? OWNER_BOOTSTRAP_DEFAULT_TTL_MS;
|
||||
assertProjectOwnerBootstrapTtl(ttlMs);
|
||||
const expiresAtMs = request.nowMs + ttlMs;
|
||||
if (!Number.isSafeInteger(expiresAtMs)) {
|
||||
throw new TypeError('Project owner bootstrap expiry is invalid');
|
||||
}
|
||||
const challengeId = encodeRandom(
|
||||
this.randomSource,
|
||||
OWNER_BOOTSTRAP_CHALLENGE_ID_BYTES,
|
||||
);
|
||||
const token = encodeRandom(this.randomSource, OWNER_BOOTSTRAP_TOKEN_BYTES);
|
||||
assertProjectOwnerBootstrapChallengeId(challengeId);
|
||||
assertProjectOwnerBootstrapToken(token);
|
||||
const tokenDigest = digestProjectOwnerBootstrapToken(
|
||||
request.projectId,
|
||||
challengeId,
|
||||
token,
|
||||
);
|
||||
await this.repository.issue({
|
||||
projectId: request.projectId,
|
||||
challengeId,
|
||||
tokenDigest,
|
||||
issuedAtMs: request.nowMs,
|
||||
expiresAtMs,
|
||||
});
|
||||
return Object.freeze({
|
||||
projectId: request.projectId,
|
||||
challengeId,
|
||||
token,
|
||||
expiresAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
async claim(request: ClaimProjectOwnerBootstrapRequest) {
|
||||
if (!request || typeof request !== 'object' || Array.isArray(request)) {
|
||||
throw new TypeError(
|
||||
'Project owner bootstrap claim request must be an object',
|
||||
);
|
||||
}
|
||||
assertExactRequestKeys(request, [
|
||||
'projectId',
|
||||
'challengeId',
|
||||
'token',
|
||||
'principal',
|
||||
'nowMs',
|
||||
]);
|
||||
assertProjectPolicyProjectId(request.projectId);
|
||||
assertProjectOwnerBootstrapChallengeId(request.challengeId);
|
||||
assertProjectOwnerBootstrapToken(request.token);
|
||||
const principal = normalizeAuthenticatedPrincipal(request.principal);
|
||||
assertAuthenticatedPrincipalActive(principal, request.nowMs);
|
||||
if (principal.subject.type !== 'user') {
|
||||
throw new ProjectOwnerBootstrapUnauthorizedError();
|
||||
}
|
||||
return this.repository.claim({
|
||||
projectId: request.projectId,
|
||||
challengeId: request.challengeId,
|
||||
tokenDigest: digestProjectOwnerBootstrapToken(
|
||||
request.projectId,
|
||||
request.challengeId,
|
||||
request.token,
|
||||
),
|
||||
subject: principal.subject,
|
||||
claimedAtMs: request.nowMs,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import {
|
||||
assertProjectPolicyProjectId,
|
||||
normalizePolicySubject,
|
||||
normalizeProjectPermission,
|
||||
normalizeProjectPolicySnapshot,
|
||||
ProjectPolicyUnavailableError,
|
||||
type ProjectPermission,
|
||||
type ProjectPolicyDecision,
|
||||
type ProjectPolicyDecisionWithFence,
|
||||
type ProjectPolicyRequest,
|
||||
type ProjectRole,
|
||||
type StaticProjectPermission,
|
||||
} from '../domain/projectPolicy';
|
||||
import type { ProjectPolicyRepository } from '../ports/projectPolicyRepository';
|
||||
|
||||
const READ_ONLY_PERMISSIONS = new Set<ProjectPermission>([
|
||||
'project.read',
|
||||
'task.read',
|
||||
'run.read',
|
||||
'artifact.read',
|
||||
]);
|
||||
|
||||
const OPERATOR_PERMISSIONS = new Set<ProjectPermission>([
|
||||
...READ_ONLY_PERMISSIONS,
|
||||
'task.create',
|
||||
'task.update',
|
||||
'run.start',
|
||||
'run.stop',
|
||||
'run.retry',
|
||||
'secret.use',
|
||||
]);
|
||||
|
||||
const ADMIN_EXCLUDED_PERMISSIONS = new Set<StaticProjectPermission>([
|
||||
'project.manage',
|
||||
]);
|
||||
|
||||
const AGENT_APPROVAL_PERMISSIONS = new Set<ProjectPermission>([
|
||||
'project.manage',
|
||||
'task.create',
|
||||
'task.update',
|
||||
'task.delete',
|
||||
'run.start',
|
||||
'run.stop',
|
||||
'run.retry',
|
||||
'secret.use',
|
||||
'secret.manage',
|
||||
'worker.manage',
|
||||
'policy.manage',
|
||||
'approval.decide',
|
||||
]);
|
||||
|
||||
function decision(
|
||||
effect: ProjectPolicyDecision['effect'],
|
||||
reason: string,
|
||||
): Readonly<ProjectPolicyDecision> {
|
||||
return Object.freeze({ effect, reasons: Object.freeze([reason]) });
|
||||
}
|
||||
|
||||
function roleAllows(role: ProjectRole, permission: ProjectPermission): boolean {
|
||||
if (role === 'owner') return true;
|
||||
if (role === 'admin') {
|
||||
return (
|
||||
permission.startsWith('tool.call:') ||
|
||||
!ADMIN_EXCLUDED_PERMISSIONS.has(permission as StaticProjectPermission)
|
||||
);
|
||||
}
|
||||
if (role === 'operator') {
|
||||
return (
|
||||
permission.startsWith('tool.call:') ||
|
||||
OPERATOR_PERMISSIONS.has(permission)
|
||||
);
|
||||
}
|
||||
return READ_ONLY_PERMISSIONS.has(permission);
|
||||
}
|
||||
|
||||
function agentRequiresApproval(permission: ProjectPermission): boolean {
|
||||
return (
|
||||
permission.startsWith('tool.call:') ||
|
||||
AGENT_APPROVAL_PERMISSIONS.has(permission)
|
||||
);
|
||||
}
|
||||
|
||||
export class ProjectPolicyEngine {
|
||||
constructor(private readonly repository: ProjectPolicyRepository) {}
|
||||
|
||||
async decide(
|
||||
request: ProjectPolicyRequest,
|
||||
): Promise<Readonly<ProjectPolicyDecision>> {
|
||||
return (await this.decideWithFence(request)).decision;
|
||||
}
|
||||
|
||||
async decideWithFence(
|
||||
request: ProjectPolicyRequest,
|
||||
): Promise<Readonly<ProjectPolicyDecisionWithFence>> {
|
||||
if (!request || typeof request !== 'object' || Array.isArray(request)) {
|
||||
throw new TypeError('Project policy request must be an object');
|
||||
}
|
||||
const requestKeys = Object.keys(request).sort();
|
||||
if (
|
||||
requestKeys.length !== 3 ||
|
||||
requestKeys[0] !== 'permission' ||
|
||||
requestKeys[1] !== 'projectId' ||
|
||||
requestKeys[2] !== 'subject'
|
||||
) {
|
||||
throw new TypeError('Project policy request shape is invalid');
|
||||
}
|
||||
const subject = normalizePolicySubject(request.subject);
|
||||
assertProjectPolicyProjectId(request.projectId);
|
||||
const permission = normalizeProjectPermission(request.permission);
|
||||
let resolved;
|
||||
try {
|
||||
resolved = await this.repository.resolve(request.projectId, subject);
|
||||
} catch {
|
||||
throw new ProjectPolicyUnavailableError();
|
||||
}
|
||||
if (!resolved) {
|
||||
return Object.freeze({
|
||||
decision: decision('deny', 'project_not_found'),
|
||||
fence: null,
|
||||
});
|
||||
}
|
||||
let snapshot;
|
||||
try {
|
||||
snapshot = normalizeProjectPolicySnapshot(resolved);
|
||||
} catch {
|
||||
throw new ProjectPolicyUnavailableError();
|
||||
}
|
||||
if (snapshot.project.id !== request.projectId) {
|
||||
throw new ProjectPolicyUnavailableError();
|
||||
}
|
||||
const fence = Object.freeze({
|
||||
projectVersion: snapshot.project.version,
|
||||
bindingVersion: snapshot.binding?.version ?? null,
|
||||
});
|
||||
if (!snapshot.binding || snapshot.binding.state === 'revoked') {
|
||||
return Object.freeze({
|
||||
decision: decision('deny', 'subject_unbound'),
|
||||
fence,
|
||||
});
|
||||
}
|
||||
if (
|
||||
snapshot.binding.subject.type !== subject.type ||
|
||||
snapshot.binding.subject.id !== subject.id
|
||||
) {
|
||||
throw new ProjectPolicyUnavailableError();
|
||||
}
|
||||
if (
|
||||
snapshot.project.status === 'archived' &&
|
||||
!READ_ONLY_PERMISSIONS.has(permission)
|
||||
) {
|
||||
return Object.freeze({
|
||||
decision: decision('deny', 'project_archived'),
|
||||
fence,
|
||||
});
|
||||
}
|
||||
if (!roleAllows(snapshot.binding.role!, permission)) {
|
||||
return Object.freeze({
|
||||
decision: decision('deny', 'permission_missing'),
|
||||
fence,
|
||||
});
|
||||
}
|
||||
if (subject.type === 'agent' && agentRequiresApproval(permission)) {
|
||||
return Object.freeze({
|
||||
decision: decision(
|
||||
'require_approval',
|
||||
'agent_action_requires_approval',
|
||||
),
|
||||
fence,
|
||||
});
|
||||
}
|
||||
return Object.freeze({
|
||||
decision: decision('allow', 'role_grant'),
|
||||
fence,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { ExecutionContext } from '../domain/execution';
|
||||
import { normalizeExecutionContext } from '../domain/executionContext';
|
||||
import { assertLocalExecutionArtifactId } from '../domain/localExecutionArtifact';
|
||||
import {
|
||||
assertLocalExecutionContextRef,
|
||||
normalizeLocalExecutionContextRecipe,
|
||||
} from '../domain/localExecutionContextRecipe';
|
||||
import { assertRunDispatchCandidate } from '../domain/runDispatchCandidate';
|
||||
import type { LocalExecutionArtifactAllocator } from '../ports/localExecutionArtifactAllocator';
|
||||
import type { LocalExecutionContextMaterializer } from '../ports/localExecutionContextMaterializer';
|
||||
import type { LocalExecutionContextRecipeSource } from '../ports/localExecutionContextRecipeSource';
|
||||
import type { LocalSecretEnvironmentProvider } from '../ports/localSecretEnvironmentProvider';
|
||||
|
||||
const VALIDATION_OUTPUT = Object.freeze({ async write() {} });
|
||||
|
||||
/** Resolves public values, ephemeral Secrets and an Attempt-scoped Artifact. */
|
||||
export class RecipeLocalExecutionContextMaterializer
|
||||
implements LocalExecutionContextMaterializer
|
||||
{
|
||||
constructor(
|
||||
private readonly recipes: LocalExecutionContextRecipeSource,
|
||||
private readonly artifacts: LocalExecutionArtifactAllocator,
|
||||
private readonly secrets?: LocalSecretEnvironmentProvider,
|
||||
) {}
|
||||
|
||||
async prepare(
|
||||
request: Parameters<LocalExecutionContextMaterializer['prepare']>[0],
|
||||
): ReturnType<LocalExecutionContextMaterializer['prepare']> {
|
||||
if (!request || typeof request !== 'object' || Array.isArray(request)) {
|
||||
throw new TypeError('Local execution context request must be an object');
|
||||
}
|
||||
assertRunDispatchCandidate(request.candidate);
|
||||
assertLocalExecutionContextRef(request.contextRef);
|
||||
const recipe = await this.recipes.resolve(request.contextRef);
|
||||
if (!recipe) return null;
|
||||
const normalized = normalizeLocalExecutionContextRecipe(recipe);
|
||||
if (normalized.contextRef !== request.contextRef) {
|
||||
throw new TypeError('Local context recipe does not match contextRef');
|
||||
}
|
||||
|
||||
const secretRefs = [
|
||||
...new Set(
|
||||
normalized.environment.flatMap((binding) =>
|
||||
binding.kind === 'secret' ? [binding.secretRef] : [],
|
||||
),
|
||||
),
|
||||
];
|
||||
let secretValues: readonly string[] = [];
|
||||
if (secretRefs.length > 0) {
|
||||
if (!this.secrets) return null;
|
||||
const resolved = await this.secrets.resolve(
|
||||
Object.freeze({
|
||||
candidate: Object.freeze({ ...request.candidate }),
|
||||
secretRefs: Object.freeze([...secretRefs]),
|
||||
}),
|
||||
);
|
||||
if (!resolved) return null;
|
||||
if (resolved.length !== secretRefs.length) {
|
||||
throw new TypeError('Local Secret provider returned an invalid result');
|
||||
}
|
||||
secretValues = resolved;
|
||||
}
|
||||
const byRef = new Map(
|
||||
secretRefs.map((secretRef, index) => [secretRef, secretValues[index]]),
|
||||
);
|
||||
const environment: Record<string, string> = Object.create(null);
|
||||
for (const binding of normalized.environment) {
|
||||
environment[binding.name] =
|
||||
binding.kind === 'public'
|
||||
? binding.value
|
||||
: (byRef.get(binding.secretRef) as string);
|
||||
}
|
||||
const validatedEnvironment = normalizeExecutionContext({
|
||||
environment,
|
||||
output: VALIDATION_OUTPUT,
|
||||
}).environment;
|
||||
|
||||
const artifact = await this.artifacts.prepare(request.candidate);
|
||||
try {
|
||||
assertLocalExecutionArtifactId(artifact.logArtifactId);
|
||||
const context: ExecutionContext = normalizeExecutionContext({
|
||||
environment: validatedEnvironment,
|
||||
output: artifact.output,
|
||||
});
|
||||
return {
|
||||
context,
|
||||
logArtifactId: artifact.logArtifactId,
|
||||
dispose: () => artifact.dispose(),
|
||||
};
|
||||
} catch (error) {
|
||||
await Promise.resolve(artifact.dispose()).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,566 @@
|
||||
import { v7 as uuidV7 } from 'uuid';
|
||||
import type {
|
||||
RunAttemptRecord,
|
||||
RunAttemptStatus,
|
||||
RunEventRecord,
|
||||
RunRecord,
|
||||
RunStatus,
|
||||
} from '../domain/run';
|
||||
import {
|
||||
assertRunDispatchId,
|
||||
assertRunDispatchLeaseToken,
|
||||
assertRunDispatchLeaseVersion,
|
||||
assertRunDispatchWorkerFence,
|
||||
type RunDispatchLeaseRecord,
|
||||
} from '../domain/runDispatchLease';
|
||||
import {
|
||||
MAX_EXECUTOR_HANDLE_LENGTH,
|
||||
MAX_LOG_ARTIFACT_ID_LENGTH,
|
||||
transitionRun,
|
||||
transitionRunAttempt,
|
||||
type RunDomainEventDraft,
|
||||
} from '../domain/runStateMachine';
|
||||
import type { RunDispatchLeaseRepository } from '../ports/runDispatchLeaseRepository';
|
||||
import type { RunRepositoryTransaction } from '../ports/runRepository';
|
||||
import type { AuthenticatedWorkerPrincipal } from './workerControlService';
|
||||
import { WorkerPrincipalMismatchError } from './workerControlService';
|
||||
|
||||
export interface RemoteRunLeaseFence {
|
||||
runId: string;
|
||||
attemptId: string;
|
||||
workerId: string;
|
||||
workerSessionId: string;
|
||||
workerGeneration: number;
|
||||
leaseGeneration: number;
|
||||
leaseToken: string;
|
||||
expectedLeaseVersion: number;
|
||||
executorType: string;
|
||||
}
|
||||
|
||||
export interface AcknowledgeRemoteRunStartingCommand
|
||||
extends RemoteRunLeaseFence {}
|
||||
|
||||
export interface AcknowledgeRemoteRunRunningCommand
|
||||
extends RemoteRunLeaseFence {
|
||||
startedAtMs: number;
|
||||
executorHandle: string;
|
||||
logArtifactId?: string;
|
||||
}
|
||||
|
||||
export interface FailRemoteRunStartCommand extends RemoteRunLeaseFence {}
|
||||
|
||||
export interface RemoteRunActivationResult {
|
||||
status:
|
||||
| 'applied'
|
||||
| 'already_starting'
|
||||
| 'already_running'
|
||||
| 'already_terminal';
|
||||
run: RunRecord;
|
||||
attempt: RunAttemptRecord;
|
||||
lease: RunDispatchLeaseRecord;
|
||||
events: readonly RunEventRecord[];
|
||||
}
|
||||
|
||||
export class RemoteRunActivationNotFoundError extends Error {
|
||||
constructor() {
|
||||
super('Remote Run activation target was not found');
|
||||
this.name = 'RemoteRunActivationNotFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
export class RemoteRunActivationUnauthorizedError extends Error {
|
||||
constructor() {
|
||||
super('Remote Run activation target is not owned by this execution path');
|
||||
this.name = 'RemoteRunActivationUnauthorizedError';
|
||||
}
|
||||
}
|
||||
|
||||
export class RemoteRunActivationStateError extends Error {
|
||||
constructor(message = 'Remote Run activation state is inconsistent') {
|
||||
super(message);
|
||||
this.name = 'RemoteRunActivationStateError';
|
||||
}
|
||||
}
|
||||
|
||||
class RemoteRunActivationConcurrentWriteError extends Error {}
|
||||
|
||||
interface StartFailureMapping {
|
||||
attemptStatus: Extract<
|
||||
RunAttemptStatus,
|
||||
'failed' | 'cancelled' | 'timed_out'
|
||||
>;
|
||||
runStatus: Extract<RunStatus, 'failed' | 'cancelled' | 'timed_out'>;
|
||||
errorCode:
|
||||
| 'EXECUTOR_START_FAILED'
|
||||
| 'EXECUTION_CANCELLED'
|
||||
| 'EXECUTION_TIMED_OUT';
|
||||
errorSummary: string;
|
||||
}
|
||||
|
||||
function assertExecutorType(value: string): void {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length < 1 ||
|
||||
value.length > 64 ||
|
||||
/[\u0000-\u001f\u007f]/.test(value)
|
||||
) {
|
||||
throw new TypeError('Remote Run executorType is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
function startFailureMapping(run: RunRecord): StartFailureMapping {
|
||||
if (run.cancelRequestedAtMs !== undefined) {
|
||||
if (run.cancelReason === 'timeout') {
|
||||
return {
|
||||
attemptStatus: 'timed_out',
|
||||
runStatus: 'timed_out',
|
||||
errorCode: 'EXECUTION_TIMED_OUT',
|
||||
errorSummary: 'Execution timed out before the executor started',
|
||||
};
|
||||
}
|
||||
return {
|
||||
attemptStatus: 'cancelled',
|
||||
runStatus: 'cancelled',
|
||||
errorCode: 'EXECUTION_CANCELLED',
|
||||
errorSummary: 'Execution was cancelled before the executor started',
|
||||
};
|
||||
}
|
||||
return {
|
||||
attemptStatus: 'failed',
|
||||
runStatus: 'failed',
|
||||
errorCode: 'EXECUTOR_START_FAILED',
|
||||
errorSummary: 'Executor failed before execution ownership was established',
|
||||
};
|
||||
}
|
||||
|
||||
export class RemoteRunActivationService {
|
||||
private readonly clock: { now(): number };
|
||||
private readonly createEventId: () => string;
|
||||
|
||||
constructor(
|
||||
private readonly leases: RunDispatchLeaseRepository,
|
||||
options: { clock?: { now(): number }; createEventId?: () => string } = {},
|
||||
) {
|
||||
this.clock = options.clock ?? Date;
|
||||
this.createEventId = options.createEventId ?? uuidV7;
|
||||
}
|
||||
|
||||
async acknowledgeStarting(
|
||||
principal: AuthenticatedWorkerPrincipal,
|
||||
command: AcknowledgeRemoteRunStartingCommand,
|
||||
): Promise<RemoteRunActivationResult> {
|
||||
this.assertCommand(principal, command);
|
||||
const observedAtMs = this.now();
|
||||
const eventId = this.createEventId();
|
||||
assertRunDispatchId('eventId', eventId);
|
||||
const result = await this.leases.withLease(
|
||||
this.useLeaseCommand(command, observedAtMs),
|
||||
async (transaction, lease) => {
|
||||
const { run, attempt } = await this.loadTarget(transaction, command);
|
||||
if (attempt.status === 'starting') {
|
||||
if (
|
||||
run.status !== 'dispatching' ||
|
||||
attempt.workerId !== command.workerId
|
||||
) {
|
||||
throw new RemoteRunActivationStateError();
|
||||
}
|
||||
return {
|
||||
status: 'already_starting' as const,
|
||||
run,
|
||||
attempt,
|
||||
events: [] as RunEventRecord[],
|
||||
};
|
||||
}
|
||||
if (attempt.status === 'running') {
|
||||
if (
|
||||
run.status !== 'running' ||
|
||||
attempt.workerId !== command.workerId
|
||||
) {
|
||||
throw new RemoteRunActivationStateError();
|
||||
}
|
||||
return {
|
||||
status: 'already_running' as const,
|
||||
run,
|
||||
attempt,
|
||||
events: [] as RunEventRecord[],
|
||||
};
|
||||
}
|
||||
if (attempt.status !== 'claimed' || run.status !== 'dispatching') {
|
||||
throw new RemoteRunActivationStateError(
|
||||
'Remote Run must be claimed and dispatching before start acknowledgement',
|
||||
);
|
||||
}
|
||||
const atMs = Math.max(
|
||||
observedAtMs,
|
||||
run.createdAtMs,
|
||||
attempt.createdAtMs,
|
||||
);
|
||||
const decision = transitionRunAttempt(run, attempt, {
|
||||
to: 'starting',
|
||||
expectedRunVersion: run.version,
|
||||
atMs,
|
||||
});
|
||||
const activatedAttempt: RunAttemptRecord = {
|
||||
...decision.attempt,
|
||||
workerId: command.workerId,
|
||||
};
|
||||
await this.persistAttemptTransition(
|
||||
transaction,
|
||||
run,
|
||||
attempt,
|
||||
decision.run,
|
||||
activatedAttempt,
|
||||
);
|
||||
const event = this.event(
|
||||
eventId,
|
||||
decision.run,
|
||||
decision.event,
|
||||
attempt.id,
|
||||
lease,
|
||||
command.workerId,
|
||||
'starting',
|
||||
atMs,
|
||||
);
|
||||
await transaction.appendEvent(event);
|
||||
return {
|
||||
status: 'applied' as const,
|
||||
run: decision.run,
|
||||
attempt: activatedAttempt,
|
||||
events: [event],
|
||||
};
|
||||
},
|
||||
);
|
||||
return { ...result.value, lease: result.lease };
|
||||
}
|
||||
|
||||
async acknowledgeRunning(
|
||||
principal: AuthenticatedWorkerPrincipal,
|
||||
command: AcknowledgeRemoteRunRunningCommand,
|
||||
): Promise<RemoteRunActivationResult> {
|
||||
this.assertCommand(principal, command);
|
||||
assertRunDispatchLeaseVersion('startedAtMs', command.startedAtMs);
|
||||
if (
|
||||
typeof command.executorHandle !== 'string' ||
|
||||
command.executorHandle.length < 1 ||
|
||||
command.executorHandle.length > MAX_EXECUTOR_HANDLE_LENGTH
|
||||
) {
|
||||
throw new TypeError('Remote Run executorHandle is invalid');
|
||||
}
|
||||
if (
|
||||
command.logArtifactId !== undefined &&
|
||||
(typeof command.logArtifactId !== 'string' ||
|
||||
command.logArtifactId.length < 1 ||
|
||||
command.logArtifactId.length > MAX_LOG_ARTIFACT_ID_LENGTH)
|
||||
) {
|
||||
throw new TypeError('Remote Run logArtifactId is invalid');
|
||||
}
|
||||
const observedAtMs = this.now();
|
||||
if (command.startedAtMs > observedAtMs) {
|
||||
throw new TypeError('Remote Run cannot start in the future');
|
||||
}
|
||||
const attemptEventId = this.createEventId();
|
||||
const runEventId = this.createEventId();
|
||||
assertRunDispatchId('attemptEventId', attemptEventId);
|
||||
assertRunDispatchId('runEventId', runEventId);
|
||||
const result = await this.leases.withLease(
|
||||
this.useLeaseCommand(command, observedAtMs),
|
||||
async (transaction, lease) => {
|
||||
const { run, attempt } = await this.loadTarget(transaction, command);
|
||||
if (attempt.status === 'running') {
|
||||
if (
|
||||
run.status !== 'running' ||
|
||||
attempt.workerId !== command.workerId ||
|
||||
attempt.executorHandle !== command.executorHandle ||
|
||||
attempt.logArtifactId !== command.logArtifactId
|
||||
) {
|
||||
throw new RemoteRunActivationStateError(
|
||||
'Remote Run running acknowledgement metadata does not match',
|
||||
);
|
||||
}
|
||||
return {
|
||||
status: 'already_running' as const,
|
||||
run,
|
||||
attempt,
|
||||
events: [] as RunEventRecord[],
|
||||
};
|
||||
}
|
||||
if (attempt.status !== 'starting' || run.status !== 'dispatching') {
|
||||
throw new RemoteRunActivationStateError(
|
||||
'Remote Run must be starting before running acknowledgement',
|
||||
);
|
||||
}
|
||||
const atMs = Math.max(
|
||||
command.startedAtMs,
|
||||
run.createdAtMs,
|
||||
attempt.createdAtMs,
|
||||
);
|
||||
const attemptDecision = transitionRunAttempt(run, attempt, {
|
||||
to: 'running',
|
||||
expectedRunVersion: run.version,
|
||||
atMs,
|
||||
executorHandle: command.executorHandle,
|
||||
...(command.logArtifactId === undefined
|
||||
? {}
|
||||
: { logArtifactId: command.logArtifactId }),
|
||||
});
|
||||
const runDecision = transitionRun(attemptDecision.run, {
|
||||
to: 'running',
|
||||
expectedVersion: attemptDecision.run.version,
|
||||
atMs,
|
||||
});
|
||||
await this.persistAttemptTransition(
|
||||
transaction,
|
||||
run,
|
||||
attempt,
|
||||
attemptDecision.run,
|
||||
attemptDecision.attempt,
|
||||
);
|
||||
const attemptEvent = this.event(
|
||||
attemptEventId,
|
||||
attemptDecision.run,
|
||||
attemptDecision.event,
|
||||
attempt.id,
|
||||
lease,
|
||||
command.workerId,
|
||||
'running-attempt',
|
||||
atMs,
|
||||
);
|
||||
await transaction.appendEvent(attemptEvent);
|
||||
if (
|
||||
!(await transaction.compareAndSetRun(
|
||||
runDecision.run,
|
||||
attemptDecision.run.version,
|
||||
))
|
||||
) {
|
||||
throw new RemoteRunActivationConcurrentWriteError();
|
||||
}
|
||||
const runEvent = this.event(
|
||||
runEventId,
|
||||
runDecision.run,
|
||||
runDecision.event,
|
||||
attempt.id,
|
||||
lease,
|
||||
command.workerId,
|
||||
'running-run',
|
||||
atMs,
|
||||
);
|
||||
await transaction.appendEvent(runEvent);
|
||||
return {
|
||||
status: 'applied' as const,
|
||||
run: runDecision.run,
|
||||
attempt: attemptDecision.attempt,
|
||||
events: [attemptEvent, runEvent],
|
||||
};
|
||||
},
|
||||
);
|
||||
return { ...result.value, lease: result.lease };
|
||||
}
|
||||
|
||||
async failStart(
|
||||
principal: AuthenticatedWorkerPrincipal,
|
||||
command: FailRemoteRunStartCommand,
|
||||
): Promise<RemoteRunActivationResult> {
|
||||
this.assertCommand(principal, command);
|
||||
const failedAtMs = this.now();
|
||||
const attemptEventId = this.createEventId();
|
||||
const runEventId = this.createEventId();
|
||||
assertRunDispatchId('attemptEventId', attemptEventId);
|
||||
assertRunDispatchId('runEventId', runEventId);
|
||||
const result = await this.leases.completeWithLease(
|
||||
{
|
||||
...this.useLeaseCommand(command, failedAtMs),
|
||||
completedAtMs: failedAtMs,
|
||||
},
|
||||
async (transaction, lease) => {
|
||||
const { run, attempt } = await this.loadTarget(transaction, command);
|
||||
const mapping = startFailureMapping(run);
|
||||
if (
|
||||
attempt.status === mapping.attemptStatus &&
|
||||
run.status === mapping.runStatus &&
|
||||
attempt.errorCode === mapping.errorCode &&
|
||||
run.errorCode === mapping.errorCode
|
||||
) {
|
||||
return {
|
||||
status: 'already_terminal' as const,
|
||||
run,
|
||||
attempt,
|
||||
events: [] as RunEventRecord[],
|
||||
};
|
||||
}
|
||||
if (attempt.status !== 'starting' || run.status !== 'dispatching') {
|
||||
throw new RemoteRunActivationStateError(
|
||||
'Only a starting Remote Run can report executor start failure',
|
||||
);
|
||||
}
|
||||
const atMs = Math.max(failedAtMs, run.createdAtMs, attempt.createdAtMs);
|
||||
const attemptDecision = transitionRunAttempt(run, attempt, {
|
||||
to: mapping.attemptStatus,
|
||||
expectedRunVersion: run.version,
|
||||
atMs,
|
||||
callbackSequence: attempt.callbackSequence + 1,
|
||||
errorCode: mapping.errorCode,
|
||||
errorSummary: mapping.errorSummary,
|
||||
});
|
||||
const runDecision = transitionRun(attemptDecision.run, {
|
||||
to: mapping.runStatus,
|
||||
expectedVersion: attemptDecision.run.version,
|
||||
atMs,
|
||||
errorCode: mapping.errorCode,
|
||||
errorSummary: mapping.errorSummary,
|
||||
});
|
||||
await this.persistAttemptTransition(
|
||||
transaction,
|
||||
run,
|
||||
attempt,
|
||||
attemptDecision.run,
|
||||
attemptDecision.attempt,
|
||||
);
|
||||
const attemptEvent = this.event(
|
||||
attemptEventId,
|
||||
attemptDecision.run,
|
||||
attemptDecision.event,
|
||||
attempt.id,
|
||||
lease,
|
||||
command.workerId,
|
||||
'start-failed-attempt',
|
||||
atMs,
|
||||
);
|
||||
await transaction.appendEvent(attemptEvent);
|
||||
if (
|
||||
!(await transaction.compareAndSetRun(
|
||||
runDecision.run,
|
||||
attemptDecision.run.version,
|
||||
))
|
||||
) {
|
||||
throw new RemoteRunActivationConcurrentWriteError();
|
||||
}
|
||||
const runEvent = this.event(
|
||||
runEventId,
|
||||
runDecision.run,
|
||||
runDecision.event,
|
||||
attempt.id,
|
||||
lease,
|
||||
command.workerId,
|
||||
'start-failed-run',
|
||||
atMs,
|
||||
);
|
||||
await transaction.appendEvent(runEvent);
|
||||
return {
|
||||
status: 'applied' as const,
|
||||
run: runDecision.run,
|
||||
attempt: attemptDecision.attempt,
|
||||
events: [attemptEvent, runEvent],
|
||||
};
|
||||
},
|
||||
);
|
||||
return { ...result.value, lease: result.lease };
|
||||
}
|
||||
|
||||
private useLeaseCommand(command: RemoteRunLeaseFence, observedAtMs: number) {
|
||||
return {
|
||||
runId: command.runId,
|
||||
attemptId: command.attemptId,
|
||||
workerId: command.workerId,
|
||||
workerSessionId: command.workerSessionId,
|
||||
workerGeneration: command.workerGeneration,
|
||||
leaseGeneration: command.leaseGeneration,
|
||||
leaseToken: command.leaseToken,
|
||||
expectedVersion: command.expectedLeaseVersion,
|
||||
observedAtMs,
|
||||
};
|
||||
}
|
||||
|
||||
private async loadTarget(
|
||||
transaction: RunRepositoryTransaction,
|
||||
command: RemoteRunLeaseFence,
|
||||
): Promise<{ run: RunRecord; attempt: RunAttemptRecord }> {
|
||||
const [run, attempt] = await Promise.all([
|
||||
transaction.findRunById(command.runId),
|
||||
transaction.findAttemptById(command.attemptId),
|
||||
]);
|
||||
if (!run || !attempt || attempt.runId !== run.id) {
|
||||
throw new RemoteRunActivationNotFoundError();
|
||||
}
|
||||
if (
|
||||
run.executionOwner !== 'runtime' ||
|
||||
attempt.executorType !== command.executorType
|
||||
) {
|
||||
throw new RemoteRunActivationUnauthorizedError();
|
||||
}
|
||||
return { run, attempt };
|
||||
}
|
||||
|
||||
private async persistAttemptTransition(
|
||||
transaction: RunRepositoryTransaction,
|
||||
previousRun: RunRecord,
|
||||
previousAttempt: RunAttemptRecord,
|
||||
run: RunRecord,
|
||||
attempt: RunAttemptRecord,
|
||||
): Promise<void> {
|
||||
if (
|
||||
!(await transaction.compareAndSetRun(run, previousRun.version)) ||
|
||||
!(await transaction.compareAndSetAttempt(attempt, {
|
||||
status: previousAttempt.status,
|
||||
callbackSequence: previousAttempt.callbackSequence,
|
||||
}))
|
||||
) {
|
||||
throw new RemoteRunActivationConcurrentWriteError();
|
||||
}
|
||||
}
|
||||
|
||||
private event(
|
||||
id: string,
|
||||
run: RunRecord,
|
||||
draft: RunDomainEventDraft,
|
||||
attemptId: string,
|
||||
lease: RunDispatchLeaseRecord,
|
||||
workerId: string,
|
||||
phase: string,
|
||||
createdAtMs: number,
|
||||
): RunEventRecord {
|
||||
return {
|
||||
id,
|
||||
runId: run.id,
|
||||
sequence: draft.sequence,
|
||||
type: draft.type,
|
||||
dedupeKey: `remote-activation:${attemptId}:${lease.leaseGeneration}:${phase}`,
|
||||
actorType: 'worker',
|
||||
actorId: workerId,
|
||||
attemptId,
|
||||
payload: {
|
||||
...draft.payload,
|
||||
lease_generation: lease.leaseGeneration,
|
||||
},
|
||||
createdAtMs,
|
||||
};
|
||||
}
|
||||
|
||||
private assertCommand(
|
||||
principal: AuthenticatedWorkerPrincipal,
|
||||
command: RemoteRunLeaseFence,
|
||||
): void {
|
||||
if (principal.workerId !== command.workerId) {
|
||||
throw new WorkerPrincipalMismatchError();
|
||||
}
|
||||
assertRunDispatchId('runId', command.runId);
|
||||
assertRunDispatchId('attemptId', command.attemptId);
|
||||
assertRunDispatchWorkerFence(command);
|
||||
assertRunDispatchLeaseToken(command.leaseToken);
|
||||
assertRunDispatchLeaseVersion(
|
||||
'leaseGeneration',
|
||||
command.leaseGeneration,
|
||||
true,
|
||||
);
|
||||
assertRunDispatchLeaseVersion(
|
||||
'expectedLeaseVersion',
|
||||
command.expectedLeaseVersion,
|
||||
);
|
||||
assertExecutorType(command.executorType);
|
||||
}
|
||||
|
||||
private now(): number {
|
||||
const nowMs = this.clock.now();
|
||||
assertRunDispatchLeaseVersion('observedAtMs', nowMs);
|
||||
return nowMs;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import { v7 as uuidV7 } from 'uuid';
|
||||
import type { ExecutionResult } from '../domain/execution';
|
||||
import type { RunEventRecord, RunRecord } from '../domain/run';
|
||||
import {
|
||||
assertRunDispatchId,
|
||||
assertRunDispatchLeaseToken,
|
||||
assertRunDispatchLeaseVersion,
|
||||
assertRunDispatchWorkerFence,
|
||||
} from '../domain/runDispatchLease';
|
||||
import type { RunDispatchLeaseRepository } from '../ports/runDispatchLeaseRepository';
|
||||
import type {
|
||||
RunRepository,
|
||||
RunRepositoryTransaction,
|
||||
} from '../ports/runRepository';
|
||||
import {
|
||||
PrimaryRunCompletionService,
|
||||
type PrimaryRunCompletionResult,
|
||||
} from './primaryRunCompletionService';
|
||||
import type { AuthenticatedWorkerPrincipal } from './workerControlService';
|
||||
import { WorkerPrincipalMismatchError } from './workerControlService';
|
||||
|
||||
class WorkerAttributedRunTransaction implements RunRepositoryTransaction {
|
||||
constructor(
|
||||
private readonly transaction: RunRepositoryTransaction,
|
||||
private readonly workerId: string,
|
||||
) {}
|
||||
|
||||
findRunById(runId: string) {
|
||||
return this.transaction.findRunById(runId);
|
||||
}
|
||||
|
||||
findAttemptById(attemptId: string) {
|
||||
return this.transaction.findAttemptById(attemptId);
|
||||
}
|
||||
|
||||
findLatestAttemptByRunId(runId: string) {
|
||||
return this.transaction.findLatestAttemptByRunId(runId);
|
||||
}
|
||||
|
||||
findRetryPolicyByRunId(runId: string) {
|
||||
return this.transaction.findRetryPolicyByRunId(runId);
|
||||
}
|
||||
|
||||
listEvents(
|
||||
runId: string,
|
||||
options?: { afterSequence?: number; limit?: number },
|
||||
) {
|
||||
return this.transaction.listEvents(runId, options);
|
||||
}
|
||||
|
||||
listCancellationRequested(options?: { beforeMs?: number; limit?: number }) {
|
||||
return this.transaction.listCancellationRequested(options);
|
||||
}
|
||||
|
||||
insertRun(run: RunRecord) {
|
||||
return this.transaction.insertRun(run);
|
||||
}
|
||||
|
||||
insertAttempt(
|
||||
attempt: Parameters<RunRepositoryTransaction['insertAttempt']>[0],
|
||||
) {
|
||||
return this.transaction.insertAttempt(attempt);
|
||||
}
|
||||
|
||||
insertRetryPolicy(
|
||||
_policy: Parameters<RunRepositoryTransaction['insertRetryPolicy']>[0],
|
||||
): Promise<void> {
|
||||
throw new Error('Worker completion cannot create a Run retry policy');
|
||||
}
|
||||
|
||||
compareAndSetRun(run: RunRecord, expectedVersion: number): Promise<boolean> {
|
||||
return this.transaction.compareAndSetRun(run, expectedVersion);
|
||||
}
|
||||
|
||||
compareAndSetAttempt(
|
||||
attempt: Parameters<RunRepositoryTransaction['compareAndSetAttempt']>[0],
|
||||
expected: Parameters<RunRepositoryTransaction['compareAndSetAttempt']>[1],
|
||||
): Promise<boolean> {
|
||||
return this.transaction.compareAndSetAttempt(attempt, expected);
|
||||
}
|
||||
|
||||
compareAndSetRetryPolicy(
|
||||
_policy: Parameters<
|
||||
RunRepositoryTransaction['compareAndSetRetryPolicy']
|
||||
>[0],
|
||||
_expectedVersion: number,
|
||||
): Promise<boolean> {
|
||||
throw new Error('Worker completion cannot modify a Run retry policy');
|
||||
}
|
||||
|
||||
appendEvent(event: RunEventRecord): Promise<void> {
|
||||
return this.transaction.appendEvent({
|
||||
...event,
|
||||
actorType: 'worker',
|
||||
actorId: this.workerId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export interface RemoteRunCompletionCommand {
|
||||
runId: string;
|
||||
attemptId: string;
|
||||
callbackSequence: number;
|
||||
result: ExecutionResult;
|
||||
executorType: string;
|
||||
workerId: string;
|
||||
workerSessionId: string;
|
||||
workerGeneration: number;
|
||||
leaseGeneration: number;
|
||||
leaseToken: string;
|
||||
expectedLeaseVersion: number;
|
||||
}
|
||||
|
||||
class LeaseFencedRunRepository implements RunRepository {
|
||||
constructor(
|
||||
private readonly reader: RunRepository,
|
||||
private readonly leases: RunDispatchLeaseRepository,
|
||||
private readonly command: RemoteRunCompletionCommand,
|
||||
private readonly completedAtMs: number,
|
||||
) {}
|
||||
|
||||
findRunById(runId: string) {
|
||||
return this.reader.findRunById(runId);
|
||||
}
|
||||
|
||||
findAttemptById(attemptId: string) {
|
||||
return this.reader.findAttemptById(attemptId);
|
||||
}
|
||||
|
||||
findLatestAttemptByRunId(runId: string) {
|
||||
return this.reader.findLatestAttemptByRunId(runId);
|
||||
}
|
||||
|
||||
findRetryPolicyByRunId(runId: string) {
|
||||
return this.reader.findRetryPolicyByRunId(runId);
|
||||
}
|
||||
|
||||
listEvents(
|
||||
runId: string,
|
||||
options?: { afterSequence?: number; limit?: number },
|
||||
): Promise<RunEventRecord[]> {
|
||||
return this.reader.listEvents(runId, options);
|
||||
}
|
||||
|
||||
listCancellationRequested(options?: {
|
||||
beforeMs?: number;
|
||||
limit?: number;
|
||||
}): Promise<RunRecord[]> {
|
||||
return this.reader.listCancellationRequested(options);
|
||||
}
|
||||
|
||||
async transaction<T>(
|
||||
work: (transaction: RunRepositoryTransaction) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const result = await this.leases.completeWithLease(
|
||||
{
|
||||
runId: this.command.runId,
|
||||
attemptId: this.command.attemptId,
|
||||
workerId: this.command.workerId,
|
||||
workerSessionId: this.command.workerSessionId,
|
||||
workerGeneration: this.command.workerGeneration,
|
||||
leaseGeneration: this.command.leaseGeneration,
|
||||
leaseToken: this.command.leaseToken,
|
||||
expectedVersion: this.command.expectedLeaseVersion,
|
||||
completedAtMs: this.completedAtMs,
|
||||
},
|
||||
(transaction) =>
|
||||
work(
|
||||
new WorkerAttributedRunTransaction(
|
||||
transaction,
|
||||
this.command.workerId,
|
||||
),
|
||||
),
|
||||
);
|
||||
return result.value;
|
||||
}
|
||||
}
|
||||
|
||||
export class RemoteRunCompletionService {
|
||||
private readonly clock: { now(): number };
|
||||
private readonly createEventId: () => string;
|
||||
|
||||
constructor(
|
||||
private readonly runs: RunRepository,
|
||||
private readonly leases: RunDispatchLeaseRepository,
|
||||
options: { clock?: { now(): number }; createEventId?: () => string } = {},
|
||||
) {
|
||||
this.clock = options.clock ?? Date;
|
||||
this.createEventId = options.createEventId ?? uuidV7;
|
||||
}
|
||||
|
||||
complete(
|
||||
principal: AuthenticatedWorkerPrincipal,
|
||||
command: RemoteRunCompletionCommand,
|
||||
): Promise<PrimaryRunCompletionResult> {
|
||||
this.assertCommand(principal, command);
|
||||
const completedAtMs = this.clock.now();
|
||||
assertRunDispatchLeaseVersion('completedAtMs', completedAtMs);
|
||||
if (completedAtMs < command.result.finishedAtMs) {
|
||||
throw new TypeError('Remote completion cannot be observed before finish');
|
||||
}
|
||||
const repository = new LeaseFencedRunRepository(
|
||||
this.runs,
|
||||
this.leases,
|
||||
command,
|
||||
completedAtMs,
|
||||
);
|
||||
return new PrimaryRunCompletionService(
|
||||
repository,
|
||||
this.createEventId,
|
||||
).complete({
|
||||
runId: command.runId,
|
||||
attemptId: command.attemptId,
|
||||
callbackSequence: command.callbackSequence,
|
||||
result: command.result,
|
||||
source: {
|
||||
kind: 'executor',
|
||||
executorType: command.executorType,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private assertCommand(
|
||||
principal: AuthenticatedWorkerPrincipal,
|
||||
command: RemoteRunCompletionCommand,
|
||||
): void {
|
||||
if (principal.workerId !== command.workerId) {
|
||||
throw new WorkerPrincipalMismatchError();
|
||||
}
|
||||
assertRunDispatchId('runId', command.runId);
|
||||
assertRunDispatchId('attemptId', command.attemptId);
|
||||
assertRunDispatchWorkerFence(command);
|
||||
assertRunDispatchLeaseToken(command.leaseToken);
|
||||
assertRunDispatchLeaseVersion(
|
||||
'leaseGeneration',
|
||||
command.leaseGeneration,
|
||||
true,
|
||||
);
|
||||
assertRunDispatchLeaseVersion(
|
||||
'expectedLeaseVersion',
|
||||
command.expectedLeaseVersion,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
import { v7 as uuidV7 } from 'uuid';
|
||||
import type {
|
||||
RunAttemptRecord,
|
||||
RunCancellationReason,
|
||||
RunEventActorType,
|
||||
RunEventRecord,
|
||||
RunRecord,
|
||||
} from '../domain/run';
|
||||
import {
|
||||
isTerminalRunAttemptStatus,
|
||||
requestRunCancellation,
|
||||
transitionRun,
|
||||
transitionRunAttempt,
|
||||
type RunAttemptTransitionCommand,
|
||||
type RunTransitionCommand,
|
||||
} from '../domain/runStateMachine';
|
||||
import { RunVersionConflictError } from '../domain/stateMachineErrors';
|
||||
import type { RunRepository } from '../ports/runRepository';
|
||||
import {
|
||||
RunAttemptConcurrentWriteError,
|
||||
RunAttemptNotFoundError,
|
||||
RunNotFoundError,
|
||||
} from './commandErrors';
|
||||
|
||||
export interface RunCommandActor {
|
||||
type: RunEventActorType;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export interface TransitionRunCommand extends RunTransitionCommand {
|
||||
runId: string;
|
||||
actor: RunCommandActor;
|
||||
dedupeKey?: string;
|
||||
}
|
||||
|
||||
export interface TransitionRunAttemptCommand
|
||||
extends RunAttemptTransitionCommand {
|
||||
runId: string;
|
||||
attemptId: string;
|
||||
actor: RunCommandActor;
|
||||
dedupeKey?: string;
|
||||
}
|
||||
|
||||
export interface RunCommandResult {
|
||||
run: RunRecord;
|
||||
event: RunEventRecord;
|
||||
}
|
||||
|
||||
export interface RunAttemptCommandResult extends RunCommandResult {
|
||||
attempt: RunAttemptRecord;
|
||||
}
|
||||
|
||||
export interface RequestRunCancellationCommand {
|
||||
runId: string;
|
||||
attemptId: string;
|
||||
atMs: number;
|
||||
reason: RunCancellationReason;
|
||||
actor: RunCommandActor;
|
||||
dedupeKey?: string;
|
||||
}
|
||||
|
||||
export type RequestRunCancellationResult =
|
||||
| {
|
||||
status: 'accepted';
|
||||
run: RunRecord;
|
||||
attempt: RunAttemptRecord;
|
||||
event: RunEventRecord;
|
||||
}
|
||||
| {
|
||||
status: 'already_requested' | 'already_terminal';
|
||||
run: RunRecord;
|
||||
attempt: RunAttemptRecord;
|
||||
};
|
||||
|
||||
export type RunEventIdFactory = () => string;
|
||||
|
||||
export class RunCommandService {
|
||||
constructor(
|
||||
private readonly repository: RunRepository,
|
||||
private readonly createEventId: RunEventIdFactory = uuidV7,
|
||||
) {}
|
||||
|
||||
async transitionRun(
|
||||
command: TransitionRunCommand,
|
||||
): Promise<RunCommandResult> {
|
||||
return this.repository.transaction(async (transaction) => {
|
||||
const current = await transaction.findRunById(command.runId);
|
||||
if (!current) throw new RunNotFoundError(command.runId);
|
||||
|
||||
const decision = transitionRun(current, command);
|
||||
const updated = await transaction.compareAndSetRun(
|
||||
decision.run,
|
||||
command.expectedVersion,
|
||||
);
|
||||
if (!updated) {
|
||||
const latest = await transaction.findRunById(command.runId);
|
||||
if (!latest) throw new RunNotFoundError(command.runId);
|
||||
throw new RunVersionConflictError(
|
||||
command.runId,
|
||||
command.expectedVersion,
|
||||
latest.version,
|
||||
);
|
||||
}
|
||||
|
||||
const event = this.createRunEvent({
|
||||
runId: command.runId,
|
||||
decision: decision.event,
|
||||
actor: command.actor,
|
||||
dedupeKey:
|
||||
command.dedupeKey ??
|
||||
`run-transition:${command.expectedVersion}:${command.to}`,
|
||||
createdAtMs: command.atMs,
|
||||
});
|
||||
await transaction.appendEvent(event);
|
||||
return { run: decision.run, event };
|
||||
});
|
||||
}
|
||||
|
||||
async transitionRunAttempt(
|
||||
command: TransitionRunAttemptCommand,
|
||||
): Promise<RunAttemptCommandResult> {
|
||||
return this.repository.transaction(async (transaction) => {
|
||||
const currentRun = await transaction.findRunById(command.runId);
|
||||
if (!currentRun) throw new RunNotFoundError(command.runId);
|
||||
|
||||
const currentAttempt = await transaction.findAttemptById(
|
||||
command.attemptId,
|
||||
);
|
||||
if (!currentAttempt) {
|
||||
throw new RunAttemptNotFoundError(command.attemptId);
|
||||
}
|
||||
|
||||
const decision = transitionRunAttempt(
|
||||
currentRun,
|
||||
currentAttempt,
|
||||
command,
|
||||
);
|
||||
const runUpdated = await transaction.compareAndSetRun(
|
||||
decision.run,
|
||||
command.expectedRunVersion,
|
||||
);
|
||||
if (!runUpdated) {
|
||||
const latest = await transaction.findRunById(command.runId);
|
||||
if (!latest) throw new RunNotFoundError(command.runId);
|
||||
throw new RunVersionConflictError(
|
||||
command.runId,
|
||||
command.expectedRunVersion,
|
||||
latest.version,
|
||||
);
|
||||
}
|
||||
|
||||
const attemptUpdated = await transaction.compareAndSetAttempt(
|
||||
decision.attempt,
|
||||
{
|
||||
status: currentAttempt.status,
|
||||
callbackSequence: currentAttempt.callbackSequence,
|
||||
},
|
||||
);
|
||||
if (!attemptUpdated) {
|
||||
throw new RunAttemptConcurrentWriteError(
|
||||
currentAttempt.id,
|
||||
currentAttempt.status,
|
||||
currentAttempt.callbackSequence,
|
||||
);
|
||||
}
|
||||
|
||||
const event = this.createRunEvent({
|
||||
runId: command.runId,
|
||||
attemptId: command.attemptId,
|
||||
decision: decision.event,
|
||||
actor: command.actor,
|
||||
dedupeKey:
|
||||
command.dedupeKey ??
|
||||
`attempt-transition:${command.attemptId}:${command.expectedRunVersion}:${command.to}`,
|
||||
createdAtMs: command.atMs,
|
||||
});
|
||||
await transaction.appendEvent(event);
|
||||
return { run: decision.run, attempt: decision.attempt, event };
|
||||
});
|
||||
}
|
||||
|
||||
async requestCancellation(
|
||||
command: RequestRunCancellationCommand,
|
||||
): Promise<RequestRunCancellationResult> {
|
||||
return this.repository.transaction(async (transaction) => {
|
||||
const currentRun = await transaction.findRunById(command.runId);
|
||||
if (!currentRun) throw new RunNotFoundError(command.runId);
|
||||
|
||||
const currentAttempt = await transaction.findAttemptById(
|
||||
command.attemptId,
|
||||
);
|
||||
if (!currentAttempt) {
|
||||
throw new RunAttemptNotFoundError(command.attemptId);
|
||||
}
|
||||
if (currentAttempt.runId !== currentRun.id) {
|
||||
throw new RunAttemptNotFoundError(command.attemptId);
|
||||
}
|
||||
if (isTerminalRunAttemptStatus(currentAttempt.status)) {
|
||||
return {
|
||||
status: 'already_terminal',
|
||||
run: currentRun,
|
||||
attempt: currentAttempt,
|
||||
};
|
||||
}
|
||||
|
||||
const decision = requestRunCancellation(currentRun, {
|
||||
expectedVersion: currentRun.version,
|
||||
atMs: command.atMs,
|
||||
reason: command.reason,
|
||||
});
|
||||
if (decision.status !== 'accepted') {
|
||||
return {
|
||||
status: decision.status,
|
||||
run: decision.run,
|
||||
attempt: currentAttempt,
|
||||
};
|
||||
}
|
||||
|
||||
const updated = await transaction.compareAndSetRun(
|
||||
decision.run,
|
||||
currentRun.version,
|
||||
);
|
||||
if (!updated) {
|
||||
const latest = await transaction.findRunById(command.runId);
|
||||
if (!latest) throw new RunNotFoundError(command.runId);
|
||||
throw new RunVersionConflictError(
|
||||
command.runId,
|
||||
currentRun.version,
|
||||
latest.version,
|
||||
);
|
||||
}
|
||||
|
||||
const event = this.createRunEvent({
|
||||
runId: command.runId,
|
||||
attemptId: command.attemptId,
|
||||
decision: decision.event,
|
||||
actor: command.actor,
|
||||
dedupeKey:
|
||||
command.dedupeKey ?? `run-cancel-request:${command.attemptId}`,
|
||||
createdAtMs: command.atMs,
|
||||
});
|
||||
await transaction.appendEvent(event);
|
||||
return {
|
||||
status: 'accepted',
|
||||
run: decision.run,
|
||||
attempt: currentAttempt,
|
||||
event,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private createRunEvent(input: {
|
||||
runId: string;
|
||||
attemptId?: string;
|
||||
decision: {
|
||||
sequence: number;
|
||||
type: string;
|
||||
payload: Readonly<Record<string, unknown>>;
|
||||
};
|
||||
actor: RunCommandActor;
|
||||
dedupeKey: string;
|
||||
createdAtMs: number;
|
||||
}): RunEventRecord {
|
||||
return {
|
||||
id: this.createEventId(),
|
||||
runId: input.runId,
|
||||
sequence: input.decision.sequence,
|
||||
type: input.decision.type,
|
||||
dedupeKey: input.dedupeKey,
|
||||
actorType: input.actor.type,
|
||||
...(input.actor.id === undefined ? {} : { actorId: input.actor.id }),
|
||||
...(input.attemptId === undefined ? {} : { attemptId: input.attemptId }),
|
||||
payload: input.decision.payload,
|
||||
createdAtMs: input.createdAtMs,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { assertRunDispatchLeaseVersion } from '../domain/runDispatchLease';
|
||||
import {
|
||||
MAX_RUN_DISPATCH_LEASE_EXPIRY_PAGE_SIZE,
|
||||
type RunDispatchLeaseExpiryCursor,
|
||||
type RunDispatchLeaseExpirySource,
|
||||
} from '../ports/runDispatchLeaseExpirySource';
|
||||
import type {
|
||||
RunDispatchLeaseExpiryResult,
|
||||
RunDispatchLeaseExpiryService,
|
||||
RunDispatchLeaseExpiryStatus,
|
||||
} from './runDispatchLeaseExpiryService';
|
||||
|
||||
export interface RunDispatchLeaseExpiryScanSummary {
|
||||
observedAtMs: number;
|
||||
scanned: number;
|
||||
counts: Readonly<Record<RunDispatchLeaseExpiryStatus, number>>;
|
||||
failed: number;
|
||||
truncated: boolean;
|
||||
nextCursor?: RunDispatchLeaseExpiryCursor;
|
||||
}
|
||||
|
||||
const STATUSES: readonly RunDispatchLeaseExpiryStatus[] = [
|
||||
'lost',
|
||||
'cancellation_pending',
|
||||
'unstarted_released',
|
||||
'terminal_released',
|
||||
'already_expired',
|
||||
'not_due',
|
||||
'not_eligible',
|
||||
'not_found',
|
||||
];
|
||||
|
||||
/** One bounded expiry page. The caller owns cadence and cursor persistence. */
|
||||
export class RunDispatchLeaseExpiryScanner {
|
||||
private readonly clock: { now(): number };
|
||||
|
||||
constructor(
|
||||
private readonly source: RunDispatchLeaseExpirySource,
|
||||
private readonly service: Pick<RunDispatchLeaseExpiryService, 'reconcile'>,
|
||||
options: { clock?: { now(): number } } = {},
|
||||
) {
|
||||
this.clock = options.clock ?? Date;
|
||||
}
|
||||
|
||||
async scan(
|
||||
options: { after?: RunDispatchLeaseExpiryCursor; limit?: number } = {},
|
||||
): Promise<RunDispatchLeaseExpiryScanSummary> {
|
||||
const observedAtMs = this.now();
|
||||
const limit = options.limit ?? 16;
|
||||
if (
|
||||
!Number.isSafeInteger(limit) ||
|
||||
limit < 1 ||
|
||||
limit > MAX_RUN_DISPATCH_LEASE_EXPIRY_PAGE_SIZE
|
||||
) {
|
||||
throw new RangeError(
|
||||
`limit must be between 1 and ${MAX_RUN_DISPATCH_LEASE_EXPIRY_PAGE_SIZE}`,
|
||||
);
|
||||
}
|
||||
const candidates = await this.source.listExpired({
|
||||
observedAtMs,
|
||||
...(options.after === undefined ? {} : { after: options.after }),
|
||||
limit,
|
||||
});
|
||||
if (candidates.length > limit) {
|
||||
throw new RangeError('Run lease expiry source exceeded page size');
|
||||
}
|
||||
const counts = Object.fromEntries(
|
||||
STATUSES.map((status) => [status, 0]),
|
||||
) as Record<RunDispatchLeaseExpiryStatus, number>;
|
||||
let failed = 0;
|
||||
let previous = options.after;
|
||||
let resumeCursor = options.after;
|
||||
for (const candidate of candidates) {
|
||||
if (
|
||||
candidate.expiresAtMs > observedAtMs ||
|
||||
(previous !== undefined &&
|
||||
(candidate.expiresAtMs < previous.expiresAtMs ||
|
||||
(candidate.expiresAtMs === previous.expiresAtMs &&
|
||||
candidate.attemptId <= previous.attemptId)))
|
||||
) {
|
||||
throw new TypeError('Run lease expiry cursor did not advance');
|
||||
}
|
||||
previous = {
|
||||
expiresAtMs: candidate.expiresAtMs,
|
||||
attemptId: candidate.attemptId,
|
||||
};
|
||||
try {
|
||||
const result: RunDispatchLeaseExpiryResult =
|
||||
await this.service.reconcile(candidate.runId, candidate.attemptId);
|
||||
counts[result.status] += 1;
|
||||
} catch {
|
||||
failed += 1;
|
||||
return {
|
||||
observedAtMs,
|
||||
scanned: candidates.length,
|
||||
counts,
|
||||
failed,
|
||||
truncated: true,
|
||||
...(resumeCursor === undefined
|
||||
? {}
|
||||
: { nextCursor: resumeCursor }),
|
||||
};
|
||||
}
|
||||
resumeCursor = previous;
|
||||
}
|
||||
const truncated = candidates.length === limit;
|
||||
return {
|
||||
observedAtMs,
|
||||
scanned: candidates.length,
|
||||
counts,
|
||||
failed,
|
||||
truncated,
|
||||
...(truncated && resumeCursor !== undefined
|
||||
? { nextCursor: resumeCursor }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
private now(): number {
|
||||
const nowMs = this.clock.now();
|
||||
assertRunDispatchLeaseVersion('observedAtMs', nowMs);
|
||||
return nowMs;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
import { v7 as uuidV7 } from 'uuid';
|
||||
import type {
|
||||
RunAttemptRecord,
|
||||
RunEventRecord,
|
||||
RunRecord,
|
||||
} from '../domain/run';
|
||||
import {
|
||||
assertRunDispatchId,
|
||||
assertRunDispatchLeaseVersion,
|
||||
type RunDispatchLeaseRecord,
|
||||
} from '../domain/runDispatchLease';
|
||||
import {
|
||||
isTerminalRunAttemptStatus,
|
||||
isTerminalRunStatus,
|
||||
transitionRun,
|
||||
transitionRunAttempt,
|
||||
type RunDomainEventDraft,
|
||||
} from '../domain/runStateMachine';
|
||||
import type { RunDispatchLeaseRepository } from '../ports/runDispatchLeaseRepository';
|
||||
import type { RunRepositoryTransaction } from '../ports/runRepository';
|
||||
|
||||
export type RunDispatchLeaseExpiryStatus =
|
||||
| 'lost'
|
||||
| 'cancellation_pending'
|
||||
| 'unstarted_released'
|
||||
| 'terminal_released'
|
||||
| 'already_expired'
|
||||
| 'not_due'
|
||||
| 'not_eligible'
|
||||
| 'not_found';
|
||||
|
||||
export interface RunDispatchLeaseExpiryResult {
|
||||
status: RunDispatchLeaseExpiryStatus;
|
||||
lease?: RunDispatchLeaseRecord;
|
||||
run?: RunRecord;
|
||||
attempt?: RunAttemptRecord;
|
||||
events?: readonly RunEventRecord[];
|
||||
}
|
||||
|
||||
export class RunDispatchLeaseExpiryTargetError extends Error {
|
||||
constructor(message: string) {
|
||||
super(`Run dispatch lease expiry target is inconsistent: ${message}`);
|
||||
this.name = 'RunDispatchLeaseExpiryTargetError';
|
||||
}
|
||||
}
|
||||
|
||||
interface ExpiryMutation {
|
||||
status:
|
||||
| 'lost'
|
||||
| 'cancellation_pending'
|
||||
| 'unstarted_released'
|
||||
| 'terminal_released';
|
||||
run: RunRecord;
|
||||
attempt: RunAttemptRecord;
|
||||
events: readonly RunEventRecord[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-owned expiry decision. No Worker principal can call this path with a
|
||||
* stale fence; the repository locks the authoritative lease and releases it
|
||||
* in the same transaction as any Attempt/Run lost transition.
|
||||
*/
|
||||
export class RunDispatchLeaseExpiryService {
|
||||
private readonly clock: { now(): number };
|
||||
private readonly createEventId: () => string;
|
||||
|
||||
constructor(
|
||||
private readonly leases: RunDispatchLeaseRepository,
|
||||
options: { clock?: { now(): number }; createEventId?: () => string } = {},
|
||||
) {
|
||||
this.clock = options.clock ?? Date;
|
||||
this.createEventId = options.createEventId ?? uuidV7;
|
||||
}
|
||||
|
||||
async reconcile(
|
||||
runId: string,
|
||||
attemptId: string,
|
||||
): Promise<RunDispatchLeaseExpiryResult> {
|
||||
assertRunDispatchId('runId', runId);
|
||||
assertRunDispatchId('attemptId', attemptId);
|
||||
const observedAtMs = this.now();
|
||||
const attemptEventId = this.eventId('attemptEventId');
|
||||
const runEventId = this.eventId('runEventId');
|
||||
const result = await this.leases.expireWithLease(
|
||||
{ runId, attemptId, observedAtMs },
|
||||
(transaction, lease) =>
|
||||
this.reconcileTarget(
|
||||
transaction,
|
||||
lease,
|
||||
observedAtMs,
|
||||
attemptEventId,
|
||||
runEventId,
|
||||
),
|
||||
);
|
||||
if (result.status === 'expired') {
|
||||
return {
|
||||
...result.value,
|
||||
lease: result.lease,
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: result.status,
|
||||
...(result.lease === undefined ? {} : { lease: result.lease }),
|
||||
};
|
||||
}
|
||||
|
||||
private async reconcileTarget(
|
||||
transaction: RunRepositoryTransaction,
|
||||
lease: RunDispatchLeaseRecord,
|
||||
observedAtMs: number,
|
||||
attemptEventId: string,
|
||||
runEventId: string,
|
||||
): Promise<ExpiryMutation> {
|
||||
const [run, attempt] = await Promise.all([
|
||||
transaction.findRunById(lease.runId),
|
||||
transaction.findAttemptById(lease.attemptId),
|
||||
]);
|
||||
if (!run || !attempt || attempt.runId !== run.id) {
|
||||
throw new RunDispatchLeaseExpiryTargetError('Run or Attempt is missing');
|
||||
}
|
||||
if (
|
||||
run.executionOwner !== 'runtime' ||
|
||||
(attempt.workerId !== undefined && attempt.workerId !== lease.workerId)
|
||||
) {
|
||||
throw new RunDispatchLeaseExpiryTargetError(
|
||||
'execution ownership does not match the expired lease',
|
||||
);
|
||||
}
|
||||
if (isTerminalRunStatus(run.status) || isTerminalRunAttemptStatus(attempt.status)) {
|
||||
return {
|
||||
status: 'terminal_released',
|
||||
run,
|
||||
attempt,
|
||||
events: [],
|
||||
};
|
||||
}
|
||||
if (run.cancelRequestedAtMs !== undefined) {
|
||||
return {
|
||||
status: 'cancellation_pending',
|
||||
run,
|
||||
attempt,
|
||||
events: [],
|
||||
};
|
||||
}
|
||||
if (attempt.status === 'claimed' && run.status === 'dispatching') {
|
||||
return {
|
||||
status: 'unstarted_released',
|
||||
run,
|
||||
attempt,
|
||||
events: [],
|
||||
};
|
||||
}
|
||||
if (
|
||||
(attempt.status !== 'starting' && attempt.status !== 'running') ||
|
||||
(run.status !== 'dispatching' && run.status !== 'running')
|
||||
) {
|
||||
throw new RunDispatchLeaseExpiryTargetError(
|
||||
'active Run and Attempt states do not match',
|
||||
);
|
||||
}
|
||||
|
||||
const atMs = Math.max(
|
||||
observedAtMs,
|
||||
run.createdAtMs,
|
||||
run.startedAtMs ?? 0,
|
||||
attempt.createdAtMs,
|
||||
attempt.startedAtMs ?? 0,
|
||||
);
|
||||
const errorCode = 'REMOTE_RUN_LEASE_EXPIRED';
|
||||
const errorSummary =
|
||||
'Remote execution authority expired before completion was observed';
|
||||
const attemptDecision = transitionRunAttempt(run, attempt, {
|
||||
to: 'lost',
|
||||
expectedRunVersion: run.version,
|
||||
atMs,
|
||||
errorCode,
|
||||
errorSummary,
|
||||
});
|
||||
const runDecision = transitionRun(attemptDecision.run, {
|
||||
to: 'lost',
|
||||
expectedVersion: attemptDecision.run.version,
|
||||
atMs,
|
||||
errorCode,
|
||||
errorSummary,
|
||||
});
|
||||
if (
|
||||
!(await transaction.compareAndSetRun(
|
||||
attemptDecision.run,
|
||||
run.version,
|
||||
)) ||
|
||||
!(await transaction.compareAndSetAttempt(attemptDecision.attempt, {
|
||||
status: attempt.status,
|
||||
callbackSequence: attempt.callbackSequence,
|
||||
}))
|
||||
) {
|
||||
throw new RunDispatchLeaseExpiryTargetError(
|
||||
'Attempt lost transition lost its compare-and-set race',
|
||||
);
|
||||
}
|
||||
const attemptEvent = this.event(
|
||||
attemptEventId,
|
||||
attemptDecision.run,
|
||||
attemptDecision.event,
|
||||
attempt.id,
|
||||
lease,
|
||||
'attempt',
|
||||
atMs,
|
||||
);
|
||||
await transaction.appendEvent(attemptEvent);
|
||||
if (
|
||||
!(await transaction.compareAndSetRun(
|
||||
runDecision.run,
|
||||
attemptDecision.run.version,
|
||||
))
|
||||
) {
|
||||
throw new RunDispatchLeaseExpiryTargetError(
|
||||
'Run lost transition lost its compare-and-set race',
|
||||
);
|
||||
}
|
||||
const runEvent = this.event(
|
||||
runEventId,
|
||||
runDecision.run,
|
||||
runDecision.event,
|
||||
attempt.id,
|
||||
lease,
|
||||
'run',
|
||||
atMs,
|
||||
);
|
||||
await transaction.appendEvent(runEvent);
|
||||
return {
|
||||
status: 'lost',
|
||||
run: runDecision.run,
|
||||
attempt: attemptDecision.attempt,
|
||||
events: [attemptEvent, runEvent],
|
||||
};
|
||||
}
|
||||
|
||||
private event(
|
||||
id: string,
|
||||
run: RunRecord,
|
||||
draft: RunDomainEventDraft,
|
||||
attemptId: string,
|
||||
lease: RunDispatchLeaseRecord,
|
||||
phase: 'attempt' | 'run',
|
||||
createdAtMs: number,
|
||||
): RunEventRecord {
|
||||
return {
|
||||
id,
|
||||
runId: run.id,
|
||||
sequence: draft.sequence,
|
||||
type: draft.type,
|
||||
dedupeKey: `run-lease-expiry:${attemptId}:${lease.leaseGeneration}:${phase}`,
|
||||
actorType: 'reconciler',
|
||||
attemptId,
|
||||
payload: {
|
||||
...draft.payload,
|
||||
lease_generation: lease.leaseGeneration,
|
||||
worker_id: lease.workerId,
|
||||
},
|
||||
createdAtMs,
|
||||
};
|
||||
}
|
||||
|
||||
private eventId(name: string): string {
|
||||
const id = this.createEventId();
|
||||
assertRunDispatchId(name, id);
|
||||
return id;
|
||||
}
|
||||
|
||||
private now(): number {
|
||||
const nowMs = this.clock.now();
|
||||
assertRunDispatchLeaseVersion('observedAtMs', nowMs);
|
||||
return nowMs;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { v7 as uuidV7 } from 'uuid';
|
||||
import {
|
||||
assertRunDispatchId,
|
||||
assertRunDispatchLeaseDuration,
|
||||
assertRunDispatchLeaseToken,
|
||||
assertRunDispatchLeaseVersion,
|
||||
assertRunDispatchWorkerFence,
|
||||
type RunDispatchReleaseReason,
|
||||
} from '../domain/runDispatchLease';
|
||||
import { assertWorkerId } from '../domain/worker';
|
||||
import type {
|
||||
ClaimRunDispatchLeaseResult,
|
||||
ReleaseRunDispatchLeaseResult,
|
||||
RunDispatchLeaseRepository,
|
||||
} from '../ports/runDispatchLeaseRepository';
|
||||
import type { AuthenticatedWorkerPrincipal } from './workerControlService';
|
||||
import { WorkerPrincipalMismatchError } from './workerControlService';
|
||||
|
||||
export interface RunDispatchLeaseServiceOptions {
|
||||
leaseDurationMs?: number;
|
||||
clock?: { now(): number };
|
||||
createEventId?: () => string;
|
||||
}
|
||||
|
||||
export interface ClaimRunDispatchLeaseRequest {
|
||||
runId: string;
|
||||
attemptId: string;
|
||||
workerId: string;
|
||||
workerSessionId: string;
|
||||
workerGeneration: number;
|
||||
leaseToken: string;
|
||||
}
|
||||
|
||||
export interface FencedRunDispatchLeaseRequest {
|
||||
attemptId: string;
|
||||
workerId: string;
|
||||
workerSessionId: string;
|
||||
workerGeneration: number;
|
||||
leaseGeneration: number;
|
||||
leaseToken: string;
|
||||
expectedVersion: number;
|
||||
}
|
||||
|
||||
export interface ReleaseRunDispatchLeaseRequest
|
||||
extends FencedRunDispatchLeaseRequest {
|
||||
runId: string;
|
||||
reason: Exclude<RunDispatchReleaseReason, 'lease_expired'>;
|
||||
}
|
||||
|
||||
export class RunDispatchLeaseService {
|
||||
private readonly leaseDurationMs: number;
|
||||
private readonly clock: { now(): number };
|
||||
private readonly createEventId: () => string;
|
||||
|
||||
constructor(
|
||||
private readonly repository: RunDispatchLeaseRepository,
|
||||
options: RunDispatchLeaseServiceOptions = {},
|
||||
) {
|
||||
this.leaseDurationMs = options.leaseDurationMs ?? 30_000;
|
||||
this.clock = options.clock ?? Date;
|
||||
this.createEventId = options.createEventId ?? uuidV7;
|
||||
assertRunDispatchLeaseDuration(this.leaseDurationMs);
|
||||
}
|
||||
|
||||
claim(
|
||||
principal: AuthenticatedWorkerPrincipal,
|
||||
request: ClaimRunDispatchLeaseRequest,
|
||||
): Promise<ClaimRunDispatchLeaseResult> {
|
||||
this.assertPrincipal(principal, request.workerId);
|
||||
this.assertClaimRequest(request);
|
||||
return this.repository.claim({
|
||||
...request,
|
||||
nowMs: this.now(),
|
||||
leaseDurationMs: this.leaseDurationMs,
|
||||
eventId: this.createEventId(),
|
||||
});
|
||||
}
|
||||
|
||||
renew(
|
||||
principal: AuthenticatedWorkerPrincipal,
|
||||
request: FencedRunDispatchLeaseRequest,
|
||||
) {
|
||||
this.assertPrincipal(principal, request.workerId);
|
||||
this.assertFenceRequest(request);
|
||||
return this.repository.renew({
|
||||
...request,
|
||||
nowMs: this.now(),
|
||||
leaseDurationMs: this.leaseDurationMs,
|
||||
});
|
||||
}
|
||||
|
||||
release(
|
||||
principal: AuthenticatedWorkerPrincipal,
|
||||
request: ReleaseRunDispatchLeaseRequest,
|
||||
): Promise<ReleaseRunDispatchLeaseResult> {
|
||||
this.assertPrincipal(principal, request.workerId);
|
||||
this.assertFenceRequest(request);
|
||||
return this.repository.release({
|
||||
...request,
|
||||
nowMs: this.now(),
|
||||
eventId: this.createEventId(),
|
||||
});
|
||||
}
|
||||
|
||||
private now(): number {
|
||||
const nowMs = this.clock.now();
|
||||
assertRunDispatchLeaseVersion('nowMs', nowMs);
|
||||
return nowMs;
|
||||
}
|
||||
|
||||
private assertPrincipal(
|
||||
principal: AuthenticatedWorkerPrincipal,
|
||||
workerId: string,
|
||||
): void {
|
||||
assertWorkerId(principal.workerId);
|
||||
assertWorkerId(workerId);
|
||||
if (principal.workerId !== workerId) {
|
||||
throw new WorkerPrincipalMismatchError();
|
||||
}
|
||||
}
|
||||
|
||||
private assertClaimRequest(request: ClaimRunDispatchLeaseRequest): void {
|
||||
assertRunDispatchId('runId', request.runId);
|
||||
assertRunDispatchId('attemptId', request.attemptId);
|
||||
assertRunDispatchWorkerFence(request);
|
||||
assertRunDispatchLeaseToken(request.leaseToken);
|
||||
}
|
||||
|
||||
private assertFenceRequest(request: FencedRunDispatchLeaseRequest): void {
|
||||
assertRunDispatchId('attemptId', request.attemptId);
|
||||
assertRunDispatchWorkerFence(request);
|
||||
assertRunDispatchLeaseToken(request.leaseToken);
|
||||
assertRunDispatchLeaseVersion(
|
||||
'leaseGeneration',
|
||||
request.leaseGeneration,
|
||||
true,
|
||||
);
|
||||
assertRunDispatchLeaseVersion('expectedVersion', request.expectedVersion);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
import { v7 as uuidV7 } from 'uuid';
|
||||
import {
|
||||
assertRunDispatchCandidate,
|
||||
assertRunDispatchCandidatePageSize,
|
||||
type RunDispatchCandidate,
|
||||
type RunDispatchCandidateCursor,
|
||||
} from '../domain/runDispatchCandidate';
|
||||
import {
|
||||
assertRunDispatchLeaseToken,
|
||||
assertRunDispatchLeaseVersion,
|
||||
RunDispatchLeaseFenceRejectedError,
|
||||
} from '../domain/runDispatchLease';
|
||||
import {
|
||||
createExecutionSpecDigest,
|
||||
createRunDispatchOfferId,
|
||||
type RunDispatcherIdleReason,
|
||||
type RunDispatcherResult,
|
||||
type RunDispatcherStats,
|
||||
} from '../domain/runDispatchOffer';
|
||||
import {
|
||||
assertRecoverableRunDispatch,
|
||||
assertRunDispatchRecoveryPageSize,
|
||||
type RunDispatchRecoveryCursor,
|
||||
} from '../domain/runDispatchRecovery';
|
||||
import {
|
||||
MAX_PLACEMENT_CANDIDATES,
|
||||
normalizeWorkerPlacementSpec,
|
||||
selectWorkerCandidates,
|
||||
type WorkerPlacementSpec,
|
||||
} from '../domain/workerPlacement';
|
||||
import type { RunDispatchCandidateSource } from '../ports/runDispatchCandidateSource';
|
||||
import type { ClaimRunDispatchLeaseResult } from '../ports/runDispatchLeaseRepository';
|
||||
import type { RunDispatchPlanSource } from '../ports/runDispatchPlanSource';
|
||||
import type { RunDispatchRecoverySource } from '../ports/runDispatchRecoverySource';
|
||||
import { executionSpecForRunDispatchCandidate } from '../domain/runDispatchPlan';
|
||||
import {
|
||||
MAX_AVAILABLE_WORKER_PAGE_SIZE,
|
||||
type WorkerRegistryRepository,
|
||||
} from '../ports/workerRegistryRepository';
|
||||
import type { ClaimRunDispatchLeaseRequest } from './runDispatchLeaseService';
|
||||
import type { AuthenticatedWorkerPrincipal } from './workerControlService';
|
||||
|
||||
const DEFAULT_CANDIDATE_PAGE_SIZE = 8;
|
||||
const DEFAULT_MAX_CANDIDATE_PAGES = 2;
|
||||
const DEFAULT_RECOVERY_PAGE_SIZE = 8;
|
||||
const DEFAULT_MAX_RECOVERY_PAGES = 2;
|
||||
const DEFAULT_WORKER_PAGE_SIZE = 8;
|
||||
const DEFAULT_MAX_WORKER_PAGES = 2;
|
||||
const DEFAULT_MAX_CLAIM_ATTEMPTS = 8;
|
||||
const MAX_DISPATCH_SCAN_PAGES = 16;
|
||||
|
||||
export interface RunDispatchClaimer {
|
||||
claim(
|
||||
principal: AuthenticatedWorkerPrincipal,
|
||||
request: ClaimRunDispatchLeaseRequest,
|
||||
): Promise<ClaimRunDispatchLeaseResult>;
|
||||
}
|
||||
|
||||
export interface RunDispatcherOptions {
|
||||
recoveryPageSize?: number;
|
||||
maxRecoveryPages?: number;
|
||||
candidatePageSize?: number;
|
||||
maxCandidatePages?: number;
|
||||
workerPageSize?: number;
|
||||
maxWorkerPages?: number;
|
||||
maxClaimAttempts?: number;
|
||||
clock?: { now(): number };
|
||||
createLeaseToken?: () => string;
|
||||
}
|
||||
|
||||
interface CandidatePageState {
|
||||
candidates: RunDispatchCandidate[];
|
||||
cursor?: RunDispatchCandidateCursor;
|
||||
more: boolean;
|
||||
}
|
||||
|
||||
function assertIntegerBetween(
|
||||
name: string,
|
||||
value: number,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): void {
|
||||
if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
|
||||
throw new RangeError(`${name} must be between ${minimum} and ${maximum}`);
|
||||
}
|
||||
}
|
||||
|
||||
function cursorOf(candidate: RunDispatchCandidate): RunDispatchCandidateCursor {
|
||||
return {
|
||||
priority: candidate.priority,
|
||||
queuedAtMs: candidate.queuedAtMs,
|
||||
attemptCreatedAtMs: candidate.attemptCreatedAtMs,
|
||||
attemptId: candidate.attemptId,
|
||||
};
|
||||
}
|
||||
|
||||
function cursorKey(cursor: RunDispatchCandidateCursor): string {
|
||||
return `${cursor.priority}\0${cursor.queuedAtMs}\0${cursor.attemptCreatedAtMs}\0${cursor.attemptId}`;
|
||||
}
|
||||
|
||||
function effectivePlacement(
|
||||
placementValue: unknown,
|
||||
executorType: string,
|
||||
): WorkerPlacementSpec {
|
||||
const placement = normalizeWorkerPlacementSpec(placementValue);
|
||||
const configuredExecutors = placement.required?.executors;
|
||||
if (
|
||||
configuredExecutors !== undefined &&
|
||||
!configuredExecutors.includes(executorType)
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Run dispatch plan executor placement does not match its candidate',
|
||||
);
|
||||
}
|
||||
return {
|
||||
...placement,
|
||||
required: {
|
||||
...placement.required,
|
||||
executors: configuredExecutors ?? [executorType],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function emptyStats(): RunDispatcherStats {
|
||||
return {
|
||||
recoveryPages: 0,
|
||||
recoveriesScanned: 0,
|
||||
recoveryPlansUnavailable: 0,
|
||||
candidatePages: 0,
|
||||
candidatesScanned: 0,
|
||||
workerPages: 0,
|
||||
workersScanned: 0,
|
||||
plansUnavailable: 0,
|
||||
matchingWorkers: 0,
|
||||
claimAttempts: 0,
|
||||
claimRaces: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** One bounded dispatch cycle. The caller owns scheduling and offer delivery. */
|
||||
export class RunDispatcher {
|
||||
private readonly recoveryPageSize: number;
|
||||
private readonly maxRecoveryPages: number;
|
||||
private readonly candidatePageSize: number;
|
||||
private readonly maxCandidatePages: number;
|
||||
private readonly workerPageSize: number;
|
||||
private readonly maxWorkerPages: number;
|
||||
private readonly maxClaimAttempts: number;
|
||||
private readonly clock: { now(): number };
|
||||
private readonly createLeaseToken: () => string;
|
||||
|
||||
constructor(
|
||||
private readonly recoveries: RunDispatchRecoverySource,
|
||||
private readonly candidates: RunDispatchCandidateSource,
|
||||
private readonly workers: WorkerRegistryRepository,
|
||||
private readonly plans: RunDispatchPlanSource,
|
||||
private readonly leases: RunDispatchClaimer,
|
||||
options: RunDispatcherOptions = {},
|
||||
) {
|
||||
this.recoveryPageSize =
|
||||
options.recoveryPageSize ?? DEFAULT_RECOVERY_PAGE_SIZE;
|
||||
this.maxRecoveryPages =
|
||||
options.maxRecoveryPages ?? DEFAULT_MAX_RECOVERY_PAGES;
|
||||
this.candidatePageSize =
|
||||
options.candidatePageSize ?? DEFAULT_CANDIDATE_PAGE_SIZE;
|
||||
this.maxCandidatePages =
|
||||
options.maxCandidatePages ?? DEFAULT_MAX_CANDIDATE_PAGES;
|
||||
this.workerPageSize = options.workerPageSize ?? DEFAULT_WORKER_PAGE_SIZE;
|
||||
this.maxWorkerPages = options.maxWorkerPages ?? DEFAULT_MAX_WORKER_PAGES;
|
||||
this.maxClaimAttempts =
|
||||
options.maxClaimAttempts ?? DEFAULT_MAX_CLAIM_ATTEMPTS;
|
||||
this.clock = options.clock ?? Date;
|
||||
this.createLeaseToken = options.createLeaseToken ?? uuidV7;
|
||||
|
||||
assertRunDispatchRecoveryPageSize(this.recoveryPageSize);
|
||||
assertIntegerBetween(
|
||||
'maxRecoveryPages',
|
||||
this.maxRecoveryPages,
|
||||
1,
|
||||
MAX_DISPATCH_SCAN_PAGES,
|
||||
);
|
||||
assertRunDispatchCandidatePageSize(this.candidatePageSize);
|
||||
assertIntegerBetween(
|
||||
'maxCandidatePages',
|
||||
this.maxCandidatePages,
|
||||
1,
|
||||
MAX_DISPATCH_SCAN_PAGES,
|
||||
);
|
||||
assertIntegerBetween(
|
||||
'workerPageSize',
|
||||
this.workerPageSize,
|
||||
1,
|
||||
MAX_AVAILABLE_WORKER_PAGE_SIZE,
|
||||
);
|
||||
assertIntegerBetween(
|
||||
'maxWorkerPages',
|
||||
this.maxWorkerPages,
|
||||
1,
|
||||
MAX_DISPATCH_SCAN_PAGES,
|
||||
);
|
||||
if (this.workerPageSize * this.maxWorkerPages > MAX_PLACEMENT_CANDIDATES) {
|
||||
throw new RangeError(
|
||||
`workerPageSize * maxWorkerPages must not exceed ${MAX_PLACEMENT_CANDIDATES}`,
|
||||
);
|
||||
}
|
||||
assertIntegerBetween(
|
||||
'maxClaimAttempts',
|
||||
this.maxClaimAttempts,
|
||||
1,
|
||||
MAX_PLACEMENT_CANDIDATES,
|
||||
);
|
||||
}
|
||||
|
||||
async dispatchOnce(): Promise<RunDispatcherResult> {
|
||||
const observedAtMs = this.now();
|
||||
const stats = emptyStats();
|
||||
const recoveryResult = await this.recoverOffer(observedAtMs, stats);
|
||||
if (recoveryResult) return recoveryResult;
|
||||
|
||||
const seenAttempts = new Set<string>();
|
||||
let page = await this.loadCandidatePage(
|
||||
observedAtMs,
|
||||
undefined,
|
||||
seenAttempts,
|
||||
stats,
|
||||
);
|
||||
if (page.candidates.length === 0) {
|
||||
return this.idle(
|
||||
stats.recoveryPlansUnavailable > 0
|
||||
? 'recovery_plans_unavailable'
|
||||
: 'no_candidates',
|
||||
stats,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
const workerState = await this.loadWorkers(observedAtMs, stats);
|
||||
if (workerState.workers.length === 0) {
|
||||
return this.idle('no_workers', stats, workerState.truncated);
|
||||
}
|
||||
|
||||
let raced = false;
|
||||
while (true) {
|
||||
for (const candidate of page.candidates) {
|
||||
stats.candidatesScanned += 1;
|
||||
const plan = await this.plans.prepare({ ...candidate });
|
||||
if (!plan) {
|
||||
stats.plansUnavailable += 1;
|
||||
continue;
|
||||
}
|
||||
const executionSpec = executionSpecForRunDispatchCandidate(
|
||||
candidate,
|
||||
plan.executionSpec,
|
||||
);
|
||||
const selected = selectWorkerCandidates(
|
||||
workerState.workers,
|
||||
effectivePlacement(plan.placement, candidate.executorType),
|
||||
observedAtMs,
|
||||
MAX_PLACEMENT_CANDIDATES,
|
||||
);
|
||||
stats.matchingWorkers += selected.length;
|
||||
if (selected.length === 0) continue;
|
||||
for (const selectedWorker of selected) {
|
||||
if (stats.claimAttempts >= this.maxClaimAttempts) {
|
||||
return this.idle('claim_budget_exhausted', stats, true);
|
||||
}
|
||||
const leaseToken = this.createLeaseToken();
|
||||
assertRunDispatchLeaseToken(leaseToken);
|
||||
stats.claimAttempts += 1;
|
||||
let claim: ClaimRunDispatchLeaseResult;
|
||||
try {
|
||||
claim = await this.leases.claim(
|
||||
{ workerId: selectedWorker.worker.id },
|
||||
{
|
||||
runId: candidate.runId,
|
||||
attemptId: candidate.attemptId,
|
||||
workerId: selectedWorker.worker.id,
|
||||
workerSessionId: selectedWorker.worker.sessionId,
|
||||
workerGeneration: selectedWorker.worker.generation,
|
||||
leaseToken,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof RunDispatchLeaseFenceRejectedError &&
|
||||
error.reason === 'version_mismatch'
|
||||
) {
|
||||
stats.claimRaces += 1;
|
||||
raced = true;
|
||||
break;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (claim.status === 'claimed' || claim.status === 'idempotent') {
|
||||
return {
|
||||
status: 'offered',
|
||||
offer: {
|
||||
offerId: createRunDispatchOfferId(claim.lease),
|
||||
executionSpecDigest: createExecutionSpecDigest(executionSpec),
|
||||
deliveryKind: 'new_claim',
|
||||
candidate: { ...candidate },
|
||||
worker: {
|
||||
id: selectedWorker.worker.id,
|
||||
sessionId: selectedWorker.worker.sessionId,
|
||||
generation: selectedWorker.worker.generation,
|
||||
},
|
||||
lease: claim.lease,
|
||||
executionSpec,
|
||||
placementScore: selectedWorker.score,
|
||||
},
|
||||
stats,
|
||||
truncated: workerState.truncated || page.more,
|
||||
};
|
||||
}
|
||||
if (claim.status === 'leased' || claim.status === 'not_eligible') {
|
||||
stats.claimRaces += 1;
|
||||
raced = true;
|
||||
break;
|
||||
}
|
||||
stats.claimRaces += 1;
|
||||
raced = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!page.more) break;
|
||||
if (stats.candidatePages >= this.maxCandidatePages) {
|
||||
return this.idle('scan_budget_exhausted', stats, true);
|
||||
}
|
||||
page = await this.loadCandidatePage(
|
||||
observedAtMs,
|
||||
page.cursor,
|
||||
seenAttempts,
|
||||
stats,
|
||||
);
|
||||
if (page.candidates.length === 0) break;
|
||||
}
|
||||
|
||||
const truncated = workerState.truncated || page.more;
|
||||
if (stats.plansUnavailable === stats.candidatesScanned) {
|
||||
return this.idle('plans_unavailable', stats, truncated);
|
||||
}
|
||||
return this.idle(raced ? 'claim_raced' : 'no_match', stats, truncated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuilds one offer from durable lease authority before claiming new work.
|
||||
* A future Worker transport must deduplicate repeated delivery by offerId.
|
||||
*/
|
||||
private async recoverOffer(
|
||||
observedAtMs: number,
|
||||
stats: RunDispatcherStats,
|
||||
): Promise<RunDispatcherResult | null> {
|
||||
const seenAttempts = new Set<string>();
|
||||
let after: RunDispatchRecoveryCursor | undefined;
|
||||
for (let pageIndex = 0; pageIndex < this.maxRecoveryPages; pageIndex += 1) {
|
||||
const recoveries = await this.recoveries.listRecoverable({
|
||||
observedAtMs,
|
||||
...(after === undefined ? {} : { after }),
|
||||
limit: this.recoveryPageSize,
|
||||
});
|
||||
if (recoveries.length > this.recoveryPageSize) {
|
||||
throw new RangeError('Run dispatch recovery source exceeded page size');
|
||||
}
|
||||
stats.recoveryPages += 1;
|
||||
for (const recovery of recoveries) {
|
||||
assertRecoverableRunDispatch(recovery);
|
||||
if (recovery.lease.expiresAtMs <= observedAtMs) {
|
||||
throw new Error(
|
||||
'Run dispatch recovery source returned an expired lease',
|
||||
);
|
||||
}
|
||||
if (seenAttempts.has(recovery.candidate.attemptId)) {
|
||||
throw new Error('Run dispatch recovery source repeated an attempt');
|
||||
}
|
||||
if (
|
||||
after !== undefined &&
|
||||
(recovery.lease.expiresAtMs < after.expiresAtMs ||
|
||||
(recovery.lease.expiresAtMs === after.expiresAtMs &&
|
||||
recovery.candidate.attemptId.localeCompare(after.attemptId) <= 0))
|
||||
) {
|
||||
throw new Error('Run dispatch recovery cursor did not advance');
|
||||
}
|
||||
seenAttempts.add(recovery.candidate.attemptId);
|
||||
stats.recoveriesScanned += 1;
|
||||
const plan = await this.plans.prepare({ ...recovery.candidate });
|
||||
if (!plan) {
|
||||
stats.recoveryPlansUnavailable += 1;
|
||||
continue;
|
||||
}
|
||||
const executionSpec = executionSpecForRunDispatchCandidate(
|
||||
recovery.candidate,
|
||||
plan.executionSpec,
|
||||
);
|
||||
return {
|
||||
status: 'offered',
|
||||
offer: {
|
||||
offerId: createRunDispatchOfferId(recovery.lease),
|
||||
executionSpecDigest: createExecutionSpecDigest(executionSpec),
|
||||
deliveryKind: 'lease_recovery',
|
||||
candidate: { ...recovery.candidate },
|
||||
worker: {
|
||||
id: recovery.lease.workerId,
|
||||
sessionId: recovery.lease.workerSessionId,
|
||||
generation: recovery.lease.workerGeneration,
|
||||
},
|
||||
lease: { ...recovery.lease },
|
||||
executionSpec,
|
||||
},
|
||||
stats,
|
||||
truncated: recoveries.length === this.recoveryPageSize,
|
||||
};
|
||||
}
|
||||
if (recoveries.length < this.recoveryPageSize) return null;
|
||||
const last = recoveries[recoveries.length - 1];
|
||||
after = {
|
||||
expiresAtMs: last.lease.expiresAtMs,
|
||||
attemptId: last.candidate.attemptId,
|
||||
};
|
||||
}
|
||||
return this.idle('recovery_scan_budget_exhausted', stats, true);
|
||||
}
|
||||
|
||||
private async loadCandidatePage(
|
||||
observedAtMs: number,
|
||||
after: RunDispatchCandidateCursor | undefined,
|
||||
seenAttempts: Set<string>,
|
||||
stats: RunDispatcherStats,
|
||||
): Promise<CandidatePageState> {
|
||||
const candidates = await this.candidates.listCandidates({
|
||||
observedAtMs,
|
||||
...(after === undefined ? {} : { after }),
|
||||
limit: this.candidatePageSize,
|
||||
});
|
||||
if (candidates.length > this.candidatePageSize) {
|
||||
throw new RangeError('Run dispatch candidate source exceeded page size');
|
||||
}
|
||||
stats.candidatePages += 1;
|
||||
for (const candidate of candidates) {
|
||||
assertRunDispatchCandidate(candidate);
|
||||
if (seenAttempts.has(candidate.attemptId)) {
|
||||
throw new Error('Run dispatch candidate source repeated an attempt');
|
||||
}
|
||||
seenAttempts.add(candidate.attemptId);
|
||||
}
|
||||
const cursor =
|
||||
candidates.length === 0
|
||||
? after
|
||||
: cursorOf(candidates[candidates.length - 1]);
|
||||
if (
|
||||
after !== undefined &&
|
||||
cursor !== undefined &&
|
||||
cursorKey(after) === cursorKey(cursor)
|
||||
) {
|
||||
throw new Error('Run dispatch candidate cursor did not advance');
|
||||
}
|
||||
return {
|
||||
candidates,
|
||||
...(cursor === undefined ? {} : { cursor }),
|
||||
more: candidates.length === this.candidatePageSize,
|
||||
};
|
||||
}
|
||||
|
||||
private async loadWorkers(observedAtMs: number, stats: RunDispatcherStats) {
|
||||
const workers = [];
|
||||
const seen = new Set<string>();
|
||||
let afterWorkerId: string | undefined;
|
||||
let truncated = false;
|
||||
for (let pageIndex = 0; pageIndex < this.maxWorkerPages; pageIndex += 1) {
|
||||
const page = await this.workers.listAvailable({
|
||||
observedAtMs,
|
||||
...(afterWorkerId === undefined ? {} : { afterWorkerId }),
|
||||
limit: this.workerPageSize,
|
||||
});
|
||||
if (page.workers.length > this.workerPageSize) {
|
||||
throw new RangeError('Worker source exceeded page size');
|
||||
}
|
||||
stats.workerPages += 1;
|
||||
for (const worker of page.workers) {
|
||||
if (seen.has(worker.id)) {
|
||||
throw new Error('Worker source repeated a Worker');
|
||||
}
|
||||
if (
|
||||
afterWorkerId !== undefined &&
|
||||
worker.id.localeCompare(afterWorkerId) <= 0
|
||||
) {
|
||||
throw new Error('Worker source cursor did not advance');
|
||||
}
|
||||
seen.add(worker.id);
|
||||
workers.push(worker);
|
||||
}
|
||||
stats.workersScanned += page.workers.length;
|
||||
if (!page.truncated) {
|
||||
truncated = false;
|
||||
break;
|
||||
}
|
||||
truncated = true;
|
||||
if (
|
||||
!page.nextCursor ||
|
||||
page.nextCursor === afterWorkerId ||
|
||||
page.workers.length === 0 ||
|
||||
page.nextCursor !== page.workers[page.workers.length - 1].id
|
||||
) {
|
||||
throw new Error('Truncated Worker page has no advancing cursor');
|
||||
}
|
||||
afterWorkerId = page.nextCursor;
|
||||
}
|
||||
return { workers, truncated };
|
||||
}
|
||||
|
||||
private idle(
|
||||
reason: RunDispatcherIdleReason,
|
||||
stats: RunDispatcherStats,
|
||||
truncated: boolean,
|
||||
): RunDispatcherResult {
|
||||
return { status: 'idle', reason, stats, truncated };
|
||||
}
|
||||
|
||||
private now(): number {
|
||||
const nowMs = this.clock.now();
|
||||
assertRunDispatchLeaseVersion('observedAtMs', nowMs);
|
||||
return nowMs;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import type {
|
||||
RunLostRetryScanSummary,
|
||||
RunLostRetryScanner,
|
||||
} from './runLostRetryScanner';
|
||||
|
||||
export const MIN_RUN_LOST_RETRY_INTERVAL_MS = 250;
|
||||
export const MAX_RUN_LOST_RETRY_INTERVAL_MS = 24 * 60 * 60 * 1_000;
|
||||
export const MAX_RUN_LOST_RETRY_INITIAL_DELAY_MS = 24 * 60 * 60 * 1_000;
|
||||
export const MAX_RUN_LOST_RETRY_STOP_TIMEOUT_MS = 60_000;
|
||||
|
||||
interface ScheduledTimer {
|
||||
unref?: () => void;
|
||||
}
|
||||
|
||||
export interface RunLostRetryLifecycleScheduler {
|
||||
setTimeout(callback: () => void, delayMs: number): ScheduledTimer;
|
||||
clearTimeout(timer: ScheduledTimer): void;
|
||||
}
|
||||
|
||||
export interface RunLostRetryLifecycleOptions {
|
||||
intervalMs: number;
|
||||
initialDelayMs?: number;
|
||||
stopTimeoutMs?: number;
|
||||
pageSize?: number;
|
||||
scheduler?: RunLostRetryLifecycleScheduler;
|
||||
onCycle?: (summary: RunLostRetryScanSummary) => void;
|
||||
onError?: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export type RunLostRetryStopResult = 'drained' | 'timed_out';
|
||||
|
||||
const defaultScheduler: RunLostRetryLifecycleScheduler = {
|
||||
setTimeout(callback, delayMs) {
|
||||
return setTimeout(callback, delayMs);
|
||||
},
|
||||
clearTimeout(timer) {
|
||||
clearTimeout(timer as ReturnType<typeof setTimeout>);
|
||||
},
|
||||
};
|
||||
|
||||
function assertIntegerBetween(
|
||||
name: string,
|
||||
value: number,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): void {
|
||||
if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
|
||||
throw new RangeError(`${name} must be between ${minimum} and ${maximum}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One-page, non-overlapping lost recovery cadence. It deliberately owns no
|
||||
* multi-page loop: a slow edge device pays at most one bounded scan per tick.
|
||||
*/
|
||||
export class RunLostRetryLifecycle {
|
||||
private readonly intervalMs: number;
|
||||
private readonly initialDelayMs: number;
|
||||
private readonly stopTimeoutMs: number;
|
||||
private readonly pageSize: number;
|
||||
private readonly scheduler: RunLostRetryLifecycleScheduler;
|
||||
private readonly onCycle?: (summary: RunLostRetryScanSummary) => void;
|
||||
private readonly onError?: (error: unknown) => void;
|
||||
private started = false;
|
||||
private timer?: ScheduledTimer;
|
||||
private inFlight?: Promise<void>;
|
||||
|
||||
constructor(
|
||||
private readonly scanner: Pick<RunLostRetryScanner, 'scan'>,
|
||||
options: RunLostRetryLifecycleOptions,
|
||||
) {
|
||||
this.intervalMs = options.intervalMs;
|
||||
this.initialDelayMs = options.initialDelayMs ?? 0;
|
||||
this.stopTimeoutMs = options.stopTimeoutMs ?? 5_000;
|
||||
this.pageSize = options.pageSize ?? 16;
|
||||
this.scheduler = options.scheduler ?? defaultScheduler;
|
||||
this.onCycle = options.onCycle;
|
||||
this.onError = options.onError;
|
||||
assertIntegerBetween(
|
||||
'intervalMs',
|
||||
this.intervalMs,
|
||||
MIN_RUN_LOST_RETRY_INTERVAL_MS,
|
||||
MAX_RUN_LOST_RETRY_INTERVAL_MS,
|
||||
);
|
||||
assertIntegerBetween(
|
||||
'initialDelayMs',
|
||||
this.initialDelayMs,
|
||||
0,
|
||||
MAX_RUN_LOST_RETRY_INITIAL_DELAY_MS,
|
||||
);
|
||||
assertIntegerBetween(
|
||||
'stopTimeoutMs',
|
||||
this.stopTimeoutMs,
|
||||
1,
|
||||
MAX_RUN_LOST_RETRY_STOP_TIMEOUT_MS,
|
||||
);
|
||||
assertIntegerBetween('pageSize', this.pageSize, 1, 64);
|
||||
}
|
||||
|
||||
start(): boolean {
|
||||
if (this.started || this.inFlight) return false;
|
||||
this.started = true;
|
||||
this.schedule(this.initialDelayMs);
|
||||
return true;
|
||||
}
|
||||
|
||||
async stop(): Promise<RunLostRetryStopResult> {
|
||||
this.started = false;
|
||||
if (this.timer) {
|
||||
this.scheduler.clearTimeout(this.timer);
|
||||
this.timer = undefined;
|
||||
}
|
||||
const inFlight = this.inFlight;
|
||||
if (!inFlight) return 'drained';
|
||||
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
const result = await Promise.race<RunLostRetryStopResult>([
|
||||
inFlight.then(() => 'drained' as const),
|
||||
new Promise<'timed_out'>((resolve) => {
|
||||
timeout = setTimeout(() => resolve('timed_out'), this.stopTimeoutMs);
|
||||
}),
|
||||
]);
|
||||
if (timeout) clearTimeout(timeout);
|
||||
return result;
|
||||
}
|
||||
|
||||
private schedule(delayMs: number): void {
|
||||
if (!this.started || this.timer) return;
|
||||
const timer = this.scheduler.setTimeout(() => {
|
||||
if (this.timer === timer) this.timer = undefined;
|
||||
this.run();
|
||||
}, delayMs);
|
||||
this.timer = timer;
|
||||
timer.unref?.();
|
||||
}
|
||||
|
||||
private run(): void {
|
||||
if (!this.started || this.inFlight) return;
|
||||
const inFlight = this.scanner
|
||||
.scan({ limit: this.pageSize })
|
||||
.then((summary) => this.notifyCycle(summary))
|
||||
.catch((error) => this.notifyError(error))
|
||||
.then(() => undefined)
|
||||
.finally(() => {
|
||||
if (this.inFlight === inFlight) this.inFlight = undefined;
|
||||
if (this.started) this.schedule(this.intervalMs);
|
||||
});
|
||||
this.inFlight = inFlight;
|
||||
}
|
||||
|
||||
private notifyCycle(summary: RunLostRetryScanSummary): void {
|
||||
try {
|
||||
this.onCycle?.(summary);
|
||||
} catch (error) {
|
||||
this.notifyError(error);
|
||||
}
|
||||
}
|
||||
|
||||
private notifyError(error: unknown): void {
|
||||
try {
|
||||
this.onError?.(error);
|
||||
} catch {
|
||||
// Diagnostics must never create another scheduler failure loop.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { RunLostRetryStatus } from './runLostRetryService';
|
||||
import type {
|
||||
RunLostRetrySource,
|
||||
RunLostRetryCandidate,
|
||||
} from '../ports/runLostRetrySource';
|
||||
import { MAX_RUN_LOST_RETRY_PAGE_SIZE } from '../ports/runLostRetrySource';
|
||||
|
||||
export interface RunLostRetryReconciler {
|
||||
reconcile(runId: string): Promise<{ status: RunLostRetryStatus }>;
|
||||
}
|
||||
|
||||
export interface RunLostRetryScanSummary {
|
||||
observedAtMs: number;
|
||||
scanned: number;
|
||||
failed: number;
|
||||
truncated: boolean;
|
||||
counts: Readonly<Partial<Record<RunLostRetryStatus, number>>>;
|
||||
failures: readonly { runId: string; reason: 'reconcile_failed' }[];
|
||||
}
|
||||
|
||||
export class RunLostRetryScanner {
|
||||
private readonly clock: { now(): number };
|
||||
|
||||
constructor(
|
||||
private readonly source: RunLostRetrySource,
|
||||
private readonly reconciler: RunLostRetryReconciler,
|
||||
options: { clock?: { now(): number } } = {},
|
||||
) {
|
||||
this.clock = options.clock ?? Date;
|
||||
}
|
||||
|
||||
async scan(
|
||||
options: { limit?: number } = {},
|
||||
): Promise<RunLostRetryScanSummary> {
|
||||
const observedAtMs = this.clock.now();
|
||||
const limit = options.limit ?? 16;
|
||||
if (!Number.isSafeInteger(observedAtMs) || observedAtMs < 0) {
|
||||
throw new RangeError('observedAtMs must be a non-negative safe integer');
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(limit) ||
|
||||
limit < 1 ||
|
||||
limit > MAX_RUN_LOST_RETRY_PAGE_SIZE
|
||||
) {
|
||||
throw new RangeError(
|
||||
`limit must be between 1 and ${MAX_RUN_LOST_RETRY_PAGE_SIZE}`,
|
||||
);
|
||||
}
|
||||
const candidates = await this.source.listCandidates({
|
||||
observedAtMs,
|
||||
limit,
|
||||
});
|
||||
this.assertPage(candidates, observedAtMs, limit);
|
||||
const counts: Partial<Record<RunLostRetryStatus, number>> = {};
|
||||
const failures: { runId: string; reason: 'reconcile_failed' }[] = [];
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const result = await this.reconciler.reconcile(candidate.runId);
|
||||
counts[result.status] = (counts[result.status] ?? 0) + 1;
|
||||
} catch {
|
||||
failures.push({
|
||||
runId: candidate.runId,
|
||||
reason: 'reconcile_failed',
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
observedAtMs,
|
||||
scanned: candidates.length,
|
||||
failed: failures.length,
|
||||
truncated: candidates.length === limit,
|
||||
counts,
|
||||
failures,
|
||||
};
|
||||
}
|
||||
|
||||
private assertPage(
|
||||
candidates: readonly RunLostRetryCandidate[],
|
||||
observedAtMs: number,
|
||||
limit: number,
|
||||
): void {
|
||||
if (candidates.length > limit) {
|
||||
throw new TypeError('Lost retry source exceeded page size');
|
||||
}
|
||||
let previous: RunLostRetryCandidate | undefined;
|
||||
for (const candidate of candidates) {
|
||||
if (
|
||||
!candidate.runId ||
|
||||
(candidate.phase !== 'lost' && candidate.phase !== 'retry_wait') ||
|
||||
!Number.isSafeInteger(candidate.availableAtMs) ||
|
||||
candidate.availableAtMs < 0 ||
|
||||
candidate.availableAtMs > observedAtMs
|
||||
) {
|
||||
throw new TypeError('Lost retry source returned an invalid candidate');
|
||||
}
|
||||
if (
|
||||
previous &&
|
||||
(candidate.availableAtMs < previous.availableAtMs ||
|
||||
(candidate.availableAtMs === previous.availableAtMs &&
|
||||
candidate.runId <= previous.runId))
|
||||
) {
|
||||
throw new TypeError('Lost retry source page is not strictly ordered');
|
||||
}
|
||||
previous = candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
import { v7 as uuidV7 } from 'uuid';
|
||||
import type {
|
||||
RunAttemptRecord,
|
||||
RunEventRecord,
|
||||
RunRecord,
|
||||
} from '../domain/run';
|
||||
import {
|
||||
assertRunRetryPolicyRecord,
|
||||
runRetryDelayMs,
|
||||
type RunRetryPolicyRecord,
|
||||
} from '../domain/runRetryPolicy';
|
||||
import {
|
||||
reserveRunEvent,
|
||||
transitionRun,
|
||||
type RunDomainEventDraft,
|
||||
} from '../domain/runStateMachine';
|
||||
import { RunVersionConflictError } from '../domain/stateMachineErrors';
|
||||
import type {
|
||||
RunRepository,
|
||||
RunRepositoryTransaction,
|
||||
} from '../ports/runRepository';
|
||||
|
||||
export type RunLostRetryStatus =
|
||||
| 'scheduled'
|
||||
| 'requeued'
|
||||
| 'cancelled'
|
||||
| 'failed_disabled'
|
||||
| 'failed_unsafe'
|
||||
| 'failed_exhausted'
|
||||
| 'not_due'
|
||||
| 'not_eligible'
|
||||
| 'not_found';
|
||||
|
||||
export interface RunLostRetryResult {
|
||||
status: RunLostRetryStatus;
|
||||
run?: RunRecord;
|
||||
attempt?: RunAttemptRecord;
|
||||
policy?: RunRetryPolicyRecord;
|
||||
events?: readonly RunEventRecord[];
|
||||
}
|
||||
|
||||
export class RunLostRetryTargetError extends Error {
|
||||
readonly code = 'RUN_LOST_RETRY_TARGET_INCONSISTENT';
|
||||
|
||||
constructor(message: string) {
|
||||
super(`Run lost retry target is inconsistent: ${message}`);
|
||||
this.name = 'RunLostRetryTargetError';
|
||||
}
|
||||
}
|
||||
|
||||
export class RunLostRetryService {
|
||||
private readonly clock: { now(): number };
|
||||
private readonly createId: () => string;
|
||||
|
||||
constructor(
|
||||
private readonly repository: RunRepository,
|
||||
options: { clock?: { now(): number }; createId?: () => string } = {},
|
||||
) {
|
||||
this.clock = options.clock ?? Date;
|
||||
this.createId = options.createId ?? uuidV7;
|
||||
}
|
||||
|
||||
reconcile(runId: string): Promise<RunLostRetryResult> {
|
||||
if (!runId) throw new TypeError('runId is required');
|
||||
const observedAtMs = this.clock.now();
|
||||
if (!Number.isSafeInteger(observedAtMs) || observedAtMs < 0) {
|
||||
throw new RangeError('observedAtMs must be a non-negative safe integer');
|
||||
}
|
||||
return this.repository.transaction((transaction) =>
|
||||
this.reconcileInTransaction(transaction, runId, observedAtMs),
|
||||
);
|
||||
}
|
||||
|
||||
private async reconcileInTransaction(
|
||||
transaction: RunRepositoryTransaction,
|
||||
runId: string,
|
||||
observedAtMs: number,
|
||||
): Promise<RunLostRetryResult> {
|
||||
const run = await transaction.findRunById(runId);
|
||||
if (!run) return { status: 'not_found' };
|
||||
if (
|
||||
run.executionOwner !== 'runtime' ||
|
||||
(run.status !== 'lost' && run.status !== 'retry_wait')
|
||||
) {
|
||||
return { status: 'not_eligible', run };
|
||||
}
|
||||
const [attempt, policy] = await Promise.all([
|
||||
transaction.findLatestAttemptByRunId(run.id),
|
||||
transaction.findRetryPolicyByRunId(run.id),
|
||||
]);
|
||||
if (!attempt || attempt.runId !== run.id || attempt.status !== 'lost') {
|
||||
throw new RunLostRetryTargetError('latest Attempt is not lost');
|
||||
}
|
||||
if (policy) assertRunRetryPolicyRecord(policy);
|
||||
|
||||
const atMs = Math.max(
|
||||
observedAtMs,
|
||||
run.createdAtMs,
|
||||
run.startedAtMs ?? 0,
|
||||
attempt.createdAtMs,
|
||||
attempt.startedAtMs ?? 0,
|
||||
attempt.finishedAtMs ?? 0,
|
||||
policy?.updatedAtMs ?? 0,
|
||||
);
|
||||
if (run.cancelRequestedAtMs !== undefined) {
|
||||
return this.finishRun(
|
||||
transaction,
|
||||
run,
|
||||
attempt,
|
||||
policy,
|
||||
atMs,
|
||||
'cancelled',
|
||||
'cancelled',
|
||||
'RUN_CANCELLED_DURING_LOST_RECOVERY',
|
||||
'Cancellation won before a replacement Attempt was created',
|
||||
);
|
||||
}
|
||||
if (!policy || !policy.retryOnLost || policy.maxAttempts <= 1) {
|
||||
return this.finishRun(
|
||||
transaction,
|
||||
run,
|
||||
attempt,
|
||||
policy,
|
||||
atMs,
|
||||
'failed_disabled',
|
||||
'failed',
|
||||
'RUN_LOST_RETRY_DISABLED',
|
||||
'Run was lost and automatic retry was not enabled at admission',
|
||||
);
|
||||
}
|
||||
if (policy.safety === 'unknown') {
|
||||
return this.finishRun(
|
||||
transaction,
|
||||
run,
|
||||
attempt,
|
||||
policy,
|
||||
atMs,
|
||||
'failed_unsafe',
|
||||
'failed',
|
||||
'RUN_LOST_RETRY_UNSAFE',
|
||||
'Run was lost but execution safety was not declared',
|
||||
);
|
||||
}
|
||||
if (attempt.attempt >= policy.maxAttempts) {
|
||||
return this.finishRun(
|
||||
transaction,
|
||||
run,
|
||||
attempt,
|
||||
policy,
|
||||
atMs,
|
||||
'failed_exhausted',
|
||||
'failed',
|
||||
'RUN_LOST_RETRY_EXHAUSTED',
|
||||
'Run exhausted its admitted automatic retry attempts',
|
||||
);
|
||||
}
|
||||
if (run.status === 'lost') {
|
||||
return this.schedule(transaction, run, attempt, policy, atMs);
|
||||
}
|
||||
if (
|
||||
policy.nextAttemptAtMs === undefined ||
|
||||
policy.nextAttemptAtMs > observedAtMs
|
||||
) {
|
||||
return { status: 'not_due', run, attempt, policy };
|
||||
}
|
||||
return this.requeue(transaction, run, attempt, policy, atMs);
|
||||
}
|
||||
|
||||
private async schedule(
|
||||
transaction: RunRepositoryTransaction,
|
||||
run: RunRecord,
|
||||
attempt: RunAttemptRecord,
|
||||
policy: RunRetryPolicyRecord,
|
||||
atMs: number,
|
||||
): Promise<RunLostRetryResult> {
|
||||
const lostAtMs = Math.max(
|
||||
run.createdAtMs,
|
||||
attempt.createdAtMs,
|
||||
attempt.finishedAtMs ?? 0,
|
||||
);
|
||||
const nextAttemptAtMs = lostAtMs + runRetryDelayMs(policy, attempt.attempt);
|
||||
if (!Number.isSafeInteger(nextAttemptAtMs)) {
|
||||
throw new RunLostRetryTargetError('next Attempt time overflowed');
|
||||
}
|
||||
const decision = transitionRun(run, {
|
||||
to: 'retry_wait',
|
||||
expectedVersion: run.version,
|
||||
atMs,
|
||||
errorCode: 'RUN_LOST_RETRY_SCHEDULED',
|
||||
errorSummary:
|
||||
'A fresh Attempt will be created after the admitted backoff',
|
||||
});
|
||||
const nextPolicy: RunRetryPolicyRecord = {
|
||||
...policy,
|
||||
nextAttemptAtMs,
|
||||
version: policy.version + 1,
|
||||
updatedAtMs: atMs,
|
||||
};
|
||||
if (
|
||||
!(await transaction.compareAndSetRun(decision.run, run.version)) ||
|
||||
!(await transaction.compareAndSetRetryPolicy(nextPolicy, policy.version))
|
||||
) {
|
||||
throw new RunVersionConflictError(run.id, run.version, run.version);
|
||||
}
|
||||
const event = this.event(
|
||||
decision.run,
|
||||
decision.event,
|
||||
attempt.id,
|
||||
`run-lost-retry-scheduled:${attempt.id}`,
|
||||
atMs,
|
||||
{
|
||||
attempt: attempt.attempt,
|
||||
max_attempts: policy.maxAttempts,
|
||||
safety: policy.safety,
|
||||
next_attempt_at_ms: nextAttemptAtMs,
|
||||
},
|
||||
);
|
||||
await transaction.appendEvent(event);
|
||||
return {
|
||||
status: 'scheduled',
|
||||
run: decision.run,
|
||||
attempt,
|
||||
policy: nextPolicy,
|
||||
events: [event],
|
||||
};
|
||||
}
|
||||
|
||||
private async requeue(
|
||||
transaction: RunRepositoryTransaction,
|
||||
run: RunRecord,
|
||||
lostAttempt: RunAttemptRecord,
|
||||
policy: RunRetryPolicyRecord,
|
||||
atMs: number,
|
||||
): Promise<RunLostRetryResult> {
|
||||
const attempt: RunAttemptRecord = {
|
||||
id: this.createId(),
|
||||
runId: run.id,
|
||||
attempt: lostAttempt.attempt + 1,
|
||||
status: 'claimed',
|
||||
executorType: lostAttempt.executorType,
|
||||
callbackSequence: 0,
|
||||
createdAtMs: atMs,
|
||||
};
|
||||
const queued = transitionRun(run, {
|
||||
to: 'queued',
|
||||
expectedVersion: run.version,
|
||||
atMs,
|
||||
});
|
||||
const nextPolicy: RunRetryPolicyRecord = {
|
||||
...policy,
|
||||
version: policy.version + 1,
|
||||
updatedAtMs: atMs,
|
||||
};
|
||||
delete nextPolicy.nextAttemptAtMs;
|
||||
if (
|
||||
!(await transaction.compareAndSetRun(queued.run, run.version)) ||
|
||||
!(await transaction.compareAndSetRetryPolicy(nextPolicy, policy.version))
|
||||
) {
|
||||
throw new RunVersionConflictError(run.id, run.version, run.version);
|
||||
}
|
||||
await transaction.insertAttempt(attempt);
|
||||
const queuedEvent = this.event(
|
||||
queued.run,
|
||||
queued.event,
|
||||
attempt.id,
|
||||
`run-lost-retry-queued:${attempt.id}`,
|
||||
atMs,
|
||||
{ previous_attempt_id: lostAttempt.id },
|
||||
);
|
||||
await transaction.appendEvent(queuedEvent);
|
||||
|
||||
const claimed = reserveRunEvent(queued.run, queued.run.version);
|
||||
if (
|
||||
!(await transaction.compareAndSetRun(claimed.run, queued.run.version))
|
||||
) {
|
||||
throw new RunVersionConflictError(
|
||||
run.id,
|
||||
queued.run.version,
|
||||
queued.run.version,
|
||||
);
|
||||
}
|
||||
const claimedEvent = this.event(
|
||||
claimed.run,
|
||||
{
|
||||
sequence: claimed.sequence,
|
||||
type: 'attempt.claimed',
|
||||
payload: {
|
||||
attempt: attempt.attempt,
|
||||
executor_type: attempt.executorType,
|
||||
version: claimed.run.version,
|
||||
},
|
||||
},
|
||||
attempt.id,
|
||||
`run-lost-retry-attempt-claimed:${attempt.id}`,
|
||||
atMs,
|
||||
{ previous_attempt_id: lostAttempt.id },
|
||||
);
|
||||
await transaction.appendEvent(claimedEvent);
|
||||
return {
|
||||
status: 'requeued',
|
||||
run: claimed.run,
|
||||
attempt,
|
||||
policy: nextPolicy,
|
||||
events: [queuedEvent, claimedEvent],
|
||||
};
|
||||
}
|
||||
|
||||
private async finishRun(
|
||||
transaction: RunRepositoryTransaction,
|
||||
run: RunRecord,
|
||||
attempt: RunAttemptRecord,
|
||||
policy: RunRetryPolicyRecord | null,
|
||||
atMs: number,
|
||||
status:
|
||||
| 'cancelled'
|
||||
| 'failed_disabled'
|
||||
| 'failed_unsafe'
|
||||
| 'failed_exhausted',
|
||||
to: 'cancelled' | 'failed',
|
||||
errorCode: string,
|
||||
errorSummary: string,
|
||||
): Promise<RunLostRetryResult> {
|
||||
const decision = transitionRun(run, {
|
||||
to,
|
||||
expectedVersion: run.version,
|
||||
atMs,
|
||||
errorCode,
|
||||
errorSummary,
|
||||
});
|
||||
if (!(await transaction.compareAndSetRun(decision.run, run.version))) {
|
||||
throw new RunVersionConflictError(run.id, run.version, run.version);
|
||||
}
|
||||
let nextPolicy = policy ?? undefined;
|
||||
if (policy?.nextAttemptAtMs !== undefined) {
|
||||
nextPolicy = {
|
||||
...policy,
|
||||
version: policy.version + 1,
|
||||
updatedAtMs: atMs,
|
||||
};
|
||||
delete nextPolicy.nextAttemptAtMs;
|
||||
if (
|
||||
!(await transaction.compareAndSetRetryPolicy(
|
||||
nextPolicy,
|
||||
policy.version,
|
||||
))
|
||||
) {
|
||||
throw new RunLostRetryTargetError('retry policy update lost its race');
|
||||
}
|
||||
}
|
||||
const event = this.event(
|
||||
decision.run,
|
||||
decision.event,
|
||||
attempt.id,
|
||||
`run-lost-retry-${status}:${attempt.id}`,
|
||||
atMs,
|
||||
{ attempt: attempt.attempt },
|
||||
);
|
||||
await transaction.appendEvent(event);
|
||||
return {
|
||||
status,
|
||||
run: decision.run,
|
||||
attempt,
|
||||
...(nextPolicy === undefined ? {} : { policy: nextPolicy }),
|
||||
events: [event],
|
||||
};
|
||||
}
|
||||
|
||||
private event(
|
||||
run: RunRecord,
|
||||
draft: RunDomainEventDraft,
|
||||
attemptId: string,
|
||||
dedupeKey: string,
|
||||
createdAtMs: number,
|
||||
extraPayload: Readonly<Record<string, unknown>>,
|
||||
): RunEventRecord {
|
||||
return {
|
||||
id: this.createId(),
|
||||
runId: run.id,
|
||||
sequence: draft.sequence,
|
||||
type: draft.type,
|
||||
dedupeKey,
|
||||
actorType: 'reconciler',
|
||||
attemptId,
|
||||
payload: { ...draft.payload, ...extraPayload },
|
||||
createdAtMs,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import {
|
||||
assertWorkerConcurrency,
|
||||
assertWorkerId,
|
||||
assertWorkerSessionId,
|
||||
hashWorkerCapabilities,
|
||||
serializeWorkerCapabilities,
|
||||
type WorkerRecord,
|
||||
} from '../domain/worker';
|
||||
import type {
|
||||
HeartbeatWorkerRequest,
|
||||
RegisterWorkerRequest,
|
||||
TransitionWorkerRequest,
|
||||
WorkerControlPlaneClient,
|
||||
} from '../ports/workerControlPlaneClient';
|
||||
import type { WorkerRegistryRepository } from '../ports/workerRegistryRepository';
|
||||
|
||||
export const MIN_WORKER_LEASE_DURATION_MS = 5_000;
|
||||
export const MAX_WORKER_LEASE_DURATION_MS = 10 * 60_000;
|
||||
|
||||
export interface AuthenticatedWorkerPrincipal {
|
||||
workerId: string;
|
||||
}
|
||||
|
||||
export interface WorkerControlServiceOptions {
|
||||
leaseDurationMs?: number;
|
||||
clock?: { now(): number };
|
||||
}
|
||||
|
||||
export class WorkerPrincipalMismatchError extends Error {
|
||||
constructor() {
|
||||
super('Authenticated Worker principal does not match the requested worker');
|
||||
this.name = 'WorkerPrincipalMismatchError';
|
||||
}
|
||||
}
|
||||
|
||||
function assertIntegerBetween(
|
||||
name: string,
|
||||
value: number,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): void {
|
||||
if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
|
||||
throw new RangeError(`${name} must be between ${minimum} and ${maximum}`);
|
||||
}
|
||||
}
|
||||
|
||||
function safeExpiration(nowMs: number, durationMs: number): number {
|
||||
assertIntegerBetween('nowMs', nowMs, 0, Number.MAX_SAFE_INTEGER);
|
||||
if (nowMs > Number.MAX_SAFE_INTEGER - durationMs) {
|
||||
throw new RangeError('Worker lease expiration exceeds the safe range');
|
||||
}
|
||||
return nowMs + durationMs;
|
||||
}
|
||||
|
||||
export class WorkerControlService {
|
||||
private readonly leaseDurationMs: number;
|
||||
private readonly clock: { now(): number };
|
||||
|
||||
constructor(
|
||||
private readonly repository: WorkerRegistryRepository,
|
||||
options: WorkerControlServiceOptions = {},
|
||||
) {
|
||||
this.leaseDurationMs = options.leaseDurationMs ?? 45_000;
|
||||
this.clock = options.clock ?? Date;
|
||||
assertIntegerBetween(
|
||||
'leaseDurationMs',
|
||||
this.leaseDurationMs,
|
||||
MIN_WORKER_LEASE_DURATION_MS,
|
||||
MAX_WORKER_LEASE_DURATION_MS,
|
||||
);
|
||||
}
|
||||
|
||||
async register(
|
||||
principal: AuthenticatedWorkerPrincipal,
|
||||
request: RegisterWorkerRequest,
|
||||
): Promise<WorkerRecord> {
|
||||
this.assertPrincipal(principal, request.workerId);
|
||||
assertWorkerSessionId(request.sessionId);
|
||||
assertWorkerConcurrency(request.maxConcurrentRuns, request.availableSlots);
|
||||
const capabilitiesJson = serializeWorkerCapabilities(request.capabilities);
|
||||
const nowMs = this.clock.now();
|
||||
const result = await this.repository.register({
|
||||
workerId: request.workerId,
|
||||
sessionId: request.sessionId,
|
||||
capabilitiesJson,
|
||||
capabilitiesHash: hashWorkerCapabilities(capabilitiesJson),
|
||||
maxConcurrentRuns: request.maxConcurrentRuns,
|
||||
availableSlots: request.availableSlots,
|
||||
registeredAtMs: nowMs,
|
||||
leaseExpiresAtMs: safeExpiration(nowMs, this.leaseDurationMs),
|
||||
});
|
||||
return result.worker;
|
||||
}
|
||||
|
||||
async heartbeat(
|
||||
principal: AuthenticatedWorkerPrincipal,
|
||||
request: HeartbeatWorkerRequest,
|
||||
): Promise<WorkerRecord> {
|
||||
this.assertPrincipal(principal, request.workerId);
|
||||
const nowMs = this.clock.now();
|
||||
return this.repository.heartbeat({
|
||||
...request,
|
||||
heartbeatAtMs: nowMs,
|
||||
leaseExpiresAtMs: safeExpiration(nowMs, this.leaseDurationMs),
|
||||
});
|
||||
}
|
||||
|
||||
async drain(
|
||||
principal: AuthenticatedWorkerPrincipal,
|
||||
request: TransitionWorkerRequest,
|
||||
): Promise<WorkerRecord> {
|
||||
return this.transition(principal, request, 'draining');
|
||||
}
|
||||
|
||||
async disconnect(
|
||||
principal: AuthenticatedWorkerPrincipal,
|
||||
request: TransitionWorkerRequest,
|
||||
): Promise<WorkerRecord> {
|
||||
return this.transition(principal, request, 'offline');
|
||||
}
|
||||
|
||||
async listAvailable(
|
||||
options: {
|
||||
afterWorkerId?: string;
|
||||
limit?: number;
|
||||
} = {},
|
||||
) {
|
||||
return this.repository.listAvailable({
|
||||
observedAtMs: this.clock.now(),
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
private async transition(
|
||||
principal: AuthenticatedWorkerPrincipal,
|
||||
request: TransitionWorkerRequest,
|
||||
status: 'draining' | 'offline',
|
||||
): Promise<WorkerRecord> {
|
||||
this.assertPrincipal(principal, request.workerId);
|
||||
return this.repository.transition({
|
||||
...request,
|
||||
status,
|
||||
transitionedAtMs: this.clock.now(),
|
||||
});
|
||||
}
|
||||
|
||||
private assertPrincipal(
|
||||
principal: AuthenticatedWorkerPrincipal,
|
||||
workerId: string,
|
||||
): void {
|
||||
assertWorkerId(principal.workerId);
|
||||
assertWorkerId(workerId);
|
||||
if (principal.workerId !== workerId) {
|
||||
throw new WorkerPrincipalMismatchError();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds an authenticated transport principal to the application service. A
|
||||
* future HTTP/gRPC adapter must establish this principal before constructing
|
||||
* the client; request payloads never get to self-authenticate a Worker id.
|
||||
*/
|
||||
export class BoundWorkerControlPlaneClient implements WorkerControlPlaneClient {
|
||||
constructor(
|
||||
private readonly service: WorkerControlService,
|
||||
private readonly principal: AuthenticatedWorkerPrincipal,
|
||||
) {}
|
||||
|
||||
register(request: RegisterWorkerRequest): Promise<WorkerRecord> {
|
||||
return this.service.register(this.principal, request);
|
||||
}
|
||||
|
||||
heartbeat(request: HeartbeatWorkerRequest): Promise<WorkerRecord> {
|
||||
return this.service.heartbeat(this.principal, request);
|
||||
}
|
||||
|
||||
drain(request: TransitionWorkerRequest): Promise<WorkerRecord> {
|
||||
return this.service.drain(this.principal, request);
|
||||
}
|
||||
|
||||
disconnect(request: TransitionWorkerRequest): Promise<WorkerRecord> {
|
||||
return this.service.disconnect(this.principal, request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import type { ExecutionStopReason } from '../domain/execution';
|
||||
import {
|
||||
cloneWorkerExecutionOfferJournalRecord,
|
||||
type WorkerExecutionOfferJournalRecord,
|
||||
} from '../domain/workerExecutionOffer';
|
||||
import { assertRunDispatchLeaseRecord } from '../domain/runDispatchLease';
|
||||
import { createRunDispatchOfferId } from '../domain/runDispatchOffer';
|
||||
import type { PersistedExecutionController } from '../ports/persistedExecutionController';
|
||||
import type { WorkerExecutionOfferJournal } from '../ports/workerExecutionOfferJournal';
|
||||
import type {
|
||||
WorkerRunLeaseLoss,
|
||||
WorkerRunLeaseLossReason,
|
||||
} from './workerRunLeaseLifecycle';
|
||||
|
||||
export type WorkerExecutionLeaseLossActionStatus =
|
||||
| 'not_found'
|
||||
| 'authority_mismatch'
|
||||
| 'no_local_execution'
|
||||
| 'already_completed'
|
||||
| 'already_stopped'
|
||||
| 'already_unverified'
|
||||
| 'stop_acknowledged'
|
||||
| 'stop_unverified';
|
||||
|
||||
export interface WorkerExecutionLeaseLossActionResult {
|
||||
offerId: string;
|
||||
attemptId: string;
|
||||
reason: WorkerRunLeaseLossReason;
|
||||
status: WorkerExecutionLeaseLossActionStatus;
|
||||
stopStatus?: Awaited<
|
||||
ReturnType<PersistedExecutionController['stop']>
|
||||
>['status'];
|
||||
}
|
||||
|
||||
const LOSS_REASONS = new Set<WorkerRunLeaseLossReason>([
|
||||
'lease_expired',
|
||||
'fenced',
|
||||
'worker_session_replaced',
|
||||
'worker_unavailable',
|
||||
'invalid_renewal',
|
||||
]);
|
||||
|
||||
const EXECUTION_OWNERSHIP_STATES = new Set<
|
||||
WorkerExecutionOfferJournalRecord['state']
|
||||
>(['launching', 'started', 'running_acknowledged', 'recovery_required']);
|
||||
|
||||
/**
|
||||
* Fails closed after a Worker loses a Run lease. It may stop only the durable
|
||||
* local identity bound to the exact lost authority and never mutates the
|
||||
* control plane. Server-owned lease expiry reconciliation decides Run/Attempt
|
||||
* lost and any later retry.
|
||||
*/
|
||||
export class WorkerExecutionLeaseLossCoordinator {
|
||||
private readonly clock: { now(): number };
|
||||
private readonly inFlight = new Map<
|
||||
string,
|
||||
Promise<WorkerExecutionLeaseLossActionResult>
|
||||
>();
|
||||
|
||||
constructor(
|
||||
private readonly journal: Pick<
|
||||
WorkerExecutionOfferJournal,
|
||||
'read' | 'replace'
|
||||
>,
|
||||
private readonly controller: PersistedExecutionController,
|
||||
options: { clock?: { now(): number } } = {},
|
||||
) {
|
||||
this.clock = options.clock ?? Date;
|
||||
}
|
||||
|
||||
reconcile(
|
||||
candidate: WorkerRunLeaseLoss,
|
||||
): Promise<WorkerExecutionLeaseLossActionResult> {
|
||||
assertRunDispatchLeaseRecord(candidate.lease);
|
||||
if (!LOSS_REASONS.has(candidate.reason)) {
|
||||
throw new TypeError('Worker Run lease loss reason is invalid');
|
||||
}
|
||||
const loss: WorkerRunLeaseLoss = {
|
||||
lease: { ...candidate.lease },
|
||||
reason: candidate.reason,
|
||||
...(candidate.error === undefined ? {} : { error: candidate.error }),
|
||||
};
|
||||
const offerId = createRunDispatchOfferId(loss.lease);
|
||||
const active = this.inFlight.get(offerId);
|
||||
if (active) return active;
|
||||
const operation = this.process(offerId, loss).finally(() => {
|
||||
if (this.inFlight.get(offerId) === operation) {
|
||||
this.inFlight.delete(offerId);
|
||||
}
|
||||
});
|
||||
this.inFlight.set(offerId, operation);
|
||||
return operation;
|
||||
}
|
||||
|
||||
private async process(
|
||||
offerId: string,
|
||||
loss: WorkerRunLeaseLoss,
|
||||
): Promise<WorkerExecutionLeaseLossActionResult> {
|
||||
const base = {
|
||||
offerId,
|
||||
attemptId: loss.lease.attemptId,
|
||||
reason: loss.reason,
|
||||
} as const;
|
||||
const record = await this.journal.read(offerId);
|
||||
if (!record) return { ...base, status: 'not_found' };
|
||||
if (!this.sameAuthority(record, loss)) {
|
||||
return { ...base, status: 'authority_mismatch' };
|
||||
}
|
||||
if (record.state === 'completion_acknowledged') {
|
||||
return { ...base, status: 'already_completed' };
|
||||
}
|
||||
if (
|
||||
record.state === 'recovery_required' &&
|
||||
record.recoveryReason === 'lease_lost_local_execution_stopped'
|
||||
) {
|
||||
return { ...base, status: 'already_stopped' };
|
||||
}
|
||||
if (
|
||||
record.state === 'recovery_required' &&
|
||||
record.recoveryReason === 'lease_lost_local_execution_unverified'
|
||||
) {
|
||||
return { ...base, status: 'already_unverified' };
|
||||
}
|
||||
if (!EXECUTION_OWNERSHIP_STATES.has(record.state)) {
|
||||
return { ...base, status: 'no_local_execution' };
|
||||
}
|
||||
if (!record.executorHandle) {
|
||||
await this.mark(record, 'lease_lost_local_execution_unverified');
|
||||
return { ...base, status: 'stop_unverified' };
|
||||
}
|
||||
|
||||
const reason: ExecutionStopReason = {
|
||||
kind: 'reconcile',
|
||||
requestedAtMs: this.now(),
|
||||
};
|
||||
const stopped = await this.controller.stop({
|
||||
durableHandle: record.executorHandle,
|
||||
reason,
|
||||
});
|
||||
const acknowledged =
|
||||
stopped.status === 'termination_requested' ||
|
||||
stopped.status === 'already_exited';
|
||||
const recoveryReason = acknowledged
|
||||
? 'lease_lost_local_execution_stopped'
|
||||
: 'lease_lost_local_execution_unverified';
|
||||
await this.mark(record, recoveryReason);
|
||||
return {
|
||||
...base,
|
||||
status: acknowledged ? 'stop_acknowledged' : 'stop_unverified',
|
||||
stopStatus: stopped.status,
|
||||
};
|
||||
}
|
||||
|
||||
private sameAuthority(
|
||||
record: WorkerExecutionOfferJournalRecord,
|
||||
loss: WorkerRunLeaseLoss,
|
||||
): boolean {
|
||||
const lease = record.offer.lease;
|
||||
return (
|
||||
lease.attemptId === loss.lease.attemptId &&
|
||||
lease.runId === loss.lease.runId &&
|
||||
lease.workerId === loss.lease.workerId &&
|
||||
lease.workerSessionId === loss.lease.workerSessionId &&
|
||||
lease.workerGeneration === loss.lease.workerGeneration &&
|
||||
lease.leaseGeneration === loss.lease.leaseGeneration &&
|
||||
lease.leaseToken === loss.lease.leaseToken
|
||||
);
|
||||
}
|
||||
|
||||
private async mark(
|
||||
previous: WorkerExecutionOfferJournalRecord,
|
||||
recoveryReason:
|
||||
| 'lease_lost_local_execution_stopped'
|
||||
| 'lease_lost_local_execution_unverified',
|
||||
): Promise<void> {
|
||||
const updated = cloneWorkerExecutionOfferJournalRecord({
|
||||
...previous,
|
||||
schemaVersion: 1,
|
||||
revision: previous.revision + 1,
|
||||
state: 'recovery_required',
|
||||
recoveryReason,
|
||||
updatedAtMs: Math.max(this.now(), previous.updatedAtMs),
|
||||
});
|
||||
try {
|
||||
await this.journal.replace(updated, previous.revision);
|
||||
} catch (error) {
|
||||
const current = await this.journal.read(previous.offer.offerId);
|
||||
if (
|
||||
current?.state === 'completion_acknowledged' ||
|
||||
(current?.state === 'recovery_required' &&
|
||||
(current.recoveryReason ===
|
||||
'lease_lost_local_execution_stopped' ||
|
||||
current.recoveryReason ===
|
||||
'lease_lost_local_execution_unverified'))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private now(): number {
|
||||
const nowMs = this.clock.now();
|
||||
if (!Number.isSafeInteger(nowMs) || nowMs < 0) {
|
||||
throw new TypeError(
|
||||
'Worker execution lease loss coordinator clock returned an invalid time',
|
||||
);
|
||||
}
|
||||
return nowMs;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { WorkerRecord } from '../domain/worker';
|
||||
import type { WorkerExecutionOfferJournalOwnership } from '../ports/workerExecutionOfferJournalOwnership';
|
||||
import type {
|
||||
WorkerExecutionOfferStartupAuditResult,
|
||||
WorkerExecutionOfferStartupAuditor,
|
||||
} from './workerExecutionOfferStartupAuditor';
|
||||
import type {
|
||||
WorkerExecutionOfferStartupRecoveryResult,
|
||||
WorkerExecutionOfferStartupRecoverySupervisor,
|
||||
} from './workerExecutionOfferStartupRecoverySupervisor';
|
||||
|
||||
export type WorkerExecutionOfferInboxStartResult =
|
||||
| {
|
||||
status: 'ready' | 'reconciliation_required';
|
||||
audit: WorkerExecutionOfferStartupAuditResult;
|
||||
recovery?: WorkerExecutionOfferStartupRecoveryResult;
|
||||
}
|
||||
| {
|
||||
status: 'already_started';
|
||||
audit: WorkerExecutionOfferStartupAuditResult;
|
||||
recovery?: WorkerExecutionOfferStartupRecoveryResult;
|
||||
};
|
||||
|
||||
export type WorkerExecutionOfferInboxStopResult =
|
||||
| 'stopped'
|
||||
| 'not_started'
|
||||
| 'ownership_compromised';
|
||||
|
||||
export class WorkerExecutionOfferInboxStartupIncompleteError extends Error {
|
||||
constructor(readonly audit: WorkerExecutionOfferStartupAuditResult) {
|
||||
super('Worker execution offer inbox startup audit exhausted its budget');
|
||||
this.name = 'WorkerExecutionOfferInboxStartupIncompleteError';
|
||||
}
|
||||
}
|
||||
|
||||
export class WorkerExecutionOfferInboxRecoveryIncompleteError extends Error {
|
||||
constructor(readonly recovery: WorkerExecutionOfferStartupRecoveryResult) {
|
||||
super('Worker execution offer inbox startup recovery exhausted its budget');
|
||||
this.name = 'WorkerExecutionOfferInboxRecoveryIncompleteError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the ordering contract: acquire the root, finish a bounded audit, then
|
||||
* keep ownership until shutdown. It does not start a delivery transport.
|
||||
*/
|
||||
export class WorkerExecutionOfferInboxLifecycle {
|
||||
private active = false;
|
||||
private lastAudit?: WorkerExecutionOfferStartupAuditResult;
|
||||
private lastRecovery?: WorkerExecutionOfferStartupRecoveryResult;
|
||||
|
||||
constructor(
|
||||
private readonly ownership: WorkerExecutionOfferJournalOwnership,
|
||||
private readonly auditor: Pick<WorkerExecutionOfferStartupAuditor, 'audit'>,
|
||||
private readonly recovery?: Pick<
|
||||
WorkerExecutionOfferStartupRecoverySupervisor,
|
||||
'recover'
|
||||
>,
|
||||
) {}
|
||||
|
||||
async start(
|
||||
currentSession: WorkerRecord,
|
||||
): Promise<WorkerExecutionOfferInboxStartResult> {
|
||||
if (this.active && this.lastAudit) {
|
||||
return {
|
||||
status: 'already_started',
|
||||
audit: this.lastAudit,
|
||||
...(this.lastRecovery === undefined
|
||||
? {}
|
||||
: { recovery: this.lastRecovery }),
|
||||
};
|
||||
}
|
||||
await this.ownership.acquireOwnership();
|
||||
try {
|
||||
let audit = await this.auditor.audit(currentSession);
|
||||
if (audit.status === 'scan_budget_exhausted') {
|
||||
throw new WorkerExecutionOfferInboxStartupIncompleteError(audit);
|
||||
}
|
||||
let recovery: WorkerExecutionOfferStartupRecoveryResult | undefined;
|
||||
if (this.recovery) {
|
||||
recovery = await this.recovery.recover(audit);
|
||||
if (recovery.status === 'action_budget_exhausted') {
|
||||
throw new WorkerExecutionOfferInboxRecoveryIncompleteError(recovery);
|
||||
}
|
||||
audit = await this.auditor.audit(currentSession);
|
||||
if (audit.status === 'scan_budget_exhausted') {
|
||||
throw new WorkerExecutionOfferInboxStartupIncompleteError(audit);
|
||||
}
|
||||
}
|
||||
this.active = true;
|
||||
this.lastAudit = audit;
|
||||
this.lastRecovery = recovery;
|
||||
return {
|
||||
status: audit.status,
|
||||
audit,
|
||||
...(recovery === undefined ? {} : { recovery }),
|
||||
};
|
||||
} catch (error) {
|
||||
await this.ownership.releaseOwnership().catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
currentAudit(): WorkerExecutionOfferStartupAuditResult | undefined {
|
||||
return this.lastAudit;
|
||||
}
|
||||
|
||||
async stop(): Promise<WorkerExecutionOfferInboxStopResult> {
|
||||
if (!this.active) return 'not_started';
|
||||
this.active = false;
|
||||
this.lastAudit = undefined;
|
||||
this.lastRecovery = undefined;
|
||||
const released = await this.ownership.releaseOwnership();
|
||||
return released === 'released' || released === 'not_owned'
|
||||
? 'stopped'
|
||||
: 'ownership_compromised';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import type { WorkerExecutionOfferJournalState } from '../domain/workerExecutionOffer';
|
||||
import type { CompletionReceiptStore } from '../ports/completionReceiptStore';
|
||||
import type {
|
||||
WorkerExecutionOfferJournal,
|
||||
WorkerExecutionOfferJournalPage,
|
||||
} from '../ports/workerExecutionOfferJournal';
|
||||
|
||||
export const MIN_WORKER_OFFER_TERMINAL_RETENTION_MS = 60_000;
|
||||
export const MAX_WORKER_OFFER_TERMINAL_RETENTION_MS = 30 * 24 * 60 * 60_000;
|
||||
export const MAX_WORKER_OFFER_RETENTION_REMOVALS = 64;
|
||||
|
||||
export type WorkerExecutionOfferRetentionEntryOutcome =
|
||||
| 'removed'
|
||||
| 'already_absent'
|
||||
| 'receipt_cleanup_failed'
|
||||
| 'journal_remove_failed';
|
||||
|
||||
export interface WorkerExecutionOfferRetentionEntry {
|
||||
offerId: string;
|
||||
attemptId: string;
|
||||
state: Extract<
|
||||
WorkerExecutionOfferJournalState,
|
||||
'completion_acknowledged' | 'start_failure_acknowledged'
|
||||
>;
|
||||
outcome: WorkerExecutionOfferRetentionEntryOutcome;
|
||||
}
|
||||
|
||||
export interface WorkerExecutionOfferRetentionResult {
|
||||
status: 'complete' | 'page_complete' | 'removal_budget_exhausted';
|
||||
observedAtMs: number;
|
||||
recordsScanned: number;
|
||||
eligibleRecords: number;
|
||||
removalsAttempted: number;
|
||||
recordsRemoved: number;
|
||||
retainedRecords: number;
|
||||
failedRecords: number;
|
||||
entries: readonly WorkerExecutionOfferRetentionEntry[];
|
||||
nextAfterOfferId?: string;
|
||||
}
|
||||
|
||||
export interface WorkerExecutionOfferJournalRetentionOptions {
|
||||
completionRetentionMs: number;
|
||||
startFailureRetentionMs: number;
|
||||
pageSize?: number;
|
||||
maximumRemovals?: number;
|
||||
clock?: { now(): number };
|
||||
}
|
||||
|
||||
export class InvalidWorkerExecutionOfferRetentionPageError extends Error {
|
||||
constructor(message: string) {
|
||||
super(`Worker offer retention page is invalid: ${message}`);
|
||||
this.name = 'InvalidWorkerExecutionOfferRetentionPageError';
|
||||
}
|
||||
}
|
||||
|
||||
function assertIntegerBetween(
|
||||
name: string,
|
||||
value: number,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): void {
|
||||
if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
|
||||
throw new RangeError(`${name} must be between ${minimum} and ${maximum}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** One bounded retention page; callers own cadence and cursor persistence. */
|
||||
export class WorkerExecutionOfferJournalRetentionService {
|
||||
private readonly completionRetentionMs: number;
|
||||
private readonly startFailureRetentionMs: number;
|
||||
private readonly pageSize: number;
|
||||
private readonly maximumRemovals: number;
|
||||
private readonly clock: { now(): number };
|
||||
|
||||
constructor(
|
||||
private readonly journal: Pick<
|
||||
WorkerExecutionOfferJournal,
|
||||
'list' | 'remove'
|
||||
>,
|
||||
private readonly receipts: Pick<CompletionReceiptStore, 'remove'>,
|
||||
options: WorkerExecutionOfferJournalRetentionOptions,
|
||||
) {
|
||||
this.completionRetentionMs = options.completionRetentionMs;
|
||||
this.startFailureRetentionMs = options.startFailureRetentionMs;
|
||||
this.pageSize = options.pageSize ?? 16;
|
||||
this.maximumRemovals = options.maximumRemovals ?? 8;
|
||||
this.clock = options.clock ?? Date;
|
||||
assertIntegerBetween(
|
||||
'completionRetentionMs',
|
||||
this.completionRetentionMs,
|
||||
MIN_WORKER_OFFER_TERMINAL_RETENTION_MS,
|
||||
MAX_WORKER_OFFER_TERMINAL_RETENTION_MS,
|
||||
);
|
||||
assertIntegerBetween(
|
||||
'startFailureRetentionMs',
|
||||
this.startFailureRetentionMs,
|
||||
MIN_WORKER_OFFER_TERMINAL_RETENTION_MS,
|
||||
MAX_WORKER_OFFER_TERMINAL_RETENTION_MS,
|
||||
);
|
||||
assertIntegerBetween('pageSize', this.pageSize, 1, 64);
|
||||
assertIntegerBetween(
|
||||
'maximumRemovals',
|
||||
this.maximumRemovals,
|
||||
1,
|
||||
Math.min(this.pageSize, MAX_WORKER_OFFER_RETENTION_REMOVALS),
|
||||
);
|
||||
}
|
||||
|
||||
async sweep(
|
||||
options: {
|
||||
afterOfferId?: string;
|
||||
} = {},
|
||||
): Promise<WorkerExecutionOfferRetentionResult> {
|
||||
const observedAtMs = this.now();
|
||||
const page = await this.journal.list({
|
||||
...(options.afterOfferId === undefined
|
||||
? {}
|
||||
: { afterOfferId: options.afterOfferId }),
|
||||
limit: this.pageSize,
|
||||
});
|
||||
this.assertPage(page, options.afterOfferId);
|
||||
|
||||
const entries: WorkerExecutionOfferRetentionEntry[] = [];
|
||||
let recordsScanned = 0;
|
||||
let eligibleRecords = 0;
|
||||
let removalsAttempted = 0;
|
||||
let recordsRemoved = 0;
|
||||
let retainedRecords = 0;
|
||||
let failedRecords = 0;
|
||||
let lastProcessedOfferId = options.afterOfferId;
|
||||
|
||||
for (const record of page.records) {
|
||||
if (
|
||||
record.state !== 'completion_acknowledged' &&
|
||||
record.state !== 'start_failure_acknowledged'
|
||||
) {
|
||||
recordsScanned += 1;
|
||||
retainedRecords += 1;
|
||||
lastProcessedOfferId = record.offer.offerId;
|
||||
continue;
|
||||
}
|
||||
const settledAtMs =
|
||||
record.state === 'completion_acknowledged'
|
||||
? record.completionAcknowledgedAtMs!
|
||||
: record.updatedAtMs;
|
||||
const retentionMs =
|
||||
record.state === 'completion_acknowledged'
|
||||
? this.completionRetentionMs
|
||||
: this.startFailureRetentionMs;
|
||||
const due =
|
||||
settledAtMs <= observedAtMs &&
|
||||
observedAtMs - settledAtMs >= retentionMs;
|
||||
if (!due) {
|
||||
recordsScanned += 1;
|
||||
retainedRecords += 1;
|
||||
lastProcessedOfferId = record.offer.offerId;
|
||||
continue;
|
||||
}
|
||||
eligibleRecords += 1;
|
||||
if (removalsAttempted >= this.maximumRemovals) {
|
||||
return {
|
||||
status: 'removal_budget_exhausted',
|
||||
observedAtMs,
|
||||
recordsScanned,
|
||||
eligibleRecords,
|
||||
removalsAttempted,
|
||||
recordsRemoved,
|
||||
retainedRecords,
|
||||
failedRecords,
|
||||
entries,
|
||||
...(lastProcessedOfferId === undefined
|
||||
? {}
|
||||
: { nextAfterOfferId: lastProcessedOfferId }),
|
||||
};
|
||||
}
|
||||
recordsScanned += 1;
|
||||
removalsAttempted += 1;
|
||||
const offerId = record.offer.offerId;
|
||||
const attemptId = record.offer.candidate.attemptId;
|
||||
if (record.state === 'completion_acknowledged') {
|
||||
try {
|
||||
await this.receipts.remove(attemptId);
|
||||
} catch {
|
||||
failedRecords += 1;
|
||||
entries.push({
|
||||
offerId,
|
||||
attemptId,
|
||||
state: record.state,
|
||||
outcome: 'receipt_cleanup_failed',
|
||||
});
|
||||
lastProcessedOfferId = offerId;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const removed = await this.journal.remove(offerId, record.revision);
|
||||
if (removed) recordsRemoved += 1;
|
||||
entries.push({
|
||||
offerId,
|
||||
attemptId,
|
||||
state: record.state,
|
||||
outcome: removed ? 'removed' : 'already_absent',
|
||||
});
|
||||
} catch {
|
||||
failedRecords += 1;
|
||||
entries.push({
|
||||
offerId,
|
||||
attemptId,
|
||||
state: record.state,
|
||||
outcome: 'journal_remove_failed',
|
||||
});
|
||||
}
|
||||
lastProcessedOfferId = offerId;
|
||||
}
|
||||
|
||||
return {
|
||||
status:
|
||||
page.nextAfterOfferId === undefined ? 'complete' : 'page_complete',
|
||||
observedAtMs,
|
||||
recordsScanned,
|
||||
eligibleRecords,
|
||||
removalsAttempted,
|
||||
recordsRemoved,
|
||||
retainedRecords,
|
||||
failedRecords,
|
||||
entries,
|
||||
...(page.nextAfterOfferId === undefined
|
||||
? {}
|
||||
: { nextAfterOfferId: page.nextAfterOfferId }),
|
||||
};
|
||||
}
|
||||
|
||||
private assertPage(
|
||||
page: WorkerExecutionOfferJournalPage,
|
||||
afterOfferId: string | undefined,
|
||||
): void {
|
||||
if (page.records.length > this.pageSize) {
|
||||
throw new InvalidWorkerExecutionOfferRetentionPageError(
|
||||
'record count exceeds pageSize',
|
||||
);
|
||||
}
|
||||
let previous = afterOfferId;
|
||||
for (const record of page.records) {
|
||||
if (previous !== undefined && record.offer.offerId <= previous) {
|
||||
throw new InvalidWorkerExecutionOfferRetentionPageError(
|
||||
'offer cursor did not advance',
|
||||
);
|
||||
}
|
||||
previous = record.offer.offerId;
|
||||
}
|
||||
if (
|
||||
page.nextAfterOfferId !== undefined &&
|
||||
(page.records.length !== this.pageSize ||
|
||||
page.records.length === 0 ||
|
||||
page.nextAfterOfferId !==
|
||||
page.records[page.records.length - 1].offer.offerId)
|
||||
) {
|
||||
throw new InvalidWorkerExecutionOfferRetentionPageError(
|
||||
'resume cursor is inconsistent',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private now(): number {
|
||||
const nowMs = this.clock.now();
|
||||
if (!Number.isSafeInteger(nowMs) || nowMs < 0) {
|
||||
throw new TypeError(
|
||||
'Worker offer retention clock returned an invalid time',
|
||||
);
|
||||
}
|
||||
return nowMs;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
import type { ExecutionHandle, ExecutionStopReason } from '../domain/execution';
|
||||
import {
|
||||
WorkerExecutionOfferConflictError,
|
||||
assertClaimedExecutionOffer,
|
||||
assertSameWorkerExecutionOffer,
|
||||
cloneClaimedExecutionOffer,
|
||||
cloneWorkerExecutionOfferJournalRecord,
|
||||
createWorkerExecutionOfferJournalRecord,
|
||||
mergeWorkerExecutionOffer,
|
||||
workerExecutionHandleMetadata,
|
||||
type WorkerExecutionOfferJournalRecord,
|
||||
} from '../domain/workerExecutionOffer';
|
||||
import {
|
||||
RunDispatchLeaseFenceRejectedError,
|
||||
type RunDispatchLeaseRecord,
|
||||
} from '../domain/runDispatchLease';
|
||||
import type { ClaimedExecutionOffer } from '../domain/runDispatchOffer';
|
||||
import type { WorkerRecord } from '../domain/worker';
|
||||
import { createWorkerExecutionCompletionReceiptAuthentication } from '../domain/workerExecutionCompletionReceiptAuthentication';
|
||||
import type { Executor } from '../ports/executor';
|
||||
import type { WorkerExecutionContextFactory } from '../ports/workerExecutionContextFactory';
|
||||
import type { WorkerExecutionOfferJournal } from '../ports/workerExecutionOfferJournal';
|
||||
import type { WorkerRemoteRunActivationClient } from '../ports/workerRemoteRunActivationClient';
|
||||
import type { WorkerRunLeaseTracker } from '../ports/workerRunLeaseTracker';
|
||||
import type {
|
||||
RemoteRunActivationResult,
|
||||
RemoteRunLeaseFence,
|
||||
} from './remoteRunActivationService';
|
||||
|
||||
export const MAX_WORKER_OFFER_ACTIVATION_RETRIES = 4;
|
||||
|
||||
export type WorkerExecutionOfferReceiveResult =
|
||||
| {
|
||||
status: 'running' | 'already_running';
|
||||
offerId: string;
|
||||
executorHandle: string;
|
||||
}
|
||||
| {
|
||||
status: 'start_failed' | 'already_failed';
|
||||
offerId: string;
|
||||
}
|
||||
| {
|
||||
status: 'already_completed';
|
||||
offerId: string;
|
||||
}
|
||||
| {
|
||||
status: 'recovery_required';
|
||||
offerId: string;
|
||||
reason:
|
||||
| 'launch_outcome_unknown'
|
||||
| 'control_plane_already_running'
|
||||
| 'control_plane_terminal'
|
||||
| 'lease_lost_local_execution_stopped'
|
||||
| 'lease_lost_local_execution_unverified';
|
||||
};
|
||||
|
||||
export interface WorkerExecutionOfferReceiverOptions {
|
||||
currentSession(): WorkerRecord | undefined;
|
||||
clock?: { now(): number };
|
||||
activationRetries?: number;
|
||||
acceptedExecutorTypes?: readonly string[];
|
||||
}
|
||||
|
||||
export class WorkerExecutionOfferTargetError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'WorkerExecutionOfferTargetError';
|
||||
}
|
||||
}
|
||||
|
||||
export class WorkerExecutionOfferExpiredError extends Error {
|
||||
constructor(readonly offerId: string) {
|
||||
super(`Worker execution offer ${offerId} has expired`);
|
||||
this.name = 'WorkerExecutionOfferExpiredError';
|
||||
}
|
||||
}
|
||||
|
||||
interface InFlightOffer {
|
||||
offer: ClaimedExecutionOffer;
|
||||
operation: Promise<WorkerExecutionOfferReceiveResult>;
|
||||
}
|
||||
|
||||
function sameLeaseAuthority(
|
||||
left: RunDispatchLeaseRecord,
|
||||
right: RunDispatchLeaseRecord,
|
||||
): boolean {
|
||||
return (
|
||||
left.attemptId === right.attemptId &&
|
||||
left.runId === right.runId &&
|
||||
left.workerId === right.workerId &&
|
||||
left.workerSessionId === right.workerSessionId &&
|
||||
left.workerGeneration === right.workerGeneration &&
|
||||
left.leaseGeneration === right.leaseGeneration &&
|
||||
left.leaseToken === right.leaseToken
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transport-neutral Worker inbox. The transport must authenticate the control
|
||||
* plane before calling receive(); this service independently fences the target
|
||||
* Worker session and writes durable state before any process spawn.
|
||||
*/
|
||||
export class WorkerExecutionOfferReceiver {
|
||||
private readonly currentSessionProvider: WorkerExecutionOfferReceiverOptions['currentSession'];
|
||||
private readonly clock: { now(): number };
|
||||
private readonly activationRetries: number;
|
||||
private readonly acceptedExecutorTypes: ReadonlySet<string>;
|
||||
private readonly inFlight = new Map<string, InFlightOffer>();
|
||||
|
||||
constructor(
|
||||
private readonly journal: WorkerExecutionOfferJournal,
|
||||
private readonly activation: WorkerRemoteRunActivationClient,
|
||||
private readonly executor: Executor,
|
||||
private readonly leaseTracker: WorkerRunLeaseTracker,
|
||||
private readonly contexts: WorkerExecutionContextFactory,
|
||||
options: WorkerExecutionOfferReceiverOptions,
|
||||
) {
|
||||
this.currentSessionProvider = options.currentSession;
|
||||
this.clock = options.clock ?? Date;
|
||||
this.activationRetries = options.activationRetries ?? 2;
|
||||
if (
|
||||
!Number.isSafeInteger(this.activationRetries) ||
|
||||
this.activationRetries < 1 ||
|
||||
this.activationRetries > MAX_WORKER_OFFER_ACTIVATION_RETRIES
|
||||
) {
|
||||
throw new RangeError(
|
||||
`activationRetries must be between 1 and ${MAX_WORKER_OFFER_ACTIVATION_RETRIES}`,
|
||||
);
|
||||
}
|
||||
const accepted = options.acceptedExecutorTypes ?? ['remote_worker'];
|
||||
if (
|
||||
accepted.length < 1 ||
|
||||
accepted.length > 16 ||
|
||||
new Set(accepted).size !== accepted.length ||
|
||||
accepted.some(
|
||||
(value) =>
|
||||
typeof value !== 'string' ||
|
||||
value.length < 1 ||
|
||||
value.length > 64 ||
|
||||
/[\u0000-\u001f\u007f]/.test(value),
|
||||
)
|
||||
) {
|
||||
throw new TypeError('acceptedExecutorTypes is invalid');
|
||||
}
|
||||
this.acceptedExecutorTypes = new Set(accepted);
|
||||
}
|
||||
|
||||
receive(
|
||||
delivered: ClaimedExecutionOffer,
|
||||
): Promise<WorkerExecutionOfferReceiveResult> {
|
||||
assertClaimedExecutionOffer(delivered);
|
||||
const offer = cloneClaimedExecutionOffer(delivered);
|
||||
const active = this.inFlight.get(offer.offerId);
|
||||
if (active) {
|
||||
assertSameWorkerExecutionOffer(active.offer, offer);
|
||||
return active.operation;
|
||||
}
|
||||
const operation = this.process(offer).finally(() => {
|
||||
if (this.inFlight.get(offer.offerId)?.operation === operation) {
|
||||
this.inFlight.delete(offer.offerId);
|
||||
}
|
||||
});
|
||||
this.inFlight.set(offer.offerId, { offer, operation });
|
||||
return operation;
|
||||
}
|
||||
|
||||
private async process(
|
||||
delivered: ClaimedExecutionOffer,
|
||||
): Promise<WorkerExecutionOfferReceiveResult> {
|
||||
const persisted = await this.journal.read(delivered.offerId);
|
||||
if (persisted) {
|
||||
assertSameWorkerExecutionOffer(persisted.offer, delivered);
|
||||
if (persisted.state === 'completion_acknowledged') {
|
||||
return {
|
||||
status: 'already_completed',
|
||||
offerId: persisted.offer.offerId,
|
||||
};
|
||||
}
|
||||
}
|
||||
this.assertTarget(delivered);
|
||||
const initial = createWorkerExecutionOfferJournalRecord(
|
||||
delivered,
|
||||
this.now(),
|
||||
);
|
||||
await this.journal.create(initial);
|
||||
let record = await this.journal.read(delivered.offerId);
|
||||
if (!record) {
|
||||
throw new Error('Worker offer journal lost an entry after create');
|
||||
}
|
||||
assertSameWorkerExecutionOffer(record.offer, delivered);
|
||||
const merged = mergeWorkerExecutionOffer(record.offer, delivered);
|
||||
if (merged.lease.version !== record.offer.lease.version) {
|
||||
record = await this.replace(record, { offer: merged });
|
||||
}
|
||||
|
||||
if (record.state === 'completion_acknowledged') {
|
||||
return { status: 'already_completed', offerId: record.offer.offerId };
|
||||
}
|
||||
if (record.state === 'running_acknowledged') {
|
||||
return {
|
||||
status: 'already_running',
|
||||
offerId: record.offer.offerId,
|
||||
executorHandle: record.executorHandle!,
|
||||
};
|
||||
}
|
||||
if (record.state === 'start_failure_acknowledged') {
|
||||
return { status: 'already_failed', offerId: record.offer.offerId };
|
||||
}
|
||||
if (record.state === 'recovery_required') {
|
||||
return {
|
||||
status: 'recovery_required',
|
||||
offerId: record.offer.offerId,
|
||||
reason: record.recoveryReason!,
|
||||
};
|
||||
}
|
||||
if (record.state === 'launching') {
|
||||
record = await this.replace(record, {
|
||||
state: 'recovery_required',
|
||||
recoveryReason: 'launch_outcome_unknown',
|
||||
});
|
||||
return this.recoveryResult(record);
|
||||
}
|
||||
|
||||
if (record.state === 'accepted') {
|
||||
const starting = await this.activation.acknowledgeStarting(
|
||||
this.fence(record.offer),
|
||||
);
|
||||
if (starting.status === 'already_running') {
|
||||
record = await this.replace(record, {
|
||||
state: 'recovery_required',
|
||||
recoveryReason: 'control_plane_already_running',
|
||||
});
|
||||
return this.recoveryResult(record);
|
||||
}
|
||||
if (starting.status === 'already_terminal') {
|
||||
record = await this.replace(record, {
|
||||
state: 'recovery_required',
|
||||
recoveryReason: 'control_plane_terminal',
|
||||
});
|
||||
return this.recoveryResult(record);
|
||||
}
|
||||
record = await this.replace(record, {
|
||||
state: 'starting_acknowledged',
|
||||
offer: {
|
||||
...record.offer,
|
||||
lease: { ...starting.lease },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (record.state === 'start_failed') {
|
||||
return this.reportStartFailure(record, true);
|
||||
}
|
||||
|
||||
if (record.state === 'starting_acknowledged') {
|
||||
const tracked = this.trackLatest(
|
||||
record.offer.lease,
|
||||
record.offer.offerId,
|
||||
);
|
||||
if (tracked.version !== record.offer.lease.version) {
|
||||
record = await this.replace(record, {
|
||||
offer: { ...record.offer, lease: tracked },
|
||||
});
|
||||
}
|
||||
let prepared;
|
||||
let receiptAuthentication;
|
||||
try {
|
||||
prepared = await this.contexts.prepare(
|
||||
cloneClaimedExecutionOffer(record.offer),
|
||||
);
|
||||
receiptAuthentication =
|
||||
createWorkerExecutionCompletionReceiptAuthentication(
|
||||
prepared.context.completionCallback,
|
||||
);
|
||||
} catch {
|
||||
record = await this.replace(record, { state: 'start_failed' });
|
||||
return this.reportStartFailure(record, false);
|
||||
}
|
||||
record = await this.replace(record, {
|
||||
state: 'launching',
|
||||
...(receiptAuthentication === undefined
|
||||
? {}
|
||||
: {
|
||||
completionReceiptCallbackSequence:
|
||||
receiptAuthentication.callbackSequence,
|
||||
completionReceiptTokenDigest: receiptAuthentication.tokenDigest,
|
||||
}),
|
||||
});
|
||||
let handle: ExecutionHandle;
|
||||
try {
|
||||
handle = await this.executor.start(
|
||||
record.offer.executionSpec,
|
||||
prepared.context,
|
||||
);
|
||||
} catch {
|
||||
record = await this.replace(record, { state: 'start_failed' });
|
||||
return this.reportStartFailure(record, false);
|
||||
}
|
||||
if (
|
||||
handle.runId !== record.offer.candidate.runId ||
|
||||
handle.attemptId !== record.offer.candidate.attemptId ||
|
||||
handle.executorType !== this.executor.type
|
||||
) {
|
||||
await this.compensateInvalidHandle(handle);
|
||||
record = await this.replace(record, {
|
||||
state: 'recovery_required',
|
||||
recoveryReason: 'launch_outcome_unknown',
|
||||
});
|
||||
return this.recoveryResult(record);
|
||||
}
|
||||
const metadata = workerExecutionHandleMetadata(handle);
|
||||
record = await this.replace(record, {
|
||||
state: 'started',
|
||||
...metadata,
|
||||
...(prepared.logArtifactId === undefined
|
||||
? {}
|
||||
: { logArtifactId: prepared.logArtifactId }),
|
||||
});
|
||||
}
|
||||
|
||||
if (record.state !== 'started') {
|
||||
throw new WorkerExecutionOfferConflictError(record.offer.offerId);
|
||||
}
|
||||
const startedRecord = record;
|
||||
const running = await this.withCurrentLease(startedRecord, (lease) =>
|
||||
this.activation.acknowledgeRunning({
|
||||
...this.fence({ ...startedRecord.offer, lease }),
|
||||
startedAtMs: startedRecord.executorStartedAtMs!,
|
||||
executorHandle: startedRecord.executorHandle!,
|
||||
...(startedRecord.logArtifactId === undefined
|
||||
? {}
|
||||
: { logArtifactId: startedRecord.logArtifactId }),
|
||||
}),
|
||||
);
|
||||
if (running.status === 'already_terminal') {
|
||||
record = await this.replace(startedRecord, {
|
||||
state: 'recovery_required',
|
||||
recoveryReason: 'control_plane_terminal',
|
||||
});
|
||||
return this.recoveryResult(record);
|
||||
}
|
||||
record = await this.replace(startedRecord, {
|
||||
state: 'running_acknowledged',
|
||||
offer: { ...record.offer, lease: { ...running.lease } },
|
||||
});
|
||||
return {
|
||||
status:
|
||||
running.status === 'already_running' ? 'already_running' : 'running',
|
||||
offerId: record.offer.offerId,
|
||||
executorHandle: record.executorHandle!,
|
||||
};
|
||||
}
|
||||
|
||||
private async reportStartFailure(
|
||||
record: WorkerExecutionOfferJournalRecord,
|
||||
replay: boolean,
|
||||
): Promise<WorkerExecutionOfferReceiveResult> {
|
||||
const failure = await this.withCurrentLease(record, (lease) =>
|
||||
this.activation.failStart(this.fence({ ...record.offer, lease })),
|
||||
);
|
||||
if (failure.status === 'already_running') {
|
||||
const recovery = await this.replace(record, {
|
||||
state: 'recovery_required',
|
||||
recoveryReason: 'control_plane_already_running',
|
||||
});
|
||||
return this.recoveryResult(recovery);
|
||||
}
|
||||
this.leaseTracker.untrack(record.offer.candidate.attemptId);
|
||||
await this.replace(record, { state: 'start_failure_acknowledged' });
|
||||
return {
|
||||
status: replay ? 'already_failed' : 'start_failed',
|
||||
offerId: record.offer.offerId,
|
||||
};
|
||||
}
|
||||
|
||||
private async withCurrentLease(
|
||||
record: WorkerExecutionOfferJournalRecord,
|
||||
operation: (
|
||||
lease: RunDispatchLeaseRecord,
|
||||
) => Promise<RemoteRunActivationResult>,
|
||||
): Promise<RemoteRunActivationResult> {
|
||||
let lastError: unknown;
|
||||
for (let attempt = 0; attempt < this.activationRetries; attempt += 1) {
|
||||
const lease = this.trackLatest(record.offer.lease, record.offer.offerId);
|
||||
try {
|
||||
return await operation(lease);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (
|
||||
!(error instanceof RunDispatchLeaseFenceRejectedError) ||
|
||||
error.reason !== 'version_mismatch'
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
private trackLatest(
|
||||
lease: RunDispatchLeaseRecord,
|
||||
offerId: string,
|
||||
): RunDispatchLeaseRecord {
|
||||
const current = this.leaseTracker
|
||||
.leases()
|
||||
.find((candidate) => candidate.attemptId === lease.attemptId);
|
||||
if (current && !sameLeaseAuthority(current, lease)) {
|
||||
throw new WorkerExecutionOfferConflictError(offerId);
|
||||
}
|
||||
const selected =
|
||||
current && current.version > lease.version ? current : lease;
|
||||
this.leaseTracker.track(selected);
|
||||
return { ...selected };
|
||||
}
|
||||
|
||||
private fence(offer: ClaimedExecutionOffer): RemoteRunLeaseFence {
|
||||
const lease = offer.lease;
|
||||
return {
|
||||
runId: offer.candidate.runId,
|
||||
attemptId: offer.candidate.attemptId,
|
||||
workerId: lease.workerId,
|
||||
workerSessionId: lease.workerSessionId,
|
||||
workerGeneration: lease.workerGeneration,
|
||||
leaseGeneration: lease.leaseGeneration,
|
||||
leaseToken: lease.leaseToken,
|
||||
expectedLeaseVersion: lease.version,
|
||||
executorType: offer.candidate.executorType,
|
||||
};
|
||||
}
|
||||
|
||||
private assertTarget(offer: ClaimedExecutionOffer): void {
|
||||
const nowMs = this.now();
|
||||
const session = this.currentSessionProvider();
|
||||
if (
|
||||
!session ||
|
||||
session.id !== offer.worker.id ||
|
||||
session.sessionId !== offer.worker.sessionId ||
|
||||
session.generation !== offer.worker.generation
|
||||
) {
|
||||
throw new WorkerExecutionOfferTargetError(
|
||||
'Execution offer does not target the current Worker session',
|
||||
);
|
||||
}
|
||||
if (
|
||||
session.status === 'offline' ||
|
||||
(session.status === 'draining' && offer.deliveryKind === 'new_claim') ||
|
||||
session.leaseExpiresAtMs <= nowMs
|
||||
) {
|
||||
throw new WorkerExecutionOfferTargetError(
|
||||
'Current Worker session cannot accept this execution offer',
|
||||
);
|
||||
}
|
||||
if (offer.lease.expiresAtMs <= nowMs) {
|
||||
throw new WorkerExecutionOfferExpiredError(offer.offerId);
|
||||
}
|
||||
if (
|
||||
!this.acceptedExecutorTypes.has(offer.candidate.executorType) ||
|
||||
!session.capabilities.executors.includes(offer.candidate.executorType)
|
||||
) {
|
||||
throw new WorkerExecutionOfferTargetError(
|
||||
'Worker does not advertise the requested execution path',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async replace(
|
||||
previous: WorkerExecutionOfferJournalRecord,
|
||||
patch: Partial<WorkerExecutionOfferJournalRecord>,
|
||||
): Promise<WorkerExecutionOfferJournalRecord> {
|
||||
const updated = cloneWorkerExecutionOfferJournalRecord({
|
||||
...previous,
|
||||
...patch,
|
||||
schemaVersion: 1,
|
||||
revision: previous.revision + 1,
|
||||
updatedAtMs: Math.max(this.now(), previous.updatedAtMs),
|
||||
});
|
||||
await this.journal.replace(updated, previous.revision);
|
||||
return updated;
|
||||
}
|
||||
|
||||
private recoveryResult(
|
||||
record: WorkerExecutionOfferJournalRecord,
|
||||
): WorkerExecutionOfferReceiveResult {
|
||||
return {
|
||||
status: 'recovery_required',
|
||||
offerId: record.offer.offerId,
|
||||
reason: record.recoveryReason!,
|
||||
};
|
||||
}
|
||||
|
||||
private async compensateInvalidHandle(
|
||||
handle: ExecutionHandle,
|
||||
): Promise<void> {
|
||||
const reason: ExecutionStopReason = {
|
||||
kind: 'reconcile',
|
||||
requestedAtMs: this.now(),
|
||||
};
|
||||
await this.executor.stop(handle, reason).catch(() => undefined);
|
||||
}
|
||||
|
||||
private now(): number {
|
||||
const nowMs = this.clock.now();
|
||||
if (!Number.isSafeInteger(nowMs) || nowMs < 0) {
|
||||
throw new TypeError(
|
||||
'Worker offer receiver clock returned an invalid time',
|
||||
);
|
||||
}
|
||||
return nowMs;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import {
|
||||
cloneWorkerExecutionOfferJournalRecord,
|
||||
type WorkerExecutionOfferJournalRecord,
|
||||
} from '../domain/workerExecutionOffer';
|
||||
import type { WorkerRecord } from '../domain/worker';
|
||||
import type { CompletionReceiptStore } from '../ports/completionReceiptStore';
|
||||
import type { WorkerExecutionOfferJournal } from '../ports/workerExecutionOfferJournal';
|
||||
import type { WorkerRemoteRunActivationClient } from '../ports/workerRemoteRunActivationClient';
|
||||
import type { WorkerRemoteRunCompletionClient } from '../ports/workerRemoteRunCompletionClient';
|
||||
import type { WorkerExecutionOfferRecoveryResult } from './workerExecutionOfferRecoveryReconciler';
|
||||
import { WorkerExecutionOfferRecoveryReconciler } from './workerExecutionOfferRecoveryReconciler';
|
||||
|
||||
export type WorkerExecutionOfferRecoveryActionStatus =
|
||||
| 'not_found'
|
||||
| 'session_unavailable'
|
||||
| 'deferred'
|
||||
| 'running_acknowledged'
|
||||
| 'already_running'
|
||||
| 'control_plane_terminal'
|
||||
| 'completion_acknowledged'
|
||||
| 'already_completed';
|
||||
|
||||
export type WorkerCompletionReceiptCleanup =
|
||||
| 'removed'
|
||||
| 'already_absent'
|
||||
| 'pending';
|
||||
|
||||
export interface WorkerExecutionOfferRecoveryActionResult {
|
||||
offerId: string;
|
||||
status: WorkerExecutionOfferRecoveryActionStatus;
|
||||
evidence?: WorkerExecutionOfferRecoveryResult;
|
||||
receiptCleanup?: WorkerCompletionReceiptCleanup;
|
||||
}
|
||||
|
||||
export interface WorkerExecutionOfferRecoveryCoordinatorOptions {
|
||||
currentSession(): WorkerRecord | undefined;
|
||||
clock?: { now(): number };
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies only evidence-backed recovery mutations. Control-plane completion is
|
||||
* durable before the local journal becomes terminal; the receipt is removed
|
||||
* only after that terminal journal write succeeds.
|
||||
*/
|
||||
export class WorkerExecutionOfferRecoveryCoordinator {
|
||||
private readonly currentSessionProvider: WorkerExecutionOfferRecoveryCoordinatorOptions['currentSession'];
|
||||
private readonly clock: { now(): number };
|
||||
private readonly inFlight = new Map<
|
||||
string,
|
||||
Promise<WorkerExecutionOfferRecoveryActionResult>
|
||||
>();
|
||||
|
||||
constructor(
|
||||
private readonly journal: Pick<
|
||||
WorkerExecutionOfferJournal,
|
||||
'read' | 'replace'
|
||||
>,
|
||||
private readonly reconciler: WorkerExecutionOfferRecoveryReconciler,
|
||||
private readonly completion: WorkerRemoteRunCompletionClient,
|
||||
private readonly activation: Pick<
|
||||
WorkerRemoteRunActivationClient,
|
||||
'acknowledgeRunning'
|
||||
>,
|
||||
private readonly receipts: Pick<CompletionReceiptStore, 'remove'>,
|
||||
options: WorkerExecutionOfferRecoveryCoordinatorOptions,
|
||||
) {
|
||||
this.currentSessionProvider = options.currentSession;
|
||||
this.clock = options.clock ?? Date;
|
||||
}
|
||||
|
||||
recover(offerId: string): Promise<WorkerExecutionOfferRecoveryActionResult> {
|
||||
const active = this.inFlight.get(offerId);
|
||||
if (active) return active;
|
||||
const operation = this.process(offerId).finally(() => {
|
||||
if (this.inFlight.get(offerId) === operation) {
|
||||
this.inFlight.delete(offerId);
|
||||
}
|
||||
});
|
||||
this.inFlight.set(offerId, operation);
|
||||
return operation;
|
||||
}
|
||||
|
||||
private async process(
|
||||
offerId: string,
|
||||
): Promise<WorkerExecutionOfferRecoveryActionResult> {
|
||||
let record = await this.journal.read(offerId);
|
||||
if (!record) return { offerId, status: 'not_found' };
|
||||
if (record.state === 'completion_acknowledged') {
|
||||
return {
|
||||
offerId,
|
||||
status: 'already_completed',
|
||||
receiptCleanup: await this.cleanupReceipt(
|
||||
record.offer.candidate.attemptId,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const currentSession = this.currentSessionProvider();
|
||||
if (!currentSession) return { offerId, status: 'session_unavailable' };
|
||||
const evidence = await this.reconciler.reconcile(record, currentSession);
|
||||
|
||||
if (
|
||||
evidence.finding === 'completion_observed' &&
|
||||
evidence.completionSubmission === 'ready' &&
|
||||
evidence.completion
|
||||
) {
|
||||
await this.completion.complete({
|
||||
runId: record.offer.candidate.runId,
|
||||
attemptId: record.offer.candidate.attemptId,
|
||||
callbackSequence: evidence.completion.callbackSequence,
|
||||
result: {
|
||||
outcome: evidence.completion.outcome,
|
||||
startedAtMs: evidence.completion.startedAtMs,
|
||||
finishedAtMs: evidence.completion.finishedAtMs,
|
||||
exitCode: evidence.completion.exitCode,
|
||||
},
|
||||
executorType: record.offer.candidate.executorType,
|
||||
workerId: record.offer.lease.workerId,
|
||||
workerSessionId: record.offer.lease.workerSessionId,
|
||||
workerGeneration: record.offer.lease.workerGeneration,
|
||||
leaseGeneration: record.offer.lease.leaseGeneration,
|
||||
leaseToken: record.offer.lease.leaseToken,
|
||||
expectedLeaseVersion: record.offer.lease.version,
|
||||
});
|
||||
record = await this.markCompletionAcknowledged(record);
|
||||
return {
|
||||
offerId,
|
||||
status: 'completion_acknowledged',
|
||||
evidence,
|
||||
receiptCleanup: await this.cleanupReceipt(
|
||||
record.offer.candidate.attemptId,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
evidence.finding === 'execution_running' &&
|
||||
evidence.authority === 'current' &&
|
||||
record.state === 'started'
|
||||
) {
|
||||
const lease = record.offer.lease;
|
||||
const activated = await this.activation.acknowledgeRunning({
|
||||
runId: record.offer.candidate.runId,
|
||||
attemptId: record.offer.candidate.attemptId,
|
||||
workerId: lease.workerId,
|
||||
workerSessionId: lease.workerSessionId,
|
||||
workerGeneration: lease.workerGeneration,
|
||||
leaseGeneration: lease.leaseGeneration,
|
||||
leaseToken: lease.leaseToken,
|
||||
expectedLeaseVersion: lease.version,
|
||||
executorType: record.offer.candidate.executorType,
|
||||
startedAtMs: record.executorStartedAtMs!,
|
||||
executorHandle: record.executorHandle!,
|
||||
...(record.logArtifactId === undefined
|
||||
? {}
|
||||
: { logArtifactId: record.logArtifactId }),
|
||||
});
|
||||
if (activated.status === 'already_terminal') {
|
||||
await this.replace(record, {
|
||||
state: 'recovery_required',
|
||||
recoveryReason: 'control_plane_terminal',
|
||||
});
|
||||
return { offerId, status: 'control_plane_terminal', evidence };
|
||||
}
|
||||
await this.replace(record, {
|
||||
state: 'running_acknowledged',
|
||||
offer: { ...record.offer, lease: { ...activated.lease } },
|
||||
});
|
||||
return {
|
||||
offerId,
|
||||
status:
|
||||
activated.status === 'already_running'
|
||||
? 'already_running'
|
||||
: 'running_acknowledged',
|
||||
evidence,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
evidence.finding === 'execution_running' &&
|
||||
evidence.authority === 'current' &&
|
||||
record.state === 'running_acknowledged'
|
||||
) {
|
||||
return { offerId, status: 'already_running', evidence };
|
||||
}
|
||||
return { offerId, status: 'deferred', evidence };
|
||||
}
|
||||
|
||||
private async markCompletionAcknowledged(
|
||||
record: WorkerExecutionOfferJournalRecord,
|
||||
): Promise<WorkerExecutionOfferJournalRecord> {
|
||||
try {
|
||||
return await this.replace(record, {
|
||||
state: 'completion_acknowledged',
|
||||
recoveryReason: undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
const current = await this.journal.read(record.offer.offerId);
|
||||
if (current?.state === 'completion_acknowledged') return current;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async replace(
|
||||
previous: WorkerExecutionOfferJournalRecord,
|
||||
patch: Partial<WorkerExecutionOfferJournalRecord>,
|
||||
): Promise<WorkerExecutionOfferJournalRecord> {
|
||||
const updatedAtMs = Math.max(this.now(), previous.updatedAtMs);
|
||||
const updated = cloneWorkerExecutionOfferJournalRecord({
|
||||
...previous,
|
||||
...patch,
|
||||
schemaVersion: 1,
|
||||
revision: previous.revision + 1,
|
||||
updatedAtMs,
|
||||
...(patch.state === 'completion_acknowledged'
|
||||
? { completionAcknowledgedAtMs: updatedAtMs }
|
||||
: {}),
|
||||
});
|
||||
await this.journal.replace(updated, previous.revision);
|
||||
return updated;
|
||||
}
|
||||
|
||||
private async cleanupReceipt(
|
||||
attemptId: string,
|
||||
): Promise<WorkerCompletionReceiptCleanup> {
|
||||
try {
|
||||
return (await this.receipts.remove(attemptId))
|
||||
? 'removed'
|
||||
: 'already_absent';
|
||||
} catch {
|
||||
return 'pending';
|
||||
}
|
||||
}
|
||||
|
||||
private now(): number {
|
||||
const nowMs = this.clock.now();
|
||||
if (!Number.isSafeInteger(nowMs) || nowMs < 0) {
|
||||
throw new TypeError(
|
||||
'Worker offer recovery coordinator clock returned an invalid time',
|
||||
);
|
||||
}
|
||||
return nowMs;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
import type { CompletionReceipt } from '../domain/completionReceipt';
|
||||
import type { WorkerRecord } from '../domain/worker';
|
||||
import {
|
||||
cloneWorkerExecutionOfferJournalRecord,
|
||||
type WorkerExecutionOfferJournalRecord,
|
||||
} from '../domain/workerExecutionOffer';
|
||||
import type { CompletionReceiptStore } from '../ports/completionReceiptStore';
|
||||
import type { PersistedExecutionInspector } from '../ports/persistedExecutionInspector';
|
||||
import type { WorkerExecutionCompletionReceiptAuthenticator } from '../ports/workerExecutionCompletionReceiptAuthenticator';
|
||||
|
||||
export type WorkerExecutionRecoveryAuthority =
|
||||
| 'current'
|
||||
| 'session_fenced'
|
||||
| 'worker_offline'
|
||||
| 'worker_session_expired'
|
||||
| 'run_lease_expired';
|
||||
|
||||
export type WorkerExecutionRecoveryFinding =
|
||||
| 'no_execution_expected'
|
||||
| 'completion_observed'
|
||||
| 'completion_receipt_conflict'
|
||||
| 'completion_receipt_unavailable'
|
||||
| 'execution_running'
|
||||
| 'execution_exited_without_receipt'
|
||||
| 'launch_outcome_unknown'
|
||||
| 'execution_identity_mismatch'
|
||||
| 'execution_handle_invalid'
|
||||
| 'execution_probe_unsupported'
|
||||
| 'execution_probe_unavailable';
|
||||
|
||||
export type WorkerExecutionCompletionSubmission =
|
||||
| 'ready'
|
||||
| 'blocked_session_fenced'
|
||||
| 'blocked_worker_offline'
|
||||
| 'blocked_worker_session_expired'
|
||||
| 'blocked_run_lease_expired'
|
||||
| 'blocked_control_plane_terminal';
|
||||
|
||||
export interface WorkerExecutionRecoveredCompletion {
|
||||
callbackSequence: number;
|
||||
outcome: 'succeeded' | 'failed';
|
||||
startedAtMs: number;
|
||||
finishedAtMs: number;
|
||||
exitCode: number;
|
||||
}
|
||||
|
||||
export interface WorkerExecutionOfferRecoveryResult {
|
||||
offerId: string;
|
||||
attemptId: string;
|
||||
state: WorkerExecutionOfferJournalRecord['state'];
|
||||
observedAtMs: number;
|
||||
authority: WorkerExecutionRecoveryAuthority;
|
||||
finding: WorkerExecutionRecoveryFinding;
|
||||
receiptChecks: number;
|
||||
processChecks: number;
|
||||
completionSubmission?: WorkerExecutionCompletionSubmission;
|
||||
completion?: WorkerExecutionRecoveredCompletion;
|
||||
identityPid?: number;
|
||||
}
|
||||
|
||||
export interface WorkerExecutionOfferRecoveryReconcilerOptions {
|
||||
clock?: { now(): number };
|
||||
receiptPublishGraceMs?: number;
|
||||
wait?: (delayMs: number) => Promise<void>;
|
||||
}
|
||||
|
||||
type ReceiptObservation =
|
||||
| { status: 'missing' }
|
||||
| { status: 'observed'; receipt: CompletionReceipt }
|
||||
| { status: 'conflict' }
|
||||
| { status: 'unavailable' };
|
||||
|
||||
const EXECUTION_OWNERSHIP_STATES = new Set<
|
||||
WorkerExecutionOfferJournalRecord['state']
|
||||
>(['launching', 'started', 'running_acknowledged', 'recovery_required']);
|
||||
|
||||
/**
|
||||
* Evidence-only Worker recovery pass. It reads a trusted receipt before
|
||||
* probing a durable process identity and never starts, stops, ACKs or removes
|
||||
* anything. The caller owns scheduling and all control-plane mutations.
|
||||
*/
|
||||
export class WorkerExecutionOfferRecoveryReconciler {
|
||||
private readonly clock: { now(): number };
|
||||
private readonly receiptPublishGraceMs: number;
|
||||
private readonly wait: (delayMs: number) => Promise<void>;
|
||||
|
||||
constructor(
|
||||
private readonly receipts: Pick<CompletionReceiptStore, 'read'>,
|
||||
private readonly receiptAuthenticator: WorkerExecutionCompletionReceiptAuthenticator,
|
||||
private readonly inspector: PersistedExecutionInspector,
|
||||
options: WorkerExecutionOfferRecoveryReconcilerOptions = {},
|
||||
) {
|
||||
this.clock = options.clock ?? Date;
|
||||
this.receiptPublishGraceMs = options.receiptPublishGraceMs ?? 0;
|
||||
if (
|
||||
!Number.isSafeInteger(this.receiptPublishGraceMs) ||
|
||||
this.receiptPublishGraceMs < 0 ||
|
||||
this.receiptPublishGraceMs > 5_000
|
||||
) {
|
||||
throw new RangeError('receiptPublishGraceMs must be between 0 and 5000');
|
||||
}
|
||||
this.wait =
|
||||
options.wait ??
|
||||
((delayMs) =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(resolve, delayMs);
|
||||
}));
|
||||
}
|
||||
|
||||
async reconcile(
|
||||
candidate: WorkerExecutionOfferJournalRecord,
|
||||
currentSession: WorkerRecord,
|
||||
): Promise<WorkerExecutionOfferRecoveryResult> {
|
||||
const record = cloneWorkerExecutionOfferJournalRecord(candidate);
|
||||
const observedAtMs = this.now();
|
||||
const authority = this.authority(record, currentSession, observedAtMs);
|
||||
let receiptChecks = 0;
|
||||
let processChecks = 0;
|
||||
|
||||
const observeReceipt = async (): Promise<ReceiptObservation> => {
|
||||
receiptChecks += 1;
|
||||
try {
|
||||
const receipt = await this.receipts.read(
|
||||
record.offer.candidate.attemptId,
|
||||
);
|
||||
if (!receipt) return { status: 'missing' };
|
||||
if (
|
||||
receipt.runId !== record.offer.candidate.runId ||
|
||||
receipt.attemptId !== record.offer.candidate.attemptId ||
|
||||
(record.executorStartedAtMs !== undefined &&
|
||||
receipt.startedAtMs !== record.executorStartedAtMs) ||
|
||||
!EXECUTION_OWNERSHIP_STATES.has(record.state)
|
||||
) {
|
||||
return { status: 'conflict' };
|
||||
}
|
||||
if (!(await this.receiptAuthenticator.authenticate(receipt, record))) {
|
||||
return { status: 'conflict' };
|
||||
}
|
||||
return { status: 'observed', receipt };
|
||||
} catch {
|
||||
return { status: 'unavailable' };
|
||||
}
|
||||
};
|
||||
|
||||
const result = (
|
||||
finding: WorkerExecutionRecoveryFinding,
|
||||
additions: Partial<
|
||||
Pick<
|
||||
WorkerExecutionOfferRecoveryResult,
|
||||
'completionSubmission' | 'completion' | 'identityPid'
|
||||
>
|
||||
> = {},
|
||||
): WorkerExecutionOfferRecoveryResult => ({
|
||||
offerId: record.offer.offerId,
|
||||
attemptId: record.offer.candidate.attemptId,
|
||||
state: record.state,
|
||||
observedAtMs,
|
||||
authority,
|
||||
finding,
|
||||
receiptChecks,
|
||||
processChecks,
|
||||
...additions,
|
||||
});
|
||||
|
||||
const receiptResult = (
|
||||
observation: ReceiptObservation,
|
||||
): WorkerExecutionOfferRecoveryResult | undefined => {
|
||||
if (observation.status === 'missing') return undefined;
|
||||
if (observation.status === 'conflict') {
|
||||
return result('completion_receipt_conflict');
|
||||
}
|
||||
if (observation.status === 'unavailable') {
|
||||
return result('completion_receipt_unavailable');
|
||||
}
|
||||
return result('completion_observed', {
|
||||
completionSubmission: this.completionSubmission(record, authority),
|
||||
completion: this.sanitizeCompletion(observation.receipt),
|
||||
});
|
||||
};
|
||||
|
||||
const initialReceipt = receiptResult(await observeReceipt());
|
||||
if (initialReceipt) return initialReceipt;
|
||||
|
||||
if (!EXECUTION_OWNERSHIP_STATES.has(record.state)) {
|
||||
return result('no_execution_expected');
|
||||
}
|
||||
if (!record.executorHandle) return result('launch_outcome_unknown');
|
||||
|
||||
let inspection;
|
||||
processChecks += 1;
|
||||
try {
|
||||
inspection = await this.inspector.inspect(record.executorHandle);
|
||||
} catch {
|
||||
return result('execution_probe_unavailable');
|
||||
}
|
||||
if (inspection.status === 'running') {
|
||||
return result('execution_running', {
|
||||
...(inspection.identityPid === undefined
|
||||
? {}
|
||||
: { identityPid: inspection.identityPid }),
|
||||
});
|
||||
}
|
||||
if (inspection.status === 'invalid') {
|
||||
return result('execution_handle_invalid');
|
||||
}
|
||||
if (inspection.status === 'identity_mismatch') {
|
||||
return result('execution_identity_mismatch', {
|
||||
...(inspection.identityPid === undefined
|
||||
? {}
|
||||
: { identityPid: inspection.identityPid }),
|
||||
});
|
||||
}
|
||||
if (inspection.status === 'unsupported') {
|
||||
return result('execution_probe_unsupported', {
|
||||
...(inspection.identityPid === undefined
|
||||
? {}
|
||||
: { identityPid: inspection.identityPid }),
|
||||
});
|
||||
}
|
||||
|
||||
const afterExitReceipt = receiptResult(await observeReceipt());
|
||||
if (afterExitReceipt) return afterExitReceipt;
|
||||
if (this.receiptPublishGraceMs > 0) {
|
||||
await this.wait(this.receiptPublishGraceMs);
|
||||
const afterGraceReceipt = receiptResult(await observeReceipt());
|
||||
if (afterGraceReceipt) return afterGraceReceipt;
|
||||
}
|
||||
return result('execution_exited_without_receipt', {
|
||||
...(inspection.identityPid === undefined
|
||||
? {}
|
||||
: { identityPid: inspection.identityPid }),
|
||||
});
|
||||
}
|
||||
|
||||
private authority(
|
||||
record: WorkerExecutionOfferJournalRecord,
|
||||
currentSession: WorkerRecord,
|
||||
observedAtMs: number,
|
||||
): WorkerExecutionRecoveryAuthority {
|
||||
if (
|
||||
record.offer.worker.id !== currentSession.id ||
|
||||
record.offer.worker.sessionId !== currentSession.sessionId ||
|
||||
record.offer.worker.generation !== currentSession.generation
|
||||
) {
|
||||
return 'session_fenced';
|
||||
}
|
||||
if (currentSession.status === 'offline') return 'worker_offline';
|
||||
if (currentSession.leaseExpiresAtMs <= observedAtMs) {
|
||||
return 'worker_session_expired';
|
||||
}
|
||||
if (record.offer.lease.expiresAtMs <= observedAtMs) {
|
||||
return 'run_lease_expired';
|
||||
}
|
||||
return 'current';
|
||||
}
|
||||
|
||||
private completionSubmission(
|
||||
record: WorkerExecutionOfferJournalRecord,
|
||||
authority: WorkerExecutionRecoveryAuthority,
|
||||
): WorkerExecutionCompletionSubmission {
|
||||
if (
|
||||
record.state === 'recovery_required' &&
|
||||
record.recoveryReason === 'control_plane_terminal'
|
||||
) {
|
||||
return 'blocked_control_plane_terminal';
|
||||
}
|
||||
if (authority === 'current') return 'ready';
|
||||
return `blocked_${authority}`;
|
||||
}
|
||||
|
||||
private sanitizeCompletion(
|
||||
receipt: CompletionReceipt,
|
||||
): WorkerExecutionRecoveredCompletion {
|
||||
return {
|
||||
callbackSequence: receipt.callbackSequence,
|
||||
outcome: receipt.exitCode === 0 ? 'succeeded' : 'failed',
|
||||
startedAtMs: receipt.startedAtMs,
|
||||
finishedAtMs: receipt.finishedAtMs,
|
||||
exitCode: receipt.exitCode,
|
||||
};
|
||||
}
|
||||
|
||||
private now(): number {
|
||||
const observedAtMs = this.clock.now();
|
||||
if (!Number.isSafeInteger(observedAtMs) || observedAtMs < 0) {
|
||||
throw new TypeError(
|
||||
'Worker offer recovery clock returned an invalid time',
|
||||
);
|
||||
}
|
||||
return observedAtMs;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import type { WorkerExecutionOfferJournalState } from '../domain/workerExecutionOffer';
|
||||
import type { WorkerRecord } from '../domain/worker';
|
||||
import type { WorkerExecutionOfferJournal } from '../ports/workerExecutionOfferJournal';
|
||||
|
||||
export const MAX_WORKER_OFFER_STARTUP_AUDIT_PAGES = 16;
|
||||
|
||||
export type WorkerExecutionOfferStartupCategory =
|
||||
| 'settled_start_failure'
|
||||
| 'settled_completion'
|
||||
| 'redelivery_required'
|
||||
| 'fenced_without_local_execution'
|
||||
| 'expired_without_local_execution'
|
||||
| 'launch_reconciliation_required'
|
||||
| 'execution_reconciliation_required';
|
||||
|
||||
export interface WorkerExecutionOfferStartupAuditEntry {
|
||||
offerId: string;
|
||||
attemptId: string;
|
||||
state: WorkerExecutionOfferJournalState;
|
||||
category: WorkerExecutionOfferStartupCategory;
|
||||
}
|
||||
|
||||
export interface WorkerExecutionOfferStartupAuditResult {
|
||||
status: 'ready' | 'reconciliation_required' | 'scan_budget_exhausted';
|
||||
observedAtMs: number;
|
||||
pagesScanned: number;
|
||||
recordsScanned: number;
|
||||
counts: Readonly<Record<WorkerExecutionOfferStartupCategory, number>>;
|
||||
entries: readonly WorkerExecutionOfferStartupAuditEntry[];
|
||||
nextAfterOfferId?: string;
|
||||
}
|
||||
|
||||
export interface WorkerExecutionOfferStartupAuditorOptions {
|
||||
pageSize?: number;
|
||||
maxPages?: number;
|
||||
clock?: { now(): number };
|
||||
}
|
||||
|
||||
export class InvalidWorkerExecutionOfferStartupPageError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'InvalidWorkerExecutionOfferStartupPageError';
|
||||
}
|
||||
}
|
||||
|
||||
const CATEGORIES: readonly WorkerExecutionOfferStartupCategory[] = [
|
||||
'settled_start_failure',
|
||||
'settled_completion',
|
||||
'redelivery_required',
|
||||
'fenced_without_local_execution',
|
||||
'expired_without_local_execution',
|
||||
'launch_reconciliation_required',
|
||||
'execution_reconciliation_required',
|
||||
];
|
||||
|
||||
function assertIntegerBetween(
|
||||
name: string,
|
||||
value: number,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): void {
|
||||
if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
|
||||
throw new RangeError(`${name} must be between ${minimum} and ${maximum}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only startup gate. It never resumes an ACK or starts/stops an Executor;
|
||||
* the future recovery coordinator must act on these low-sensitive categories.
|
||||
*/
|
||||
export class WorkerExecutionOfferStartupAuditor {
|
||||
private readonly pageSize: number;
|
||||
private readonly maxPages: number;
|
||||
private readonly clock: { now(): number };
|
||||
|
||||
constructor(
|
||||
private readonly journal: Pick<WorkerExecutionOfferJournal, 'list'>,
|
||||
options: WorkerExecutionOfferStartupAuditorOptions = {},
|
||||
) {
|
||||
this.pageSize = options.pageSize ?? 16;
|
||||
this.maxPages = options.maxPages ?? 4;
|
||||
this.clock = options.clock ?? Date;
|
||||
assertIntegerBetween('pageSize', this.pageSize, 1, 64);
|
||||
assertIntegerBetween(
|
||||
'maxPages',
|
||||
this.maxPages,
|
||||
1,
|
||||
MAX_WORKER_OFFER_STARTUP_AUDIT_PAGES,
|
||||
);
|
||||
if (this.pageSize * this.maxPages > 1024) {
|
||||
throw new RangeError(
|
||||
'Worker offer startup audit budget must not exceed 1024 records',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async audit(
|
||||
currentSession: WorkerRecord,
|
||||
): Promise<WorkerExecutionOfferStartupAuditResult> {
|
||||
const observedAtMs = this.now();
|
||||
const counts = Object.fromEntries(
|
||||
CATEGORIES.map((category) => [category, 0]),
|
||||
) as Record<WorkerExecutionOfferStartupCategory, number>;
|
||||
const entries: WorkerExecutionOfferStartupAuditEntry[] = [];
|
||||
const seen = new Set<string>();
|
||||
let afterOfferId: string | undefined;
|
||||
let pagesScanned = 0;
|
||||
|
||||
while (pagesScanned < this.maxPages) {
|
||||
const page = await this.journal.list({
|
||||
...(afterOfferId === undefined ? {} : { afterOfferId }),
|
||||
limit: this.pageSize,
|
||||
});
|
||||
pagesScanned += 1;
|
||||
if (page.records.length > this.pageSize) {
|
||||
throw new InvalidWorkerExecutionOfferStartupPageError(
|
||||
'Worker offer startup page exceeds the requested limit',
|
||||
);
|
||||
}
|
||||
let previousOfferId = afterOfferId;
|
||||
for (const record of page.records) {
|
||||
const offerId = record.offer.offerId;
|
||||
if (
|
||||
seen.has(offerId) ||
|
||||
(previousOfferId !== undefined && offerId <= previousOfferId)
|
||||
) {
|
||||
throw new InvalidWorkerExecutionOfferStartupPageError(
|
||||
'Worker offer startup cursor did not advance',
|
||||
);
|
||||
}
|
||||
seen.add(offerId);
|
||||
previousOfferId = offerId;
|
||||
const category = this.classify(record, currentSession, observedAtMs);
|
||||
counts[category] += 1;
|
||||
entries.push({
|
||||
offerId,
|
||||
attemptId: record.offer.candidate.attemptId,
|
||||
state: record.state,
|
||||
category,
|
||||
});
|
||||
}
|
||||
|
||||
if (page.nextAfterOfferId === undefined) {
|
||||
return this.result(counts, entries, observedAtMs, pagesScanned);
|
||||
}
|
||||
if (
|
||||
page.records.length !== this.pageSize ||
|
||||
page.records.length === 0 ||
|
||||
page.nextAfterOfferId !==
|
||||
page.records[page.records.length - 1].offer.offerId ||
|
||||
(afterOfferId !== undefined && page.nextAfterOfferId <= afterOfferId)
|
||||
) {
|
||||
throw new InvalidWorkerExecutionOfferStartupPageError(
|
||||
'Worker offer startup page returned an invalid resume cursor',
|
||||
);
|
||||
}
|
||||
afterOfferId = page.nextAfterOfferId;
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'scan_budget_exhausted',
|
||||
observedAtMs,
|
||||
pagesScanned,
|
||||
recordsScanned: entries.length,
|
||||
counts,
|
||||
entries,
|
||||
...(afterOfferId === undefined ? {} : { nextAfterOfferId: afterOfferId }),
|
||||
};
|
||||
}
|
||||
|
||||
private classify(
|
||||
record: Awaited<
|
||||
ReturnType<WorkerExecutionOfferJournal['list']>
|
||||
>['records'][number],
|
||||
currentSession: WorkerRecord,
|
||||
observedAtMs: number,
|
||||
): WorkerExecutionOfferStartupCategory {
|
||||
if (record.state === 'start_failure_acknowledged') {
|
||||
return 'settled_start_failure';
|
||||
}
|
||||
if (record.state === 'completion_acknowledged') {
|
||||
return 'settled_completion';
|
||||
}
|
||||
if (record.state === 'launching' || record.state === 'recovery_required') {
|
||||
return 'launch_reconciliation_required';
|
||||
}
|
||||
if (record.state === 'started' || record.state === 'running_acknowledged') {
|
||||
return 'execution_reconciliation_required';
|
||||
}
|
||||
const sameSession =
|
||||
record.offer.worker.id === currentSession.id &&
|
||||
record.offer.worker.sessionId === currentSession.sessionId &&
|
||||
record.offer.worker.generation === currentSession.generation;
|
||||
if (!sameSession) return 'fenced_without_local_execution';
|
||||
if (
|
||||
record.offer.lease.expiresAtMs <= observedAtMs ||
|
||||
currentSession.leaseExpiresAtMs <= observedAtMs ||
|
||||
currentSession.status === 'offline'
|
||||
) {
|
||||
return 'expired_without_local_execution';
|
||||
}
|
||||
return 'redelivery_required';
|
||||
}
|
||||
|
||||
private result(
|
||||
counts: Record<WorkerExecutionOfferStartupCategory, number>,
|
||||
entries: WorkerExecutionOfferStartupAuditEntry[],
|
||||
observedAtMs: number,
|
||||
pagesScanned: number,
|
||||
): WorkerExecutionOfferStartupAuditResult {
|
||||
const requiresReconciliation =
|
||||
counts.launch_reconciliation_required > 0 ||
|
||||
counts.execution_reconciliation_required > 0;
|
||||
return {
|
||||
status: requiresReconciliation ? 'reconciliation_required' : 'ready',
|
||||
observedAtMs,
|
||||
pagesScanned,
|
||||
recordsScanned: entries.length,
|
||||
counts,
|
||||
entries,
|
||||
};
|
||||
}
|
||||
|
||||
private now(): number {
|
||||
const observedAtMs = this.clock.now();
|
||||
if (!Number.isSafeInteger(observedAtMs) || observedAtMs < 0) {
|
||||
throw new TypeError(
|
||||
'Worker offer startup audit clock returned an invalid time',
|
||||
);
|
||||
}
|
||||
return observedAtMs;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import type {
|
||||
WorkerExecutionOfferRecoveryActionResult,
|
||||
WorkerExecutionOfferRecoveryCoordinator,
|
||||
WorkerCompletionReceiptCleanup,
|
||||
} from './workerExecutionOfferRecoveryCoordinator';
|
||||
import type {
|
||||
WorkerExecutionOfferStartupAuditResult,
|
||||
WorkerExecutionOfferStartupCategory,
|
||||
} from './workerExecutionOfferStartupAuditor';
|
||||
|
||||
export const MAX_WORKER_OFFER_STARTUP_RECOVERY_ACTIONS = 1024;
|
||||
|
||||
export interface WorkerExecutionOfferStartupRecoveryEntry {
|
||||
offerId: string;
|
||||
attemptId: string;
|
||||
category: WorkerExecutionOfferStartupCategory;
|
||||
outcome: 'applied' | 'failed';
|
||||
actionStatus?: WorkerExecutionOfferRecoveryActionResult['status'];
|
||||
receiptCleanup?: WorkerCompletionReceiptCleanup;
|
||||
}
|
||||
|
||||
export interface WorkerExecutionOfferStartupRecoveryResult {
|
||||
status: 'recovered' | 'reconciliation_required' | 'action_budget_exhausted';
|
||||
actionsPlanned: number;
|
||||
actionsAttempted: number;
|
||||
entries: readonly WorkerExecutionOfferStartupRecoveryEntry[];
|
||||
}
|
||||
|
||||
export class WorkerExecutionOfferStartupRecoveryAuditIncompleteError extends Error {
|
||||
constructor() {
|
||||
super('Worker offer startup recovery requires a complete startup audit');
|
||||
this.name = 'WorkerExecutionOfferStartupRecoveryAuditIncompleteError';
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidWorkerExecutionOfferStartupRecoveryInputError extends TypeError {
|
||||
constructor(message: string) {
|
||||
super(`Worker offer startup recovery input is invalid: ${message}`);
|
||||
this.name = 'InvalidWorkerExecutionOfferStartupRecoveryInputError';
|
||||
}
|
||||
}
|
||||
|
||||
const ACTIONABLE_CATEGORIES = new Set<WorkerExecutionOfferStartupCategory>([
|
||||
'settled_completion',
|
||||
'launch_reconciliation_required',
|
||||
'execution_reconciliation_required',
|
||||
]);
|
||||
|
||||
const RECOVERED_ACTIONS = new Set<
|
||||
WorkerExecutionOfferRecoveryActionResult['status']
|
||||
>([
|
||||
'not_found',
|
||||
'running_acknowledged',
|
||||
'already_running',
|
||||
'completion_acknowledged',
|
||||
'already_completed',
|
||||
]);
|
||||
|
||||
/** Runs one bounded, sequential recovery pass and never creates a timer. */
|
||||
export class WorkerExecutionOfferStartupRecoverySupervisor {
|
||||
private readonly maximumActions: number;
|
||||
|
||||
constructor(
|
||||
private readonly coordinator: Pick<
|
||||
WorkerExecutionOfferRecoveryCoordinator,
|
||||
'recover'
|
||||
>,
|
||||
options: { maximumActions?: number } = {},
|
||||
) {
|
||||
this.maximumActions = options.maximumActions ?? 64;
|
||||
if (
|
||||
!Number.isSafeInteger(this.maximumActions) ||
|
||||
this.maximumActions < 1 ||
|
||||
this.maximumActions > MAX_WORKER_OFFER_STARTUP_RECOVERY_ACTIONS
|
||||
) {
|
||||
throw new RangeError(
|
||||
`maximumActions must be between 1 and ${MAX_WORKER_OFFER_STARTUP_RECOVERY_ACTIONS}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async recover(
|
||||
audit: WorkerExecutionOfferStartupAuditResult,
|
||||
): Promise<WorkerExecutionOfferStartupRecoveryResult> {
|
||||
if (audit.status === 'scan_budget_exhausted') {
|
||||
throw new WorkerExecutionOfferStartupRecoveryAuditIncompleteError();
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(audit.recordsScanned) ||
|
||||
audit.recordsScanned < 0 ||
|
||||
audit.recordsScanned !== audit.entries.length
|
||||
) {
|
||||
throw new InvalidWorkerExecutionOfferStartupRecoveryInputError(
|
||||
'record count does not match entries',
|
||||
);
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
for (const entry of audit.entries) {
|
||||
if (seen.has(entry.offerId)) {
|
||||
throw new InvalidWorkerExecutionOfferStartupRecoveryInputError(
|
||||
'offerId is duplicated',
|
||||
);
|
||||
}
|
||||
seen.add(entry.offerId);
|
||||
}
|
||||
const planned = audit.entries.filter((entry) =>
|
||||
ACTIONABLE_CATEGORIES.has(entry.category),
|
||||
);
|
||||
if (planned.length > this.maximumActions) {
|
||||
return {
|
||||
status: 'action_budget_exhausted',
|
||||
actionsPlanned: planned.length,
|
||||
actionsAttempted: 0,
|
||||
entries: [],
|
||||
};
|
||||
}
|
||||
|
||||
const entries: WorkerExecutionOfferStartupRecoveryEntry[] = [];
|
||||
let unresolved = false;
|
||||
for (const entry of planned) {
|
||||
try {
|
||||
const action = await this.coordinator.recover(entry.offerId);
|
||||
if (!RECOVERED_ACTIONS.has(action.status)) unresolved = true;
|
||||
entries.push({
|
||||
offerId: entry.offerId,
|
||||
attemptId: entry.attemptId,
|
||||
category: entry.category,
|
||||
outcome: 'applied',
|
||||
actionStatus: action.status,
|
||||
...(action.receiptCleanup === undefined
|
||||
? {}
|
||||
: { receiptCleanup: action.receiptCleanup }),
|
||||
});
|
||||
} catch {
|
||||
unresolved = true;
|
||||
entries.push({
|
||||
offerId: entry.offerId,
|
||||
attemptId: entry.attemptId,
|
||||
category: entry.category,
|
||||
outcome: 'failed',
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: unresolved ? 'reconciliation_required' : 'recovered',
|
||||
actionsPlanned: planned.length,
|
||||
actionsAttempted: entries.length,
|
||||
entries,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
import { v7 as uuidV7 } from 'uuid';
|
||||
import {
|
||||
WorkerFenceRejectedError,
|
||||
assertWorkerConcurrency,
|
||||
assertWorkerId,
|
||||
assertWorkerSessionId,
|
||||
type WorkerCapabilities,
|
||||
type WorkerRecord,
|
||||
} from '../domain/worker';
|
||||
import type { WorkerControlPlaneClient } from '../ports/workerControlPlaneClient';
|
||||
|
||||
export const MIN_WORKER_HEARTBEAT_INTERVAL_MS = 1_000;
|
||||
export const MAX_WORKER_HEARTBEAT_INTERVAL_MS = 5 * 60_000;
|
||||
export const MAX_WORKER_HEARTBEAT_STOP_TIMEOUT_MS = 60_000;
|
||||
|
||||
interface ScheduledTimer {
|
||||
unref?: () => void;
|
||||
}
|
||||
|
||||
export interface WorkerHeartbeatScheduler {
|
||||
setTimeout(callback: () => void, delayMs: number): ScheduledTimer;
|
||||
clearTimeout(timer: ScheduledTimer): void;
|
||||
}
|
||||
|
||||
export interface WorkerHeartbeatLifecycleOptions {
|
||||
workerId: string;
|
||||
capabilities(): WorkerCapabilities | Promise<WorkerCapabilities>;
|
||||
maxConcurrentRuns: number;
|
||||
availableSlots(): number | Promise<number>;
|
||||
heartbeatIntervalMs?: number;
|
||||
stopTimeoutMs?: number;
|
||||
createSessionId?: () => string;
|
||||
scheduler?: WorkerHeartbeatScheduler;
|
||||
onSession?: (worker: WorkerRecord) => void;
|
||||
onError?: (error: unknown) => void;
|
||||
onFenced?: (error: WorkerFenceRejectedError) => void;
|
||||
}
|
||||
|
||||
export type WorkerHeartbeatStopResult =
|
||||
| 'drained'
|
||||
| 'timed_out'
|
||||
| 'disconnect_failed';
|
||||
|
||||
const defaultScheduler: WorkerHeartbeatScheduler = {
|
||||
setTimeout(callback, delayMs) {
|
||||
return setTimeout(callback, delayMs);
|
||||
},
|
||||
clearTimeout(timer) {
|
||||
clearTimeout(timer as ReturnType<typeof setTimeout>);
|
||||
},
|
||||
};
|
||||
|
||||
function assertIntegerBetween(
|
||||
name: string,
|
||||
value: number,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): void {
|
||||
if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
|
||||
throw new RangeError(`${name} must be between ${minimum} and ${maximum}`);
|
||||
}
|
||||
}
|
||||
|
||||
export class WorkerHeartbeatLifecycle {
|
||||
private readonly workerId: string;
|
||||
private readonly maxConcurrentRuns: number;
|
||||
private readonly heartbeatIntervalMs: number;
|
||||
private readonly stopTimeoutMs: number;
|
||||
private readonly capabilitiesProvider: WorkerHeartbeatLifecycleOptions['capabilities'];
|
||||
private readonly availableSlotsProvider: WorkerHeartbeatLifecycleOptions['availableSlots'];
|
||||
private readonly createSessionId: () => string;
|
||||
private readonly scheduler: WorkerHeartbeatScheduler;
|
||||
private readonly onSession?: (worker: WorkerRecord) => void;
|
||||
private readonly onError?: (error: unknown) => void;
|
||||
private readonly onFenced?: (error: WorkerFenceRejectedError) => void;
|
||||
private started = false;
|
||||
private draining = false;
|
||||
private timer?: ScheduledTimer;
|
||||
private inFlight?: Promise<void>;
|
||||
private session?: WorkerRecord;
|
||||
|
||||
constructor(
|
||||
private readonly client: WorkerControlPlaneClient,
|
||||
options: WorkerHeartbeatLifecycleOptions,
|
||||
) {
|
||||
assertWorkerId(options.workerId);
|
||||
assertWorkerConcurrency(options.maxConcurrentRuns, 0);
|
||||
this.workerId = options.workerId;
|
||||
this.maxConcurrentRuns = options.maxConcurrentRuns;
|
||||
this.heartbeatIntervalMs = options.heartbeatIntervalMs ?? 10_000;
|
||||
this.stopTimeoutMs = options.stopTimeoutMs ?? 5_000;
|
||||
this.capabilitiesProvider = options.capabilities;
|
||||
this.availableSlotsProvider = options.availableSlots;
|
||||
this.createSessionId = options.createSessionId ?? uuidV7;
|
||||
this.scheduler = options.scheduler ?? defaultScheduler;
|
||||
this.onSession = options.onSession;
|
||||
this.onError = options.onError;
|
||||
this.onFenced = options.onFenced;
|
||||
assertIntegerBetween(
|
||||
'heartbeatIntervalMs',
|
||||
this.heartbeatIntervalMs,
|
||||
MIN_WORKER_HEARTBEAT_INTERVAL_MS,
|
||||
MAX_WORKER_HEARTBEAT_INTERVAL_MS,
|
||||
);
|
||||
assertIntegerBetween(
|
||||
'stopTimeoutMs',
|
||||
this.stopTimeoutMs,
|
||||
1,
|
||||
MAX_WORKER_HEARTBEAT_STOP_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
|
||||
currentSession(): WorkerRecord | undefined {
|
||||
return this.session ? this.cloneRecord(this.session) : undefined;
|
||||
}
|
||||
|
||||
async start(): Promise<boolean> {
|
||||
if (this.started || this.inFlight) return false;
|
||||
this.started = true;
|
||||
this.draining = false;
|
||||
try {
|
||||
const sessionId = this.createSessionId();
|
||||
assertWorkerSessionId(sessionId);
|
||||
const availableSlots = await this.readAvailableSlots();
|
||||
const worker = await this.client.register({
|
||||
workerId: this.workerId,
|
||||
sessionId,
|
||||
capabilities: await this.capabilitiesProvider(),
|
||||
maxConcurrentRuns: this.maxConcurrentRuns,
|
||||
availableSlots,
|
||||
});
|
||||
this.acceptSession(worker, sessionId);
|
||||
this.schedule();
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.started = false;
|
||||
this.notifyError(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async drain(): Promise<WorkerRecord | undefined> {
|
||||
if (!this.started || !this.session) return undefined;
|
||||
this.draining = true;
|
||||
this.clearTimer();
|
||||
const precedingOperation = this.inFlight ?? Promise.resolve();
|
||||
const drainOperation = precedingOperation.then(async () => {
|
||||
const session = this.session;
|
||||
if (!this.started || !session) return undefined;
|
||||
try {
|
||||
const worker = await this.client.drain({
|
||||
workerId: this.workerId,
|
||||
sessionId: session.sessionId,
|
||||
generation: session.generation,
|
||||
expectedVersion: session.version,
|
||||
});
|
||||
this.acceptSession(worker, session.sessionId);
|
||||
return this.cloneRecord(worker);
|
||||
} catch (error) {
|
||||
this.handleOperationError(error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
const trackedOperation = drainOperation
|
||||
.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
)
|
||||
.finally(() => {
|
||||
if (this.inFlight === trackedOperation) this.inFlight = undefined;
|
||||
this.schedule();
|
||||
});
|
||||
this.inFlight = trackedOperation;
|
||||
return drainOperation;
|
||||
}
|
||||
|
||||
async stop(): Promise<WorkerHeartbeatStopResult> {
|
||||
this.clearTimer();
|
||||
const deadline = Date.now() + this.stopTimeoutMs;
|
||||
if (!(await this.waitWithin(this.inFlight, deadline))) {
|
||||
this.started = false;
|
||||
this.clearTimer();
|
||||
return 'timed_out';
|
||||
}
|
||||
this.started = false;
|
||||
this.clearTimer();
|
||||
const session = this.session;
|
||||
if (!session) return 'drained';
|
||||
let disconnectFailed = false;
|
||||
const disconnected = this.client
|
||||
.disconnect({
|
||||
workerId: this.workerId,
|
||||
sessionId: session.sessionId,
|
||||
generation: session.generation,
|
||||
expectedVersion: session.version,
|
||||
})
|
||||
.then((worker) => {
|
||||
this.acceptSession(worker, session.sessionId);
|
||||
})
|
||||
.catch((error) => {
|
||||
disconnectFailed = true;
|
||||
this.handleOperationError(error);
|
||||
});
|
||||
if (!(await this.waitWithin(disconnected, deadline))) return 'timed_out';
|
||||
return disconnectFailed ? 'disconnect_failed' : 'drained';
|
||||
}
|
||||
|
||||
private schedule(): void {
|
||||
if (!this.started || this.timer || this.inFlight) return;
|
||||
const timer = this.scheduler.setTimeout(() => {
|
||||
if (this.timer === timer) this.timer = undefined;
|
||||
this.runHeartbeat();
|
||||
}, this.heartbeatIntervalMs);
|
||||
this.timer = timer;
|
||||
timer.unref?.();
|
||||
}
|
||||
|
||||
private clearTimer(): void {
|
||||
if (!this.timer) return;
|
||||
this.scheduler.clearTimeout(this.timer);
|
||||
this.timer = undefined;
|
||||
}
|
||||
|
||||
private runHeartbeat(): void {
|
||||
if (!this.started || this.inFlight || !this.session) return;
|
||||
const session = this.session;
|
||||
const inFlight = this.readAvailableSlots()
|
||||
.then((availableSlots) =>
|
||||
this.client.heartbeat({
|
||||
workerId: this.workerId,
|
||||
sessionId: session.sessionId,
|
||||
generation: session.generation,
|
||||
expectedVersion: session.version,
|
||||
availableSlots: this.draining ? 0 : availableSlots,
|
||||
}),
|
||||
)
|
||||
.then((worker) => this.acceptSession(worker, session.sessionId))
|
||||
.catch((error) => this.handleOperationError(error))
|
||||
.then(() => undefined)
|
||||
.finally(() => {
|
||||
if (this.inFlight === inFlight) this.inFlight = undefined;
|
||||
this.schedule();
|
||||
});
|
||||
this.inFlight = inFlight;
|
||||
}
|
||||
|
||||
private async readAvailableSlots(): Promise<number> {
|
||||
const availableSlots = await this.availableSlotsProvider();
|
||||
assertWorkerConcurrency(this.maxConcurrentRuns, availableSlots);
|
||||
return availableSlots;
|
||||
}
|
||||
|
||||
private acceptSession(worker: WorkerRecord, sessionId: string): void {
|
||||
if (worker.id !== this.workerId || worker.sessionId !== sessionId) {
|
||||
throw new WorkerFenceRejectedError(this.workerId, 'session_mismatch');
|
||||
}
|
||||
this.session = this.cloneRecord(worker);
|
||||
try {
|
||||
this.onSession?.(this.cloneRecord(worker));
|
||||
} catch (error) {
|
||||
this.notifyError(error);
|
||||
}
|
||||
}
|
||||
|
||||
private handleOperationError(error: unknown): void {
|
||||
if (error instanceof WorkerFenceRejectedError) {
|
||||
this.started = false;
|
||||
this.clearTimer();
|
||||
try {
|
||||
this.onFenced?.(error);
|
||||
} catch (callbackError) {
|
||||
this.notifyError(callbackError);
|
||||
}
|
||||
}
|
||||
this.notifyError(error);
|
||||
}
|
||||
|
||||
private notifyError(error: unknown): void {
|
||||
try {
|
||||
this.onError?.(error);
|
||||
} catch {
|
||||
// Diagnostics must never create a heartbeat failure loop.
|
||||
}
|
||||
}
|
||||
|
||||
private async waitWithin(
|
||||
promise: Promise<unknown> | undefined,
|
||||
deadline: number,
|
||||
): Promise<boolean> {
|
||||
if (!promise) return true;
|
||||
const remainingMs = Math.max(0, deadline - Date.now());
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
const completed = await Promise.race([
|
||||
promise.then(() => true),
|
||||
new Promise<false>((resolve) => {
|
||||
timeout = setTimeout(() => resolve(false), remainingMs);
|
||||
}),
|
||||
]);
|
||||
if (timeout) clearTimeout(timeout);
|
||||
return completed;
|
||||
}
|
||||
|
||||
private cloneRecord(worker: WorkerRecord): WorkerRecord {
|
||||
return {
|
||||
...worker,
|
||||
capabilities: {
|
||||
...worker.capabilities,
|
||||
executors: [...worker.capabilities.executors],
|
||||
runtimes: worker.capabilities.runtimes.map((runtime) => ({
|
||||
...runtime,
|
||||
})),
|
||||
labels: { ...worker.capabilities.labels },
|
||||
capacity: {
|
||||
...worker.capabilities.capacity,
|
||||
...(worker.capabilities.capacity.gpu
|
||||
? {
|
||||
gpu: worker.capabilities.capacity.gpu.map((gpu) => ({
|
||||
...gpu,
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
features: [...worker.capabilities.features],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
import {
|
||||
RunDispatchLeaseFenceRejectedError,
|
||||
assertRunDispatchLeaseRecord,
|
||||
type RunDispatchLeaseRecord,
|
||||
type RunDispatchReleaseReason,
|
||||
} from '../domain/runDispatchLease';
|
||||
import type { WorkerRecord } from '../domain/worker';
|
||||
import type { WorkerRunLeaseClient } from '../ports/workerRunLeaseClient';
|
||||
|
||||
export const MIN_WORKER_RUN_LEASE_RETRY_MS = 100;
|
||||
export const MAX_WORKER_RUN_LEASE_RETRY_MS = 5_000;
|
||||
export const MAX_WORKER_RUN_LEASE_STOP_TIMEOUT_MS = 60_000;
|
||||
|
||||
interface ScheduledTimer {
|
||||
unref?: () => void;
|
||||
}
|
||||
|
||||
export interface WorkerRunLeaseScheduler {
|
||||
setTimeout(callback: () => void, delayMs: number): ScheduledTimer;
|
||||
clearTimeout(timer: ScheduledTimer): void;
|
||||
}
|
||||
|
||||
export type WorkerRunLeaseLossReason =
|
||||
| 'lease_expired'
|
||||
| 'fenced'
|
||||
| 'worker_session_replaced'
|
||||
| 'worker_unavailable'
|
||||
| 'invalid_renewal';
|
||||
|
||||
export interface WorkerRunLeaseLoss {
|
||||
lease: RunDispatchLeaseRecord;
|
||||
reason: WorkerRunLeaseLossReason;
|
||||
error?: unknown;
|
||||
}
|
||||
|
||||
export interface WorkerRunLeaseLifecycleOptions {
|
||||
currentSession(): WorkerRecord | undefined;
|
||||
clock?: { now(): number };
|
||||
scheduler?: WorkerRunLeaseScheduler;
|
||||
retryDelayMs?: number;
|
||||
stopTimeoutMs?: number;
|
||||
onRenewed?: (lease: RunDispatchLeaseRecord) => void;
|
||||
onLost?: (loss: WorkerRunLeaseLoss) => void;
|
||||
onError?: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export type WorkerRunLeaseStopResult = 'stopped' | 'not_started' | 'timed_out';
|
||||
|
||||
interface TrackedLease {
|
||||
lease: RunDispatchLeaseRecord;
|
||||
renewAtMs: number;
|
||||
}
|
||||
|
||||
const defaultScheduler: WorkerRunLeaseScheduler = {
|
||||
setTimeout(callback, delayMs) {
|
||||
return setTimeout(callback, delayMs);
|
||||
},
|
||||
clearTimeout(timer) {
|
||||
clearTimeout(timer as ReturnType<typeof setTimeout>);
|
||||
},
|
||||
};
|
||||
|
||||
function assertIntegerBetween(
|
||||
name: string,
|
||||
value: number,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): void {
|
||||
if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
|
||||
throw new RangeError(`${name} must be between ${minimum} and ${maximum}`);
|
||||
}
|
||||
}
|
||||
|
||||
function cloneLease(lease: RunDispatchLeaseRecord): RunDispatchLeaseRecord {
|
||||
return { ...lease };
|
||||
}
|
||||
|
||||
function renewalTime(lease: RunDispatchLeaseRecord): number {
|
||||
const duration = lease.expiresAtMs - lease.renewedAtMs;
|
||||
if (!Number.isSafeInteger(duration) || duration < 2) {
|
||||
throw new TypeError('Run dispatch lease renewal window is invalid');
|
||||
}
|
||||
return lease.renewedAtMs + Math.floor(duration / 2);
|
||||
}
|
||||
|
||||
export class WorkerRunLeaseLifecycle {
|
||||
private readonly currentSessionProvider: WorkerRunLeaseLifecycleOptions['currentSession'];
|
||||
private readonly clock: { now(): number };
|
||||
private readonly scheduler: WorkerRunLeaseScheduler;
|
||||
private readonly retryDelayMs: number;
|
||||
private readonly stopTimeoutMs: number;
|
||||
private readonly onRenewed?: WorkerRunLeaseLifecycleOptions['onRenewed'];
|
||||
private readonly onLost?: WorkerRunLeaseLifecycleOptions['onLost'];
|
||||
private readonly onError?: WorkerRunLeaseLifecycleOptions['onError'];
|
||||
private readonly tracked = new Map<string, TrackedLease>();
|
||||
private started = false;
|
||||
private releasing = false;
|
||||
private timer?: ScheduledTimer;
|
||||
private inFlight?: Promise<void>;
|
||||
|
||||
constructor(
|
||||
private readonly client: WorkerRunLeaseClient,
|
||||
options: WorkerRunLeaseLifecycleOptions,
|
||||
) {
|
||||
this.currentSessionProvider = options.currentSession;
|
||||
this.clock = options.clock ?? Date;
|
||||
this.scheduler = options.scheduler ?? defaultScheduler;
|
||||
this.retryDelayMs = options.retryDelayMs ?? 1_000;
|
||||
this.stopTimeoutMs = options.stopTimeoutMs ?? 5_000;
|
||||
this.onRenewed = options.onRenewed;
|
||||
this.onLost = options.onLost;
|
||||
this.onError = options.onError;
|
||||
assertIntegerBetween(
|
||||
'retryDelayMs',
|
||||
this.retryDelayMs,
|
||||
MIN_WORKER_RUN_LEASE_RETRY_MS,
|
||||
MAX_WORKER_RUN_LEASE_RETRY_MS,
|
||||
);
|
||||
assertIntegerBetween(
|
||||
'stopTimeoutMs',
|
||||
this.stopTimeoutMs,
|
||||
1,
|
||||
MAX_WORKER_RUN_LEASE_STOP_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
|
||||
start(): boolean {
|
||||
if (this.started || this.releasing) return false;
|
||||
this.started = true;
|
||||
this.schedule();
|
||||
return true;
|
||||
}
|
||||
|
||||
track(lease: RunDispatchLeaseRecord): void {
|
||||
if (this.releasing) {
|
||||
throw new Error('Run leases cannot be tracked while release is active');
|
||||
}
|
||||
assertRunDispatchLeaseRecord(lease);
|
||||
if (lease.status !== 'leased') {
|
||||
throw new TypeError('Only an active Run dispatch lease can be tracked');
|
||||
}
|
||||
const nowMs = this.now();
|
||||
if (lease.expiresAtMs <= nowMs) {
|
||||
throw new TypeError('An expired Run dispatch lease cannot be tracked');
|
||||
}
|
||||
const session = this.currentSessionProvider();
|
||||
this.assertCurrentSession(lease, session, nowMs);
|
||||
const existing = this.tracked.get(lease.attemptId);
|
||||
if (existing && !this.sameAuthority(existing.lease, lease)) {
|
||||
throw new TypeError(
|
||||
`Run dispatch lease ${lease.attemptId} cannot replace a different authority`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
!existing &&
|
||||
session &&
|
||||
this.tracked.size >= session.maxConcurrentRuns
|
||||
) {
|
||||
throw new RangeError('Tracked Run leases exceed Worker concurrency');
|
||||
}
|
||||
this.tracked.set(lease.attemptId, {
|
||||
lease: cloneLease(lease),
|
||||
renewAtMs: renewalTime(lease),
|
||||
});
|
||||
this.clearTimer();
|
||||
this.schedule();
|
||||
}
|
||||
|
||||
untrack(attemptId: string): RunDispatchLeaseRecord | undefined {
|
||||
const tracked = this.tracked.get(attemptId);
|
||||
if (!tracked) return undefined;
|
||||
this.tracked.delete(attemptId);
|
||||
this.clearTimer();
|
||||
this.schedule();
|
||||
return cloneLease(tracked.lease);
|
||||
}
|
||||
|
||||
leases(): RunDispatchLeaseRecord[] {
|
||||
return [...this.tracked.values()]
|
||||
.map(({ lease }) => cloneLease(lease))
|
||||
.sort((left, right) => left.attemptId.localeCompare(right.attemptId));
|
||||
}
|
||||
|
||||
async releaseAll(
|
||||
reason: Exclude<RunDispatchReleaseReason, 'lease_expired'> = 'shutdown',
|
||||
): Promise<RunDispatchLeaseRecord[]> {
|
||||
if (this.releasing) {
|
||||
throw new Error('Run lease release is already active');
|
||||
}
|
||||
this.releasing = true;
|
||||
this.clearTimer();
|
||||
const released: RunDispatchLeaseRecord[] = [];
|
||||
try {
|
||||
await this.inFlight;
|
||||
this.clearTimer();
|
||||
for (const tracked of [...this.tracked.values()]) {
|
||||
if (this.tracked.get(tracked.lease.attemptId) !== tracked) continue;
|
||||
try {
|
||||
const result = await this.client.release({
|
||||
runId: tracked.lease.runId,
|
||||
...this.fence(tracked.lease),
|
||||
reason,
|
||||
});
|
||||
this.assertRelease(tracked.lease, result.lease, reason);
|
||||
this.tracked.delete(tracked.lease.attemptId);
|
||||
released.push(cloneLease(result.lease));
|
||||
} catch (error) {
|
||||
if (error instanceof RunDispatchLeaseFenceRejectedError) {
|
||||
this.lose(tracked, 'fenced', error);
|
||||
} else {
|
||||
this.notifyError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.releasing = false;
|
||||
this.schedule();
|
||||
}
|
||||
return released;
|
||||
}
|
||||
|
||||
async stop(): Promise<WorkerRunLeaseStopResult> {
|
||||
if (!this.started) return 'not_started';
|
||||
this.started = false;
|
||||
this.clearTimer();
|
||||
const deadline = Date.now() + this.stopTimeoutMs;
|
||||
if (!(await this.waitWithin(this.inFlight, deadline))) return 'timed_out';
|
||||
return 'stopped';
|
||||
}
|
||||
|
||||
private schedule(): void {
|
||||
if (
|
||||
!this.started ||
|
||||
this.releasing ||
|
||||
this.timer ||
|
||||
this.inFlight ||
|
||||
!this.tracked.size
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const nowMs = this.now();
|
||||
const nextAtMs = Math.min(
|
||||
...[...this.tracked.values()].map((tracked) =>
|
||||
Math.min(tracked.renewAtMs, tracked.lease.expiresAtMs),
|
||||
),
|
||||
);
|
||||
const timer = this.scheduler.setTimeout(() => {
|
||||
if (this.timer === timer) this.timer = undefined;
|
||||
this.runRenewals();
|
||||
}, Math.max(0, nextAtMs - nowMs));
|
||||
this.timer = timer;
|
||||
timer.unref?.();
|
||||
}
|
||||
|
||||
private clearTimer(): void {
|
||||
if (!this.timer) return;
|
||||
this.scheduler.clearTimeout(this.timer);
|
||||
this.timer = undefined;
|
||||
}
|
||||
|
||||
private runRenewals(): void {
|
||||
if (!this.started || this.releasing || this.inFlight) return;
|
||||
const operation = this.renewDue()
|
||||
.catch((error) => this.notifyError(error))
|
||||
.then(() => undefined)
|
||||
.finally(() => {
|
||||
if (this.inFlight === operation) this.inFlight = undefined;
|
||||
this.schedule();
|
||||
});
|
||||
this.inFlight = operation;
|
||||
}
|
||||
|
||||
private async renewDue(): Promise<void> {
|
||||
const due = [...this.tracked.values()]
|
||||
.filter(
|
||||
(tracked) =>
|
||||
tracked.renewAtMs <= this.now() ||
|
||||
tracked.lease.expiresAtMs <= this.now(),
|
||||
)
|
||||
.sort((left, right) =>
|
||||
left.lease.attemptId.localeCompare(right.lease.attemptId),
|
||||
);
|
||||
for (const tracked of due) await this.renewOne(tracked);
|
||||
}
|
||||
|
||||
private async renewOne(tracked: TrackedLease): Promise<void> {
|
||||
if (this.tracked.get(tracked.lease.attemptId) !== tracked) return;
|
||||
const nowMs = this.now();
|
||||
if (tracked.lease.expiresAtMs <= nowMs) {
|
||||
this.lose(tracked, 'lease_expired');
|
||||
return;
|
||||
}
|
||||
const session = this.currentSessionProvider();
|
||||
try {
|
||||
this.assertCurrentSession(tracked.lease, session, nowMs);
|
||||
} catch (error) {
|
||||
const reason =
|
||||
session && this.sameWorkerSession(tracked.lease, session)
|
||||
? 'worker_unavailable'
|
||||
: 'worker_session_replaced';
|
||||
this.lose(tracked, reason, error);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const renewed = await this.client.renew(this.fence(tracked.lease));
|
||||
this.assertRenewal(tracked.lease, renewed, this.now());
|
||||
if (this.tracked.get(tracked.lease.attemptId) !== tracked) return;
|
||||
tracked.lease = cloneLease(renewed);
|
||||
tracked.renewAtMs = renewalTime(renewed);
|
||||
this.notifyRenewed(renewed);
|
||||
} catch (error) {
|
||||
if (error instanceof RunDispatchLeaseFenceRejectedError) {
|
||||
this.lose(tracked, 'fenced', error);
|
||||
return;
|
||||
}
|
||||
this.notifyError(error);
|
||||
const retryAtMs = this.now() + this.retryDelayMs;
|
||||
if (retryAtMs >= tracked.lease.expiresAtMs) {
|
||||
tracked.renewAtMs = tracked.lease.expiresAtMs;
|
||||
} else {
|
||||
tracked.renewAtMs = retryAtMs;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private assertRenewal(
|
||||
previous: RunDispatchLeaseRecord,
|
||||
renewed: RunDispatchLeaseRecord,
|
||||
nowMs: number,
|
||||
): void {
|
||||
assertRunDispatchLeaseRecord(renewed);
|
||||
if (
|
||||
renewed.status !== 'leased' ||
|
||||
!this.sameAuthority(previous, renewed) ||
|
||||
renewed.version !== previous.version + 1 ||
|
||||
renewed.renewedAtMs < previous.renewedAtMs ||
|
||||
renewed.expiresAtMs <= nowMs
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Control plane returned an invalid Run lease renewal',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private assertRelease(
|
||||
previous: RunDispatchLeaseRecord,
|
||||
released: RunDispatchLeaseRecord,
|
||||
reason: RunDispatchReleaseReason,
|
||||
): void {
|
||||
assertRunDispatchLeaseRecord(released);
|
||||
if (
|
||||
released.status !== 'released' ||
|
||||
!this.sameAuthority(previous, released) ||
|
||||
released.version !== previous.version + 1 ||
|
||||
released.releaseReason !== reason
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Control plane returned an invalid Run lease release',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private assertCurrentSession(
|
||||
lease: RunDispatchLeaseRecord,
|
||||
session: WorkerRecord | undefined,
|
||||
nowMs: number,
|
||||
): asserts session is WorkerRecord {
|
||||
if (!session || !this.sameWorkerSession(lease, session)) {
|
||||
throw new TypeError(
|
||||
'Run lease does not belong to the current Worker session',
|
||||
);
|
||||
}
|
||||
if (
|
||||
(session.status !== 'online' && session.status !== 'draining') ||
|
||||
session.leaseExpiresAtMs <= nowMs
|
||||
) {
|
||||
throw new TypeError('Current Worker session is unavailable');
|
||||
}
|
||||
}
|
||||
|
||||
private sameWorkerSession(
|
||||
lease: RunDispatchLeaseRecord,
|
||||
session: WorkerRecord,
|
||||
): boolean {
|
||||
return (
|
||||
lease.workerId === session.id &&
|
||||
lease.workerSessionId === session.sessionId &&
|
||||
lease.workerGeneration === session.generation
|
||||
);
|
||||
}
|
||||
|
||||
private sameAuthority(
|
||||
left: RunDispatchLeaseRecord,
|
||||
right: RunDispatchLeaseRecord,
|
||||
): boolean {
|
||||
return (
|
||||
left.attemptId === right.attemptId &&
|
||||
left.runId === right.runId &&
|
||||
left.workerId === right.workerId &&
|
||||
left.workerSessionId === right.workerSessionId &&
|
||||
left.workerGeneration === right.workerGeneration &&
|
||||
left.leaseGeneration === right.leaseGeneration &&
|
||||
left.leaseToken === right.leaseToken
|
||||
);
|
||||
}
|
||||
|
||||
private fence(lease: RunDispatchLeaseRecord) {
|
||||
return {
|
||||
attemptId: lease.attemptId,
|
||||
workerId: lease.workerId,
|
||||
workerSessionId: lease.workerSessionId,
|
||||
workerGeneration: lease.workerGeneration,
|
||||
leaseGeneration: lease.leaseGeneration,
|
||||
leaseToken: lease.leaseToken,
|
||||
expectedVersion: lease.version,
|
||||
};
|
||||
}
|
||||
|
||||
private now(): number {
|
||||
const nowMs = this.clock.now();
|
||||
if (!Number.isSafeInteger(nowMs) || nowMs < 0) {
|
||||
throw new TypeError('Worker Run lease clock returned an invalid time');
|
||||
}
|
||||
return nowMs;
|
||||
}
|
||||
|
||||
private lose(
|
||||
tracked: TrackedLease,
|
||||
reason: WorkerRunLeaseLossReason,
|
||||
error?: unknown,
|
||||
): void {
|
||||
if (this.tracked.get(tracked.lease.attemptId) !== tracked) return;
|
||||
this.tracked.delete(tracked.lease.attemptId);
|
||||
try {
|
||||
this.onLost?.({
|
||||
lease: cloneLease(tracked.lease),
|
||||
reason,
|
||||
...(error === undefined ? {} : { error }),
|
||||
});
|
||||
} catch (callbackError) {
|
||||
this.notifyError(callbackError);
|
||||
}
|
||||
}
|
||||
|
||||
private notifyRenewed(lease: RunDispatchLeaseRecord): void {
|
||||
try {
|
||||
this.onRenewed?.(cloneLease(lease));
|
||||
} catch (error) {
|
||||
this.notifyError(error);
|
||||
}
|
||||
}
|
||||
|
||||
private notifyError(error: unknown): void {
|
||||
try {
|
||||
this.onError?.(error);
|
||||
} catch {
|
||||
// Diagnostics must not create a renewal failure loop.
|
||||
}
|
||||
}
|
||||
|
||||
private async waitWithin(
|
||||
promise: Promise<unknown> | undefined,
|
||||
deadline: number,
|
||||
): Promise<boolean> {
|
||||
if (!promise) return true;
|
||||
const remainingMs = Math.max(0, deadline - Date.now());
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
const completed = await Promise.race([
|
||||
promise.then(() => true),
|
||||
new Promise<false>((resolve) => {
|
||||
timeout = setTimeout(() => resolve(false), remainingMs);
|
||||
}),
|
||||
]);
|
||||
if (timeout) clearTimeout(timeout);
|
||||
return completed;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user