mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 01:21:50 +08:00
293 lines
9.5 KiB
TypeScript
293 lines
9.5 KiB
TypeScript
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;
|
|
}
|
|
}
|