mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 09:58:46 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
@@ -0,0 +1,304 @@
|
||||
// Remote Execution owns bounded Secret and Artifact context materialization.
|
||||
import {
|
||||
MAX_LOCAL_DISPATCH_ENVIRONMENT_BYTES,
|
||||
MAX_LOCAL_DISPATCH_SECRET_REFS,
|
||||
} from '@qinglong/runtime-core/local-dispatch';
|
||||
import type { ClusterRemoteExecutionOffer } from '@qinglong/runtime-core/remote-dispatch';
|
||||
import { createClusterRemoteExecutionOffer } from '@qinglong/runtime-core/remote-dispatch';
|
||||
import { assertRunDispatchId } from '@qinglong/runtime-core/run-dispatch-lease';
|
||||
import type {
|
||||
MaterializedWorkerRemoteExecutionContext,
|
||||
WorkerRemoteExecutionContextMaterializer,
|
||||
WorkerRemoteExecutionOutputSink,
|
||||
} from './executionInboxProcessor';
|
||||
|
||||
export interface WorkerRemoteSecretResolution {
|
||||
readonly values: readonly Readonly<{
|
||||
secretRef: string;
|
||||
value: string;
|
||||
}>[];
|
||||
readonly dispose?: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
export interface WorkerRemoteSecretEnvironmentProvider {
|
||||
resolve(request: Readonly<{
|
||||
projectId: string;
|
||||
taskId: string;
|
||||
taskRevision: string;
|
||||
runId: string;
|
||||
attemptId: string;
|
||||
offerId: string;
|
||||
executionDigest: string;
|
||||
secretRefs: readonly string[];
|
||||
}>): Promise<WorkerRemoteSecretResolution | undefined>;
|
||||
}
|
||||
|
||||
export interface WorkerRemoteLogArtifactPreparation {
|
||||
readonly logArtifactId: string;
|
||||
/** Transfers the prepared writer once; release must not close it afterwards. */
|
||||
readonly takeOutput: () => WorkerRemoteExecutionOutputSink;
|
||||
/** Releases only preparation resources; it must not delete a handed-off log. */
|
||||
readonly release: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
export interface WorkerRemoteLogArtifactAllocator {
|
||||
prepare(request: Readonly<{
|
||||
projectId: string;
|
||||
runId: string;
|
||||
attemptId: string;
|
||||
offerId: string;
|
||||
}>): Promise<WorkerRemoteLogArtifactPreparation | undefined>;
|
||||
}
|
||||
|
||||
export interface BoundedWorkerRemoteExecutionContextMaterializerOptions {
|
||||
readonly artifacts: WorkerRemoteLogArtifactAllocator;
|
||||
readonly secrets?: WorkerRemoteSecretEnvironmentProvider;
|
||||
}
|
||||
|
||||
export class WorkerRemoteExecutionMaterializationError extends Error {
|
||||
constructor(
|
||||
readonly reason:
|
||||
| 'invalid_configuration'
|
||||
| 'secret_unavailable'
|
||||
| 'secret_response_invalid'
|
||||
| 'environment_budget_exceeded'
|
||||
| 'artifact_unavailable'
|
||||
| 'artifact_response_invalid',
|
||||
) {
|
||||
super(`Worker remote execution materialization failed: ${reason}`);
|
||||
this.name = 'WorkerRemoteExecutionMaterializationError';
|
||||
}
|
||||
}
|
||||
|
||||
function environmentValue(value: unknown): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.includes('\0') ||
|
||||
Buffer.byteLength(value, 'utf8') > 16 * 1024
|
||||
) {
|
||||
throw new WorkerRemoteExecutionMaterializationError(
|
||||
'secret_response_invalid',
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function disposeQuietly(
|
||||
operation: (() => Promise<void> | void) | undefined,
|
||||
): Promise<void> {
|
||||
await Promise.resolve().then(() => operation?.()).catch(() => undefined);
|
||||
}
|
||||
|
||||
export class BoundedWorkerRemoteExecutionContextMaterializer
|
||||
implements WorkerRemoteExecutionContextMaterializer {
|
||||
private readonly artifacts: WorkerRemoteLogArtifactAllocator;
|
||||
private readonly secrets?: WorkerRemoteSecretEnvironmentProvider;
|
||||
|
||||
constructor(options: BoundedWorkerRemoteExecutionContextMaterializerOptions) {
|
||||
if (
|
||||
!options ||
|
||||
typeof options.artifacts?.prepare !== 'function' ||
|
||||
(options.secrets !== undefined &&
|
||||
typeof options.secrets.resolve !== 'function')
|
||||
) {
|
||||
throw new WorkerRemoteExecutionMaterializationError(
|
||||
'invalid_configuration',
|
||||
);
|
||||
}
|
||||
this.artifacts = options.artifacts;
|
||||
this.secrets = options.secrets;
|
||||
}
|
||||
|
||||
async prepare(input: Readonly<{
|
||||
offer: ClusterRemoteExecutionOffer;
|
||||
}>): Promise<MaterializedWorkerRemoteExecutionContext> {
|
||||
let offer: ClusterRemoteExecutionOffer;
|
||||
try {
|
||||
offer = createClusterRemoteExecutionOffer(input?.offer);
|
||||
} catch {
|
||||
throw new WorkerRemoteExecutionMaterializationError(
|
||||
'invalid_configuration',
|
||||
);
|
||||
}
|
||||
const bindings = offer.executionRevision.environment;
|
||||
const secretRefs = Object.freeze([
|
||||
...new Set(bindings.flatMap((binding) =>
|
||||
binding.kind === 'secret' ? [binding.secretRef] : [])),
|
||||
]);
|
||||
if (secretRefs.length > MAX_LOCAL_DISPATCH_SECRET_REFS) {
|
||||
throw new WorkerRemoteExecutionMaterializationError(
|
||||
'environment_budget_exceeded',
|
||||
);
|
||||
}
|
||||
let secretResolution: WorkerRemoteSecretResolution | undefined;
|
||||
const secretByRef = new Map<string, string>();
|
||||
if (secretRefs.length > 0) {
|
||||
if (!this.secrets) {
|
||||
throw new WorkerRemoteExecutionMaterializationError(
|
||||
'secret_unavailable',
|
||||
);
|
||||
}
|
||||
try {
|
||||
secretResolution = await this.secrets.resolve(Object.freeze({
|
||||
projectId: offer.candidate.projectId,
|
||||
taskId: offer.candidate.taskId,
|
||||
taskRevision: offer.candidate.taskRevision,
|
||||
runId: offer.candidate.runId,
|
||||
attemptId: offer.candidate.attemptId,
|
||||
offerId: offer.offerId,
|
||||
executionDigest: offer.executionDigest,
|
||||
secretRefs,
|
||||
}));
|
||||
} catch {
|
||||
throw new WorkerRemoteExecutionMaterializationError(
|
||||
'secret_unavailable',
|
||||
);
|
||||
}
|
||||
if (!secretResolution) {
|
||||
throw new WorkerRemoteExecutionMaterializationError(
|
||||
'secret_unavailable',
|
||||
);
|
||||
}
|
||||
if (
|
||||
Object.keys(secretResolution).some((key) =>
|
||||
key !== 'values' && key !== 'dispose') ||
|
||||
!Array.isArray(secretResolution.values) ||
|
||||
secretResolution.values.length !== secretRefs.length ||
|
||||
(secretResolution.dispose !== undefined &&
|
||||
typeof secretResolution.dispose !== 'function')
|
||||
) {
|
||||
await disposeQuietly(secretResolution.dispose);
|
||||
throw new WorkerRemoteExecutionMaterializationError(
|
||||
'secret_response_invalid',
|
||||
);
|
||||
}
|
||||
try {
|
||||
for (const entry of secretResolution.values) {
|
||||
if (
|
||||
!entry ||
|
||||
typeof entry !== 'object' ||
|
||||
Object.keys(entry).length !== 2 ||
|
||||
!Object.hasOwn(entry, 'secretRef') ||
|
||||
!Object.hasOwn(entry, 'value') ||
|
||||
typeof entry.secretRef !== 'string' ||
|
||||
!secretRefs.includes(entry.secretRef) ||
|
||||
secretByRef.has(entry.secretRef)
|
||||
) {
|
||||
throw new WorkerRemoteExecutionMaterializationError(
|
||||
'secret_response_invalid',
|
||||
);
|
||||
}
|
||||
secretByRef.set(entry.secretRef, environmentValue(entry.value));
|
||||
}
|
||||
} catch (error) {
|
||||
await disposeQuietly(secretResolution.dispose);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
let environmentBytes = 0;
|
||||
let environment: MaterializedWorkerRemoteExecutionContext['environment'];
|
||||
try {
|
||||
environment = Object.freeze(bindings.map((binding) => {
|
||||
const value = binding.kind === 'public'
|
||||
? binding.value
|
||||
: secretByRef.get(binding.secretRef);
|
||||
if (value === undefined) {
|
||||
throw new WorkerRemoteExecutionMaterializationError(
|
||||
'secret_response_invalid',
|
||||
);
|
||||
}
|
||||
environmentBytes += Buffer.byteLength(binding.name, 'utf8') +
|
||||
Buffer.byteLength(value, 'utf8');
|
||||
if (environmentBytes > MAX_LOCAL_DISPATCH_ENVIRONMENT_BYTES) {
|
||||
throw new WorkerRemoteExecutionMaterializationError(
|
||||
'environment_budget_exceeded',
|
||||
);
|
||||
}
|
||||
return Object.freeze({ name: binding.name, value });
|
||||
}));
|
||||
} catch (error) {
|
||||
await disposeQuietly(secretResolution?.dispose);
|
||||
throw error;
|
||||
}
|
||||
let artifact: WorkerRemoteLogArtifactPreparation | undefined;
|
||||
try {
|
||||
artifact = await this.artifacts.prepare(Object.freeze({
|
||||
projectId: offer.candidate.projectId,
|
||||
runId: offer.candidate.runId,
|
||||
attemptId: offer.candidate.attemptId,
|
||||
offerId: offer.offerId,
|
||||
}));
|
||||
} catch {
|
||||
await disposeQuietly(secretResolution?.dispose);
|
||||
throw new WorkerRemoteExecutionMaterializationError(
|
||||
'artifact_unavailable',
|
||||
);
|
||||
}
|
||||
if (!artifact) {
|
||||
await disposeQuietly(secretResolution?.dispose);
|
||||
throw new WorkerRemoteExecutionMaterializationError(
|
||||
'artifact_unavailable',
|
||||
);
|
||||
}
|
||||
try {
|
||||
if (
|
||||
Object.keys(artifact).length !== 3 ||
|
||||
!Object.hasOwn(artifact, 'logArtifactId') ||
|
||||
!Object.hasOwn(artifact, 'takeOutput') ||
|
||||
!Object.hasOwn(artifact, 'release')
|
||||
) {
|
||||
throw new Error('invalid artifact preparation');
|
||||
}
|
||||
assertRunDispatchId('logArtifactId', artifact.logArtifactId);
|
||||
if (
|
||||
artifact.logArtifactId.length > 36 ||
|
||||
typeof artifact.takeOutput !== 'function' ||
|
||||
typeof artifact.release !== 'function'
|
||||
) {
|
||||
throw new Error('invalid artifact preparation');
|
||||
}
|
||||
} catch {
|
||||
await disposeQuietly(artifact.release);
|
||||
await disposeQuietly(secretResolution?.dispose);
|
||||
throw new WorkerRemoteExecutionMaterializationError(
|
||||
'artifact_response_invalid',
|
||||
);
|
||||
}
|
||||
let disposed = false;
|
||||
let outputTaken = false;
|
||||
return Object.freeze({
|
||||
environment,
|
||||
logArtifactId: artifact.logArtifactId,
|
||||
takeOutput() {
|
||||
if (disposed || outputTaken) {
|
||||
throw new WorkerRemoteExecutionMaterializationError(
|
||||
'artifact_response_invalid',
|
||||
);
|
||||
}
|
||||
const output = artifact!.takeOutput();
|
||||
if (
|
||||
!output ||
|
||||
typeof output !== 'object' ||
|
||||
output.logArtifactId !== artifact!.logArtifactId ||
|
||||
typeof output.write !== 'function' ||
|
||||
typeof output.close !== 'function'
|
||||
) {
|
||||
void Promise.resolve(output?.close?.()).catch(() => undefined);
|
||||
throw new WorkerRemoteExecutionMaterializationError(
|
||||
'artifact_response_invalid',
|
||||
);
|
||||
}
|
||||
outputTaken = true;
|
||||
return output;
|
||||
},
|
||||
async dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
await Promise.all([
|
||||
disposeQuietly(artifact!.release),
|
||||
disposeQuietly(secretResolution?.dispose),
|
||||
]);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
// Remote Execution owns the durable offer inbox authority and transition contract.
|
||||
import type { ClusterRemoteExecutionOffer } from '@qinglong/runtime-core/remote-dispatch';
|
||||
import { createClusterRemoteExecutionOffer } from '@qinglong/runtime-core/remote-dispatch';
|
||||
|
||||
export const WORKER_REMOTE_EXECUTION_INBOX_STATES = [
|
||||
'accepted',
|
||||
'starting_acknowledged',
|
||||
'launching',
|
||||
'started',
|
||||
'running_acknowledged',
|
||||
'start_failed',
|
||||
'start_failure_acknowledged',
|
||||
'completion_acknowledged',
|
||||
'recovery_required',
|
||||
] as const;
|
||||
|
||||
export type WorkerRemoteExecutionInboxState =
|
||||
(typeof WORKER_REMOTE_EXECUTION_INBOX_STATES)[number];
|
||||
|
||||
export type WorkerRemoteExecutionRecoveryReason =
|
||||
| 'launch_outcome_unknown'
|
||||
| 'control_plane_already_running'
|
||||
| 'control_plane_terminal'
|
||||
| 'lease_lost_local_execution_stopped'
|
||||
| 'lease_lost_local_execution_unverified';
|
||||
|
||||
export interface WorkerRemoteExecutionInboxRecord {
|
||||
readonly schemaVersion: 1;
|
||||
readonly revision: number;
|
||||
readonly state: WorkerRemoteExecutionInboxState;
|
||||
readonly offer: ClusterRemoteExecutionOffer;
|
||||
readonly acceptedAtMs: number;
|
||||
readonly updatedAtMs: number;
|
||||
readonly executorHandle?: string;
|
||||
readonly executorStartedAtMs?: number;
|
||||
readonly logArtifactId?: string;
|
||||
readonly completionReceiptCallbackSequence?: number;
|
||||
readonly completionReceiptTokenDigest?: string;
|
||||
readonly completionAcknowledgedAtMs?: number;
|
||||
readonly recoveryReason?: WorkerRemoteExecutionRecoveryReason;
|
||||
}
|
||||
|
||||
export interface WorkerRemoteExecutionInboxPage {
|
||||
readonly records: readonly WorkerRemoteExecutionInboxRecord[];
|
||||
readonly nextAfterOfferId?: string;
|
||||
}
|
||||
|
||||
export interface WorkerRemoteExecutionInbox {
|
||||
readOffer(offerId: string): Promise<WorkerRemoteExecutionInboxRecord | undefined>;
|
||||
replaceOffer(
|
||||
record: WorkerRemoteExecutionInboxRecord,
|
||||
expectedRevision: number,
|
||||
): Promise<void>;
|
||||
listOffers(options?: Readonly<{
|
||||
afterOfferId?: string;
|
||||
limit?: number;
|
||||
}>): Promise<WorkerRemoteExecutionInboxPage>;
|
||||
}
|
||||
|
||||
export class WorkerRemoteExecutionInboxError extends Error {
|
||||
constructor(
|
||||
readonly reason:
|
||||
| 'invalid_record'
|
||||
| 'authority_conflict'
|
||||
| 'revision_conflict'
|
||||
| 'invalid_transition',
|
||||
) {
|
||||
super(`Worker remote execution inbox failed: ${reason}`);
|
||||
this.name = 'WorkerRemoteExecutionInboxError';
|
||||
}
|
||||
}
|
||||
|
||||
const STATES = new Set<string>(WORKER_REMOTE_EXECUTION_INBOX_STATES);
|
||||
const SHA256 = /^[a-f0-9]{64}$/;
|
||||
const RECOVERY_REASONS = new Set<string>([
|
||||
'launch_outcome_unknown',
|
||||
'control_plane_already_running',
|
||||
'control_plane_terminal',
|
||||
'lease_lost_local_execution_stopped',
|
||||
'lease_lost_local_execution_unverified',
|
||||
]);
|
||||
const OPTIONAL_FIELDS = [
|
||||
'executorHandle',
|
||||
'executorStartedAtMs',
|
||||
'logArtifactId',
|
||||
'completionReceiptCallbackSequence',
|
||||
'completionReceiptTokenDigest',
|
||||
'completionAcknowledgedAtMs',
|
||||
'recoveryReason',
|
||||
] as const;
|
||||
const BASE_FIELDS = [
|
||||
'schemaVersion',
|
||||
'revision',
|
||||
'state',
|
||||
'offer',
|
||||
'acceptedAtMs',
|
||||
'updatedAtMs',
|
||||
] as const;
|
||||
|
||||
function invalid(): never {
|
||||
throw new WorkerRemoteExecutionInboxError('invalid_record');
|
||||
}
|
||||
|
||||
function safeInteger(value: unknown): number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 0) invalid();
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function boundedText(value: unknown, maximum: number): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length < 1 ||
|
||||
Buffer.byteLength(value, 'utf8') > maximum ||
|
||||
/[\u0000-\u001f\u007f]/.test(value)
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function sameOfferAuthority(
|
||||
left: ClusterRemoteExecutionOffer,
|
||||
right: ClusterRemoteExecutionOffer,
|
||||
): boolean {
|
||||
const first = createClusterRemoteExecutionOffer(left);
|
||||
const second = createClusterRemoteExecutionOffer(right);
|
||||
return (
|
||||
first.offerId === second.offerId &&
|
||||
first.executionDigest === second.executionDigest &&
|
||||
first.deliveryKind === second.deliveryKind &&
|
||||
JSON.stringify(first.candidate) === JSON.stringify(second.candidate) &&
|
||||
JSON.stringify(first.worker) === JSON.stringify(second.worker) &&
|
||||
first.lease.runId === second.lease.runId &&
|
||||
first.lease.attemptId === second.lease.attemptId &&
|
||||
first.lease.workerId === second.lease.workerId &&
|
||||
first.lease.workerSessionId === second.lease.workerSessionId &&
|
||||
first.lease.workerGeneration === second.lease.workerGeneration &&
|
||||
first.lease.leaseGeneration === second.lease.leaseGeneration &&
|
||||
first.lease.leaseTokenDigest === second.lease.leaseTokenDigest &&
|
||||
first.leaseToken === second.leaseToken &&
|
||||
JSON.stringify(first.executionRevision) ===
|
||||
JSON.stringify(second.executionRevision)
|
||||
);
|
||||
}
|
||||
|
||||
function exactOptional<T extends keyof WorkerRemoteExecutionInboxRecord>(
|
||||
value: WorkerRemoteExecutionInboxRecord,
|
||||
key: T,
|
||||
): WorkerRemoteExecutionInboxRecord[T] | undefined {
|
||||
return Object.prototype.hasOwnProperty.call(value, key) ? value[key] : undefined;
|
||||
}
|
||||
|
||||
export function normalizeWorkerRemoteExecutionInboxRecord(
|
||||
value: WorkerRemoteExecutionInboxRecord,
|
||||
): WorkerRemoteExecutionInboxRecord {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) invalid();
|
||||
const allowed = new Set<string>([...BASE_FIELDS, ...OPTIONAL_FIELDS]);
|
||||
const keys = Object.keys(value);
|
||||
if (
|
||||
value.schemaVersion !== 1 ||
|
||||
BASE_FIELDS.some((key) => !keys.includes(key)) ||
|
||||
keys.some((key) => !allowed.has(key)) ||
|
||||
!STATES.has(value.state)
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
const revision = safeInteger(value.revision);
|
||||
const acceptedAtMs = safeInteger(value.acceptedAtMs);
|
||||
const updatedAtMs = safeInteger(value.updatedAtMs);
|
||||
if (updatedAtMs < acceptedAtMs) invalid();
|
||||
const offer = createClusterRemoteExecutionOffer(value.offer);
|
||||
|
||||
const executorHandle = exactOptional(value, 'executorHandle');
|
||||
const executorStartedAtMs = exactOptional(value, 'executorStartedAtMs');
|
||||
const logArtifactId = exactOptional(value, 'logArtifactId');
|
||||
const callbackSequence = exactOptional(
|
||||
value,
|
||||
'completionReceiptCallbackSequence',
|
||||
);
|
||||
const tokenDigest = exactOptional(value, 'completionReceiptTokenDigest');
|
||||
const completionAcknowledgedAtMs = exactOptional(
|
||||
value,
|
||||
'completionAcknowledgedAtMs',
|
||||
);
|
||||
const recoveryReason = exactOptional(value, 'recoveryReason');
|
||||
|
||||
if (executorHandle !== undefined && executorStartedAtMs === undefined) invalid();
|
||||
if (executorHandle !== undefined) boundedText(executorHandle, 512);
|
||||
if (executorStartedAtMs !== undefined) safeInteger(executorStartedAtMs);
|
||||
if (logArtifactId !== undefined) boundedText(logArtifactId, 36);
|
||||
if ((callbackSequence === undefined) !== (tokenDigest === undefined)) invalid();
|
||||
if (
|
||||
callbackSequence !== undefined &&
|
||||
(safeInteger(callbackSequence) < 1 || callbackSequence > 2_147_483_647)
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
if (tokenDigest !== undefined && !SHA256.test(tokenDigest)) invalid();
|
||||
if (completionAcknowledgedAtMs !== undefined) {
|
||||
safeInteger(completionAcknowledgedAtMs);
|
||||
}
|
||||
if (
|
||||
recoveryReason !== undefined &&
|
||||
!RECOVERY_REASONS.has(recoveryReason)
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
|
||||
const hasExecutor = executorHandle !== undefined;
|
||||
const hasExecutorStartedAt = executorStartedAtMs !== undefined;
|
||||
const hasLogArtifact = logArtifactId !== undefined;
|
||||
const hasReceiptAuthentication = callbackSequence !== undefined;
|
||||
const executorRequired = [
|
||||
'started',
|
||||
'running_acknowledged',
|
||||
].includes(value.state);
|
||||
const executorStartedAtRequired = [
|
||||
'launching',
|
||||
'started',
|
||||
'running_acknowledged',
|
||||
'completion_acknowledged',
|
||||
].includes(value.state);
|
||||
const executorStartedAtOptional = [
|
||||
'start_failed',
|
||||
'start_failure_acknowledged',
|
||||
'recovery_required',
|
||||
].includes(value.state);
|
||||
const receiptAuthenticationRequired = [
|
||||
'launching',
|
||||
'started',
|
||||
'running_acknowledged',
|
||||
'completion_acknowledged',
|
||||
].includes(value.state);
|
||||
const receiptAuthenticationOptional = [
|
||||
'start_failed',
|
||||
'start_failure_acknowledged',
|
||||
'recovery_required',
|
||||
].includes(value.state);
|
||||
const logArtifactRequired = [
|
||||
'launching',
|
||||
'started',
|
||||
'running_acknowledged',
|
||||
'completion_acknowledged',
|
||||
].includes(value.state);
|
||||
if (
|
||||
(!['completion_acknowledged', 'recovery_required'].includes(value.state) &&
|
||||
executorRequired !== hasExecutor) ||
|
||||
(!executorStartedAtOptional &&
|
||||
executorStartedAtRequired !== hasExecutorStartedAt) ||
|
||||
(hasExecutor &&
|
||||
!['started', 'running_acknowledged', 'completion_acknowledged', 'recovery_required']
|
||||
.includes(value.state)) ||
|
||||
(hasExecutorStartedAt &&
|
||||
!['launching', 'started', 'running_acknowledged', 'start_failed',
|
||||
'start_failure_acknowledged', 'completion_acknowledged',
|
||||
'recovery_required'].includes(value.state)) ||
|
||||
(!receiptAuthenticationOptional &&
|
||||
receiptAuthenticationRequired !== hasReceiptAuthentication) ||
|
||||
(hasReceiptAuthentication &&
|
||||
!['launching', 'started', 'running_acknowledged', 'start_failed',
|
||||
'start_failure_acknowledged', 'completion_acknowledged',
|
||||
'recovery_required'].includes(value.state)) ||
|
||||
(logArtifactRequired && !hasLogArtifact) ||
|
||||
(hasLogArtifact && ['accepted', 'starting_acknowledged'].includes(value.state))
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
if (
|
||||
completionAcknowledgedAtMs !== undefined !==
|
||||
(value.state === 'completion_acknowledged') ||
|
||||
recoveryReason !== undefined !== (value.state === 'recovery_required')
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
revision,
|
||||
state: value.state,
|
||||
offer,
|
||||
acceptedAtMs,
|
||||
updatedAtMs,
|
||||
...(executorHandle === undefined ? {} : { executorHandle }),
|
||||
...(executorStartedAtMs === undefined ? {} : { executorStartedAtMs }),
|
||||
...(logArtifactId === undefined ? {} : { logArtifactId }),
|
||||
...(callbackSequence === undefined
|
||||
? {}
|
||||
: { completionReceiptCallbackSequence: callbackSequence }),
|
||||
...(tokenDigest === undefined
|
||||
? {}
|
||||
: { completionReceiptTokenDigest: tokenDigest }),
|
||||
...(completionAcknowledgedAtMs === undefined
|
||||
? {}
|
||||
: { completionAcknowledgedAtMs }),
|
||||
...(recoveryReason === undefined ? {} : { recoveryReason }),
|
||||
});
|
||||
}
|
||||
|
||||
export function createWorkerRemoteExecutionInboxRecord(
|
||||
offer: ClusterRemoteExecutionOffer,
|
||||
acceptedAtMs: number,
|
||||
): WorkerRemoteExecutionInboxRecord {
|
||||
return normalizeWorkerRemoteExecutionInboxRecord({
|
||||
schemaVersion: 1,
|
||||
revision: 0,
|
||||
state: 'accepted',
|
||||
offer,
|
||||
acceptedAtMs,
|
||||
updatedAtMs: acceptedAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
function transitions(
|
||||
...states: WorkerRemoteExecutionInboxState[]
|
||||
): ReadonlySet<WorkerRemoteExecutionInboxState> {
|
||||
return new Set(states);
|
||||
}
|
||||
|
||||
const TRANSITIONS: Readonly<Record<
|
||||
WorkerRemoteExecutionInboxState,
|
||||
ReadonlySet<WorkerRemoteExecutionInboxState>
|
||||
>> = Object.freeze({
|
||||
accepted: transitions('accepted', 'starting_acknowledged', 'recovery_required'),
|
||||
starting_acknowledged: transitions(
|
||||
'starting_acknowledged', 'launching', 'start_failed', 'recovery_required',
|
||||
),
|
||||
launching: transitions(
|
||||
'launching', 'started', 'start_failed', 'completion_acknowledged',
|
||||
'recovery_required',
|
||||
),
|
||||
started: transitions(
|
||||
'started', 'running_acknowledged', 'completion_acknowledged',
|
||||
'recovery_required',
|
||||
),
|
||||
running_acknowledged: transitions(
|
||||
'running_acknowledged', 'completion_acknowledged', 'recovery_required',
|
||||
),
|
||||
start_failed: transitions(
|
||||
'start_failed', 'start_failure_acknowledged', 'recovery_required',
|
||||
),
|
||||
start_failure_acknowledged: transitions('start_failure_acknowledged'),
|
||||
completion_acknowledged: transitions('completion_acknowledged'),
|
||||
recovery_required: transitions('recovery_required', 'completion_acknowledged'),
|
||||
});
|
||||
|
||||
export function assertWorkerRemoteExecutionInboxTransition(
|
||||
previousValue: WorkerRemoteExecutionInboxRecord,
|
||||
nextValue: WorkerRemoteExecutionInboxRecord,
|
||||
): void {
|
||||
const previous = normalizeWorkerRemoteExecutionInboxRecord(previousValue);
|
||||
const next = normalizeWorkerRemoteExecutionInboxRecord(nextValue);
|
||||
if (!sameOfferAuthority(previous.offer, next.offer)) {
|
||||
throw new WorkerRemoteExecutionInboxError('authority_conflict');
|
||||
}
|
||||
if (
|
||||
next.revision !== previous.revision + 1 ||
|
||||
next.acceptedAtMs !== previous.acceptedAtMs ||
|
||||
next.updatedAtMs < previous.updatedAtMs ||
|
||||
next.offer.lease.version < previous.offer.lease.version ||
|
||||
(next.offer.lease.version === previous.offer.lease.version &&
|
||||
JSON.stringify(next.offer.lease) !== JSON.stringify(previous.offer.lease))
|
||||
) {
|
||||
throw new WorkerRemoteExecutionInboxError('revision_conflict');
|
||||
}
|
||||
if (!TRANSITIONS[previous.state].has(next.state)) {
|
||||
throw new WorkerRemoteExecutionInboxError('invalid_transition');
|
||||
}
|
||||
for (const key of OPTIONAL_FIELDS) {
|
||||
const before = exactOptional(previous, key);
|
||||
const after = exactOptional(next, key);
|
||||
if (
|
||||
key === 'recoveryReason' &&
|
||||
next.state === 'completion_acknowledged'
|
||||
) continue;
|
||||
if (before !== undefined && before !== after) {
|
||||
throw new WorkerRemoteExecutionInboxError('invalid_transition');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,649 @@
|
||||
// Remote Execution owns offer activation, launch barriers, and recovery processing.
|
||||
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
||||
import type {
|
||||
AcknowledgeRemoteRunRunningCommand,
|
||||
AcknowledgeRemoteRunStartingCommand,
|
||||
FailRemoteRunStartCommand,
|
||||
RemoteRunActivationResult,
|
||||
} from '@qinglong/runtime-core/remote-activation';
|
||||
import type { ClusterRemoteExecutionOffer } from '@qinglong/runtime-core/remote-dispatch';
|
||||
import { createClusterRemoteExecutionOffer } from '@qinglong/runtime-core/remote-dispatch';
|
||||
import {
|
||||
normalizeWorkerRemoteExecutionInboxRecord,
|
||||
type WorkerRemoteExecutionInbox,
|
||||
type WorkerRemoteExecutionInboxRecord,
|
||||
type WorkerRemoteExecutionRecoveryReason,
|
||||
} from './executionInbox';
|
||||
|
||||
export interface WorkerRemoteExecutionSession {
|
||||
readonly workerId: string;
|
||||
readonly sessionId: string;
|
||||
readonly generation: number;
|
||||
readonly status: 'available' | 'draining' | 'offline';
|
||||
readonly leaseExpiresAtMs: number;
|
||||
}
|
||||
|
||||
export interface WorkerRemoteExecutionActivationClient {
|
||||
acknowledgeStarting(
|
||||
command: AcknowledgeRemoteRunStartingCommand,
|
||||
): Promise<Readonly<RemoteRunActivationResult>>;
|
||||
acknowledgeRunning(
|
||||
command: AcknowledgeRemoteRunRunningCommand,
|
||||
): Promise<Readonly<RemoteRunActivationResult>>;
|
||||
failStart(
|
||||
command: FailRemoteRunStartCommand,
|
||||
): Promise<Readonly<RemoteRunActivationResult>>;
|
||||
}
|
||||
|
||||
export interface WorkerRemoteExecutionCompletionCallback {
|
||||
readonly sequence: number;
|
||||
/** Ephemeral capability. Implementations must not persist or log it. */
|
||||
readonly token: Uint8Array;
|
||||
}
|
||||
|
||||
export type WorkerRemoteExecutionOutputStream = 'stdout' | 'stderr';
|
||||
|
||||
export interface WorkerRemoteExecutionOutputChunk {
|
||||
readonly stream: WorkerRemoteExecutionOutputStream;
|
||||
readonly chunk: Uint8Array;
|
||||
readonly observedAtMs: number;
|
||||
}
|
||||
|
||||
export interface WorkerRemoteExecutionOutputSink {
|
||||
readonly logArtifactId: string;
|
||||
write(output: WorkerRemoteExecutionOutputChunk): Promise<void>;
|
||||
/** Flushes accepted bytes and releases the writer. Must be idempotent. */
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface MaterializedWorkerRemoteExecutionContext {
|
||||
readonly environment: readonly Readonly<{
|
||||
name: string;
|
||||
value: string;
|
||||
}>[];
|
||||
readonly logArtifactId: string;
|
||||
/** Transfers the prepared writer exactly once after the durable spawn barrier. */
|
||||
readonly takeOutput: () => WorkerRemoteExecutionOutputSink;
|
||||
readonly dispose?: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface WorkerRemoteExecutionContextMaterializer {
|
||||
prepare(input: Readonly<{
|
||||
offer: ClusterRemoteExecutionOffer;
|
||||
completionCallback: WorkerRemoteExecutionCompletionCallback;
|
||||
}>): Promise<MaterializedWorkerRemoteExecutionContext>;
|
||||
}
|
||||
|
||||
export interface WorkerRemoteExecutionLaunch {
|
||||
readonly offerId: string;
|
||||
readonly runId: string;
|
||||
readonly attemptId: string;
|
||||
/** Durable pre-spawn timestamp from the launching inbox barrier. */
|
||||
readonly executorStartedAtMs: number;
|
||||
readonly command: ClusterRemoteExecutionOffer['executionRevision']['command'];
|
||||
readonly environment: MaterializedWorkerRemoteExecutionContext['environment'];
|
||||
readonly workingDirectory?: string;
|
||||
readonly timeoutMs?: number;
|
||||
/** Durable database-clock timeout authority returned by starting ACK. */
|
||||
readonly executionDeadlineAtMs?: number;
|
||||
readonly logArtifactId: string;
|
||||
/**
|
||||
* Ownership transfers to the Executor when start() is called. The Executor
|
||||
* must close it on every known terminal path, including explicit rejection.
|
||||
*/
|
||||
readonly output: WorkerRemoteExecutionOutputSink;
|
||||
readonly completionCallback: WorkerRemoteExecutionCompletionCallback;
|
||||
}
|
||||
|
||||
export interface WorkerRemoteExecutionExecutor {
|
||||
/** A rejected result proves no execution started; a thrown error is unknown. */
|
||||
start(launch: WorkerRemoteExecutionLaunch): Promise<
|
||||
| Readonly<{
|
||||
status: 'started';
|
||||
executorHandle: string;
|
||||
executorStartedAtMs: number;
|
||||
}>
|
||||
| Readonly<{ status: 'rejected' }>
|
||||
>;
|
||||
}
|
||||
|
||||
export type WorkerRemoteExecutionProcessResult = Readonly<{
|
||||
status:
|
||||
| 'running'
|
||||
| 'already_running'
|
||||
| 'start_failed'
|
||||
| 'already_failed'
|
||||
| 'already_completed'
|
||||
| 'recovery_required';
|
||||
offerId: string;
|
||||
executorHandle?: string;
|
||||
recoveryReason?: WorkerRemoteExecutionRecoveryReason;
|
||||
}>;
|
||||
|
||||
export interface WorkerRemoteExecutionInboxProcessorOptions {
|
||||
readonly inbox: WorkerRemoteExecutionInbox;
|
||||
readonly activation: WorkerRemoteExecutionActivationClient;
|
||||
readonly materializer: WorkerRemoteExecutionContextMaterializer;
|
||||
readonly executor: WorkerRemoteExecutionExecutor;
|
||||
readonly currentSession: () => WorkerRemoteExecutionSession | undefined;
|
||||
readonly now?: () => number;
|
||||
readonly randomCapability?: () => Uint8Array;
|
||||
readonly eventId?: () => string;
|
||||
}
|
||||
|
||||
export class WorkerRemoteExecutionProcessorError extends Error {
|
||||
constructor(
|
||||
readonly reason:
|
||||
| 'invalid_configuration'
|
||||
| 'offer_missing'
|
||||
| 'target_fenced'
|
||||
| 'offer_expired'
|
||||
| 'activation_response_invalid'
|
||||
| 'materialized_context_invalid',
|
||||
) {
|
||||
super(`Worker remote execution processor failed: ${reason}`);
|
||||
this.name = 'WorkerRemoteExecutionProcessorError';
|
||||
}
|
||||
}
|
||||
|
||||
const SHA256 = /^[a-f0-9]{64}$/;
|
||||
|
||||
function safeTime(value: number): number {
|
||||
if (!Number.isSafeInteger(value) || value < 0) {
|
||||
throw new WorkerRemoteExecutionProcessorError('invalid_configuration');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function boundedText(value: unknown, maximum: number): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length < 1 ||
|
||||
value.length > maximum ||
|
||||
/[\u0000-\u001f\u007f]/.test(value)
|
||||
) {
|
||||
throw new WorkerRemoteExecutionProcessorError('materialized_context_invalid');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function validateEnvironment(
|
||||
offer: ClusterRemoteExecutionOffer,
|
||||
context: MaterializedWorkerRemoteExecutionContext,
|
||||
): MaterializedWorkerRemoteExecutionContext['environment'] {
|
||||
if (!context || typeof context !== 'object' || !Array.isArray(context.environment)) {
|
||||
throw new WorkerRemoteExecutionProcessorError('materialized_context_invalid');
|
||||
}
|
||||
const expected = offer.executionRevision.environment;
|
||||
if (context.environment.length !== expected.length) {
|
||||
throw new WorkerRemoteExecutionProcessorError('materialized_context_invalid');
|
||||
}
|
||||
const values = new Map<string, string>();
|
||||
for (const entry of context.environment) {
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
throw new WorkerRemoteExecutionProcessorError('materialized_context_invalid');
|
||||
}
|
||||
const name = boundedText(entry.name, 255);
|
||||
if (
|
||||
name.includes('=') ||
|
||||
typeof entry.value !== 'string' ||
|
||||
entry.value.includes('\0') ||
|
||||
values.has(name)
|
||||
) {
|
||||
throw new WorkerRemoteExecutionProcessorError('materialized_context_invalid');
|
||||
}
|
||||
values.set(name, entry.value);
|
||||
}
|
||||
const normalized = expected.map((binding) => {
|
||||
const value = values.get(binding.name);
|
||||
if (
|
||||
value === undefined ||
|
||||
(binding.kind === 'public' && value !== binding.value)
|
||||
) {
|
||||
throw new WorkerRemoteExecutionProcessorError('materialized_context_invalid');
|
||||
}
|
||||
return Object.freeze({ name: binding.name, value });
|
||||
});
|
||||
return Object.freeze(normalized);
|
||||
}
|
||||
|
||||
async function validateOutput(
|
||||
context: MaterializedWorkerRemoteExecutionContext,
|
||||
logArtifactId: string,
|
||||
): Promise<WorkerRemoteExecutionOutputSink> {
|
||||
if (typeof context.takeOutput !== 'function') {
|
||||
throw new WorkerRemoteExecutionProcessorError('materialized_context_invalid');
|
||||
}
|
||||
const output = context.takeOutput();
|
||||
if (
|
||||
!output ||
|
||||
typeof output !== 'object' ||
|
||||
output.logArtifactId !== logArtifactId ||
|
||||
typeof output.write !== 'function' ||
|
||||
typeof output.close !== 'function'
|
||||
) {
|
||||
if (
|
||||
output &&
|
||||
typeof output === 'object' &&
|
||||
typeof (output as Partial<WorkerRemoteExecutionOutputSink>).close ===
|
||||
'function'
|
||||
) {
|
||||
await Promise.resolve().then(() =>
|
||||
(output as WorkerRemoteExecutionOutputSink).close()
|
||||
).catch(() => undefined);
|
||||
}
|
||||
throw new WorkerRemoteExecutionProcessorError('materialized_context_invalid');
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
export class WorkerRemoteExecutionInboxProcessor {
|
||||
private readonly inbox: WorkerRemoteExecutionInbox;
|
||||
private readonly activation: WorkerRemoteExecutionActivationClient;
|
||||
private readonly materializer: WorkerRemoteExecutionContextMaterializer;
|
||||
private readonly executor: WorkerRemoteExecutionExecutor;
|
||||
private readonly currentSessionProvider: () =>
|
||||
WorkerRemoteExecutionSession | undefined;
|
||||
private readonly nowProvider: () => number;
|
||||
private readonly randomCapabilityProvider: () => Uint8Array;
|
||||
private readonly eventIdProvider: () => string;
|
||||
private readonly inFlight = new Map<string, Promise<WorkerRemoteExecutionProcessResult>>();
|
||||
|
||||
constructor(options: WorkerRemoteExecutionInboxProcessorOptions) {
|
||||
if (
|
||||
!options ||
|
||||
typeof options.inbox?.readOffer !== 'function' ||
|
||||
typeof options.inbox?.replaceOffer !== 'function' ||
|
||||
typeof options.activation?.acknowledgeStarting !== 'function' ||
|
||||
typeof options.activation?.acknowledgeRunning !== 'function' ||
|
||||
typeof options.activation?.failStart !== 'function' ||
|
||||
typeof options.materializer?.prepare !== 'function' ||
|
||||
typeof options.executor?.start !== 'function' ||
|
||||
typeof options.currentSession !== 'function'
|
||||
) {
|
||||
throw new WorkerRemoteExecutionProcessorError('invalid_configuration');
|
||||
}
|
||||
this.inbox = options.inbox;
|
||||
this.activation = options.activation;
|
||||
this.materializer = options.materializer;
|
||||
this.executor = options.executor;
|
||||
this.currentSessionProvider = options.currentSession;
|
||||
this.nowProvider = options.now ?? Date.now;
|
||||
this.randomCapabilityProvider = options.randomCapability ??
|
||||
(() => randomBytes(32));
|
||||
this.eventIdProvider = options.eventId ?? randomUUID;
|
||||
}
|
||||
|
||||
process(offerId: string): Promise<WorkerRemoteExecutionProcessResult> {
|
||||
const active = this.inFlight.get(offerId);
|
||||
if (active) return active;
|
||||
const operation = this.processOnce(offerId).finally(() => {
|
||||
if (this.inFlight.get(offerId) === operation) this.inFlight.delete(offerId);
|
||||
});
|
||||
this.inFlight.set(offerId, operation);
|
||||
return operation;
|
||||
}
|
||||
|
||||
private async processOnce(
|
||||
offerId: string,
|
||||
): Promise<WorkerRemoteExecutionProcessResult> {
|
||||
let record = await this.inbox.readOffer(offerId);
|
||||
if (!record) {
|
||||
throw new WorkerRemoteExecutionProcessorError('offer_missing');
|
||||
}
|
||||
record = normalizeWorkerRemoteExecutionInboxRecord(record);
|
||||
if (record.state === 'completion_acknowledged') {
|
||||
return Object.freeze({ status: 'already_completed', offerId });
|
||||
}
|
||||
if (record.state === 'running_acknowledged') {
|
||||
return Object.freeze({
|
||||
status: 'already_running',
|
||||
offerId,
|
||||
executorHandle: record.executorHandle,
|
||||
});
|
||||
}
|
||||
if (record.state === 'start_failure_acknowledged') {
|
||||
return Object.freeze({ status: 'already_failed', offerId });
|
||||
}
|
||||
if (record.state === 'recovery_required') return this.recoveryResult(record);
|
||||
this.assertCurrentTarget(record.offer);
|
||||
|
||||
if (record.state === 'launching') {
|
||||
record = await this.recover(record, 'launch_outcome_unknown');
|
||||
return this.recoveryResult(record);
|
||||
}
|
||||
if (record.state === 'accepted') {
|
||||
const starting = await this.activation.acknowledgeStarting({
|
||||
...this.fence(record.offer),
|
||||
eventId: this.eventId(),
|
||||
});
|
||||
this.assertActivation(record.offer, starting);
|
||||
if (starting.status === 'already_running') {
|
||||
record = await this.recover(record, 'control_plane_already_running');
|
||||
return this.recoveryResult(record);
|
||||
}
|
||||
if (starting.status === 'already_terminal') {
|
||||
record = await this.recover(record, 'control_plane_terminal');
|
||||
return this.recoveryResult(record);
|
||||
}
|
||||
record = await this.replace(record, { state: 'starting_acknowledged' });
|
||||
}
|
||||
if (record.state === 'start_failed') {
|
||||
return this.reportStartFailure(record);
|
||||
}
|
||||
if (record.state === 'starting_acknowledged') {
|
||||
record = await this.launch(record);
|
||||
if (record.state === 'start_failed') return this.reportStartFailure(record);
|
||||
if (record.state === 'recovery_required') return this.recoveryResult(record);
|
||||
}
|
||||
if (record.state !== 'started') {
|
||||
throw new WorkerRemoteExecutionProcessorError('activation_response_invalid');
|
||||
}
|
||||
const running = await this.activation.acknowledgeRunning({
|
||||
...this.fence(record.offer),
|
||||
attemptEventId: this.eventId(),
|
||||
runEventId: this.eventId(),
|
||||
executorHandle: record.executorHandle!,
|
||||
...(record.logArtifactId === undefined
|
||||
? {}
|
||||
: { logArtifactId: record.logArtifactId }),
|
||||
callbackSequence: record.completionReceiptCallbackSequence!,
|
||||
callbackTokenDigest: record.completionReceiptTokenDigest!,
|
||||
});
|
||||
this.assertActivation(record.offer, running);
|
||||
if (running.status === 'already_terminal') {
|
||||
record = await this.recover(record, 'control_plane_terminal');
|
||||
return this.recoveryResult(record);
|
||||
}
|
||||
if (
|
||||
running.status !== 'applied' &&
|
||||
running.status !== 'already_running'
|
||||
) {
|
||||
throw new WorkerRemoteExecutionProcessorError('activation_response_invalid');
|
||||
}
|
||||
record = await this.replace(record, { state: 'running_acknowledged' });
|
||||
return Object.freeze({
|
||||
status: running.status === 'already_running' ? 'already_running' : 'running',
|
||||
offerId,
|
||||
executorHandle: record.executorHandle,
|
||||
});
|
||||
}
|
||||
|
||||
private async launch(
|
||||
record: WorkerRemoteExecutionInboxRecord,
|
||||
): Promise<WorkerRemoteExecutionInboxRecord> {
|
||||
const callback = await this.nextCallbackAuthority(record.offer);
|
||||
const callbackSequence = callback.sequence;
|
||||
const token = Buffer.from(this.randomCapabilityProvider());
|
||||
if (token.byteLength !== 32) {
|
||||
token.fill(0);
|
||||
throw new WorkerRemoteExecutionProcessorError('invalid_configuration');
|
||||
}
|
||||
const tokenDigest = createHash('sha256').update(token).digest('hex');
|
||||
let context: MaterializedWorkerRemoteExecutionContext | undefined;
|
||||
try {
|
||||
try {
|
||||
context = await this.materializer.prepare({
|
||||
offer: createClusterRemoteExecutionOffer(record.offer),
|
||||
completionCallback: Object.freeze({
|
||||
sequence: callbackSequence,
|
||||
token,
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
return await this.replace(record, { state: 'start_failed' });
|
||||
}
|
||||
let environment: MaterializedWorkerRemoteExecutionContext['environment'];
|
||||
let logArtifactId: string;
|
||||
try {
|
||||
environment = validateEnvironment(record.offer, context);
|
||||
logArtifactId = boundedText(context.logArtifactId, 36);
|
||||
if (typeof context.takeOutput !== 'function') {
|
||||
throw new WorkerRemoteExecutionProcessorError(
|
||||
'materialized_context_invalid',
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
return await this.replace(record, { state: 'start_failed' });
|
||||
}
|
||||
record = await this.replace(record, {
|
||||
state: 'launching',
|
||||
executorStartedAtMs: this.now(),
|
||||
logArtifactId,
|
||||
completionReceiptCallbackSequence: callbackSequence,
|
||||
completionReceiptTokenDigest: tokenDigest,
|
||||
});
|
||||
let output: WorkerRemoteExecutionOutputSink;
|
||||
try {
|
||||
output = await validateOutput(context, logArtifactId);
|
||||
} catch {
|
||||
return await this.replace(record, { state: 'start_failed' });
|
||||
}
|
||||
let outcome: Awaited<ReturnType<WorkerRemoteExecutionExecutor['start']>>;
|
||||
try {
|
||||
outcome = await this.executor.start(Object.freeze({
|
||||
offerId: record.offer.offerId,
|
||||
runId: record.offer.candidate.runId,
|
||||
attemptId: record.offer.candidate.attemptId,
|
||||
executorStartedAtMs: record.executorStartedAtMs!,
|
||||
command: record.offer.executionRevision.command,
|
||||
environment,
|
||||
...(record.offer.executionRevision.workingDirectory === undefined
|
||||
? {}
|
||||
: { workingDirectory: record.offer.executionRevision.workingDirectory }),
|
||||
...(record.offer.executionRevision.timeoutMs === undefined
|
||||
? {}
|
||||
: {
|
||||
timeoutMs: record.offer.executionRevision.timeoutMs,
|
||||
executionDeadlineAtMs: callback.deadlineAtMs,
|
||||
}),
|
||||
logArtifactId,
|
||||
output,
|
||||
completionCallback: Object.freeze({
|
||||
sequence: callbackSequence,
|
||||
token,
|
||||
}),
|
||||
}));
|
||||
} catch {
|
||||
return await this.recover(record, 'launch_outcome_unknown');
|
||||
}
|
||||
if (outcome?.status === 'rejected') {
|
||||
await output.close().catch(() => undefined);
|
||||
return await this.replace(record, { state: 'start_failed' });
|
||||
}
|
||||
if (outcome?.status !== 'started') {
|
||||
return await this.recover(record, 'launch_outcome_unknown');
|
||||
}
|
||||
let executorHandle: string;
|
||||
let executorStartedAtMs: number;
|
||||
try {
|
||||
executorHandle = boundedText(outcome.executorHandle, 512);
|
||||
executorStartedAtMs = outcome.executorStartedAtMs;
|
||||
if (
|
||||
!Number.isSafeInteger(executorStartedAtMs) ||
|
||||
executorStartedAtMs < 0 ||
|
||||
executorStartedAtMs > this.now() ||
|
||||
executorStartedAtMs !== record.executorStartedAtMs
|
||||
) {
|
||||
throw new WorkerRemoteExecutionProcessorError(
|
||||
'materialized_context_invalid',
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
return await this.recover(record, 'launch_outcome_unknown');
|
||||
}
|
||||
try {
|
||||
return await this.replace(record, {
|
||||
state: 'started',
|
||||
executorHandle,
|
||||
logArtifactId,
|
||||
});
|
||||
} catch {
|
||||
return await this.recover(record, 'launch_outcome_unknown');
|
||||
}
|
||||
} finally {
|
||||
token.fill(0);
|
||||
await context?.dispose?.().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
private async nextCallbackAuthority(
|
||||
offer: ClusterRemoteExecutionOffer,
|
||||
): Promise<Readonly<{ sequence: number; deadlineAtMs?: number }>> {
|
||||
const replay = await this.activation.acknowledgeStarting({
|
||||
...this.fence(offer),
|
||||
eventId: this.eventId(),
|
||||
});
|
||||
this.assertActivation(offer, replay);
|
||||
if (
|
||||
replay.status !== 'already_starting' &&
|
||||
replay.status !== 'applied'
|
||||
) {
|
||||
throw new WorkerRemoteExecutionProcessorError('activation_response_invalid');
|
||||
}
|
||||
const sequence = replay.snapshot.callbackSequence + 1;
|
||||
if (!Number.isSafeInteger(sequence) || sequence < 1 || sequence > 2_147_483_647) {
|
||||
throw new WorkerRemoteExecutionProcessorError('activation_response_invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
sequence,
|
||||
...(replay.snapshot.deadlineAtMs === undefined
|
||||
? {}
|
||||
: { deadlineAtMs: replay.snapshot.deadlineAtMs }),
|
||||
});
|
||||
}
|
||||
|
||||
private async reportStartFailure(
|
||||
record: WorkerRemoteExecutionInboxRecord,
|
||||
): Promise<WorkerRemoteExecutionProcessResult> {
|
||||
const result = await this.activation.failStart({
|
||||
...this.fence(record.offer),
|
||||
attemptEventId: this.eventId(),
|
||||
runEventId: this.eventId(),
|
||||
});
|
||||
this.assertActivation(record.offer, result, true);
|
||||
if (result.status === 'already_running') {
|
||||
const recovery = await this.recover(
|
||||
record,
|
||||
'control_plane_already_running',
|
||||
);
|
||||
return this.recoveryResult(recovery);
|
||||
}
|
||||
if (result.status !== 'applied' && result.status !== 'already_terminal') {
|
||||
throw new WorkerRemoteExecutionProcessorError('activation_response_invalid');
|
||||
}
|
||||
await this.replace(record, { state: 'start_failure_acknowledged' });
|
||||
return Object.freeze({ status: 'start_failed', offerId: record.offer.offerId });
|
||||
}
|
||||
|
||||
private assertCurrentTarget(offer: ClusterRemoteExecutionOffer): void {
|
||||
const current = this.currentSessionProvider();
|
||||
const now = this.now();
|
||||
if (
|
||||
!current ||
|
||||
current.workerId !== offer.worker.workerId ||
|
||||
current.sessionId !== offer.worker.sessionId ||
|
||||
current.generation !== offer.worker.generation ||
|
||||
current.status === 'offline' ||
|
||||
(current.status === 'draining' && offer.deliveryKind === 'new_claim') ||
|
||||
current.leaseExpiresAtMs <= now
|
||||
) {
|
||||
throw new WorkerRemoteExecutionProcessorError('target_fenced');
|
||||
}
|
||||
if (offer.lease.expiresAtMs <= now) {
|
||||
throw new WorkerRemoteExecutionProcessorError('offer_expired');
|
||||
}
|
||||
}
|
||||
|
||||
private assertActivation(
|
||||
offer: ClusterRemoteExecutionOffer,
|
||||
result: Readonly<RemoteRunActivationResult>,
|
||||
allowCompletedLease = false,
|
||||
): void {
|
||||
const snapshot = result?.snapshot;
|
||||
if (
|
||||
!['applied', 'already_starting', 'already_running', 'already_terminal']
|
||||
.includes(result?.status) ||
|
||||
!snapshot ||
|
||||
snapshot.runId !== offer.candidate.runId ||
|
||||
snapshot.attemptId !== offer.candidate.attemptId ||
|
||||
snapshot.leaseGeneration !== offer.lease.leaseGeneration ||
|
||||
!Number.isSafeInteger(snapshot.leaseVersion) ||
|
||||
snapshot.leaseVersion < offer.lease.version ||
|
||||
snapshot.leaseVersion > offer.lease.version + (allowCompletedLease ? 1 : 0) ||
|
||||
!Number.isSafeInteger(snapshot.callbackSequence) ||
|
||||
snapshot.callbackSequence < 0 ||
|
||||
snapshot.callbackSequence > 2_147_483_647
|
||||
|| (offer.executionRevision.timeoutMs === undefined) !==
|
||||
(snapshot.deadlineAtMs === undefined)
|
||||
|| (snapshot.deadlineAtMs !== undefined &&
|
||||
(!Number.isSafeInteger(snapshot.deadlineAtMs) ||
|
||||
snapshot.deadlineAtMs < 0))
|
||||
) {
|
||||
throw new WorkerRemoteExecutionProcessorError('activation_response_invalid');
|
||||
}
|
||||
}
|
||||
|
||||
private fence(offer: ClusterRemoteExecutionOffer) {
|
||||
return Object.freeze({
|
||||
runId: offer.candidate.runId,
|
||||
attemptId: offer.candidate.attemptId,
|
||||
workerId: offer.worker.workerId,
|
||||
workerSessionId: offer.worker.sessionId,
|
||||
workerGeneration: offer.worker.generation,
|
||||
offerId: offer.offerId,
|
||||
leaseGeneration: offer.lease.leaseGeneration,
|
||||
leaseToken: offer.leaseToken,
|
||||
expectedLeaseVersion: offer.lease.version,
|
||||
});
|
||||
}
|
||||
|
||||
private async replace(
|
||||
previous: WorkerRemoteExecutionInboxRecord,
|
||||
patch: Partial<WorkerRemoteExecutionInboxRecord>,
|
||||
): Promise<WorkerRemoteExecutionInboxRecord> {
|
||||
const next = normalizeWorkerRemoteExecutionInboxRecord({
|
||||
...previous,
|
||||
...patch,
|
||||
schemaVersion: 1,
|
||||
revision: previous.revision + 1,
|
||||
updatedAtMs: Math.max(this.now(), previous.updatedAtMs),
|
||||
});
|
||||
await this.inbox.replaceOffer(next, previous.revision);
|
||||
return next;
|
||||
}
|
||||
|
||||
private recover(
|
||||
record: WorkerRemoteExecutionInboxRecord,
|
||||
recoveryReason: WorkerRemoteExecutionRecoveryReason,
|
||||
): Promise<WorkerRemoteExecutionInboxRecord> {
|
||||
return this.replace(record, { state: 'recovery_required', recoveryReason });
|
||||
}
|
||||
|
||||
private recoveryResult(
|
||||
record: WorkerRemoteExecutionInboxRecord,
|
||||
): WorkerRemoteExecutionProcessResult {
|
||||
return Object.freeze({
|
||||
status: 'recovery_required',
|
||||
offerId: record.offer.offerId,
|
||||
recoveryReason: record.recoveryReason,
|
||||
});
|
||||
}
|
||||
|
||||
private eventId(): string {
|
||||
const value = this.eventIdProvider();
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length < 1 ||
|
||||
value.length > 36 ||
|
||||
/[\u0000-\u001f\u007f]/.test(value)
|
||||
) {
|
||||
throw new WorkerRemoteExecutionProcessorError('invalid_configuration');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private now(): number {
|
||||
return safeTime(this.nowProvider());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
// Remote Execution owns the caller-driven offer supervision and drain lifecycle.
|
||||
import type { WorkerRemoteExecutionInbox } from './executionInbox';
|
||||
import type {
|
||||
WorkerRemoteExecutionInboxProcessor,
|
||||
WorkerRemoteExecutionProcessResult,
|
||||
WorkerRemoteExecutionSession,
|
||||
} from './executionInboxProcessor';
|
||||
import type {
|
||||
WorkerRemoteOfferPullCoordinator,
|
||||
WorkerRemoteOfferPullResult,
|
||||
} from './remoteOfferDelivery';
|
||||
import type {
|
||||
WorkerRemoteExecutionControlCoordinator,
|
||||
WorkerRemoteExecutionControlResult,
|
||||
} from '../execution/workerExecutionControlCoordinator';
|
||||
|
||||
export interface WorkerRemoteExecutionLifecycleJournal
|
||||
extends WorkerRemoteExecutionInbox {
|
||||
acquireOwnership(): Promise<void>;
|
||||
releaseOwnership(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface WorkerRemoteExecutionHeadlessLifecycleOptions {
|
||||
readonly journal: WorkerRemoteExecutionLifecycleJournal;
|
||||
readonly offers: Pick<WorkerRemoteOfferPullCoordinator, 'pull'>;
|
||||
readonly processor: Pick<WorkerRemoteExecutionInboxProcessor, 'process'>;
|
||||
readonly control: Pick<WorkerRemoteExecutionControlCoordinator, 'reconcile'>;
|
||||
readonly currentSession: () => WorkerRemoteExecutionSession | undefined;
|
||||
readonly maximumRecordsPerTick?: number;
|
||||
readonly maximumSupervisionRecordsPerTick?: number;
|
||||
readonly now?: () => number;
|
||||
}
|
||||
|
||||
export type WorkerRemoteExecutionLifecycleTickResult =
|
||||
| Readonly<{
|
||||
status: 'reconciling';
|
||||
processed: number;
|
||||
nextAfterOfferId: string;
|
||||
}>
|
||||
| Readonly<{
|
||||
status: 'reconciled';
|
||||
processed: number;
|
||||
}>
|
||||
| Readonly<{
|
||||
status: 'recovery_required';
|
||||
offerId: string;
|
||||
}>
|
||||
| Readonly<{
|
||||
status: 'session_unavailable';
|
||||
}>
|
||||
| Readonly<{
|
||||
status: 'draining';
|
||||
}>
|
||||
| Readonly<{
|
||||
status: 'processed';
|
||||
offerId: string;
|
||||
execution: WorkerRemoteExecutionProcessResult;
|
||||
pull?: WorkerRemoteOfferPullResult;
|
||||
}>
|
||||
| Readonly<{
|
||||
status: 'pull_result';
|
||||
pull: WorkerRemoteOfferPullResult;
|
||||
}>;
|
||||
|
||||
export class WorkerRemoteExecutionLifecycleError extends Error {
|
||||
constructor(
|
||||
readonly reason:
|
||||
| 'invalid_configuration'
|
||||
| 'inactive'
|
||||
| 'stopping'
|
||||
| 'draining',
|
||||
) {
|
||||
super(`Worker remote execution lifecycle failed: ${reason}`);
|
||||
this.name = 'WorkerRemoteExecutionLifecycleError';
|
||||
}
|
||||
}
|
||||
|
||||
type Mode = 'inactive' | 'running' | 'stopping';
|
||||
|
||||
export class WorkerRemoteExecutionHeadlessLifecycle {
|
||||
private readonly journal: WorkerRemoteExecutionLifecycleJournal;
|
||||
private readonly offers: Pick<WorkerRemoteOfferPullCoordinator, 'pull'>;
|
||||
private readonly processor: Pick<WorkerRemoteExecutionInboxProcessor, 'process'>;
|
||||
private readonly control: Pick<
|
||||
WorkerRemoteExecutionControlCoordinator,
|
||||
'reconcile'
|
||||
>;
|
||||
private readonly currentSessionProvider: () =>
|
||||
WorkerRemoteExecutionSession | undefined;
|
||||
private readonly maximumRecordsPerTick: number;
|
||||
private readonly maximumSupervisionRecordsPerTick: number;
|
||||
private readonly nowProvider: () => number;
|
||||
private mode: Mode = 'inactive';
|
||||
private draining = false;
|
||||
private startupAfterOfferId?: string;
|
||||
private startupComplete = false;
|
||||
private supervisionAfterOfferId?: string;
|
||||
private retryOfferId?: string;
|
||||
private recoveryOfferId?: string;
|
||||
private inFlight?: Promise<WorkerRemoteExecutionLifecycleTickResult>;
|
||||
private stopController?: AbortController;
|
||||
private drainOperation?: Promise<void>;
|
||||
private stopOperation?: Promise<void>;
|
||||
|
||||
constructor(options: WorkerRemoteExecutionHeadlessLifecycleOptions) {
|
||||
if (
|
||||
!options ||
|
||||
typeof options.journal?.acquireOwnership !== 'function' ||
|
||||
typeof options.journal?.releaseOwnership !== 'function' ||
|
||||
typeof options.journal?.listOffers !== 'function' ||
|
||||
typeof options.offers?.pull !== 'function' ||
|
||||
typeof options.processor?.process !== 'function' ||
|
||||
typeof options.control?.reconcile !== 'function' ||
|
||||
typeof options.currentSession !== 'function'
|
||||
) {
|
||||
throw new WorkerRemoteExecutionLifecycleError('invalid_configuration');
|
||||
}
|
||||
const maximumRecordsPerTick = options.maximumRecordsPerTick ?? 16;
|
||||
const maximumSupervisionRecordsPerTick =
|
||||
options.maximumSupervisionRecordsPerTick ?? maximumRecordsPerTick;
|
||||
if (
|
||||
!Number.isSafeInteger(maximumRecordsPerTick) ||
|
||||
maximumRecordsPerTick < 1 ||
|
||||
maximumRecordsPerTick > 64
|
||||
|| !Number.isSafeInteger(maximumSupervisionRecordsPerTick)
|
||||
|| maximumSupervisionRecordsPerTick < 1
|
||||
|| maximumSupervisionRecordsPerTick > 64
|
||||
) {
|
||||
throw new WorkerRemoteExecutionLifecycleError('invalid_configuration');
|
||||
}
|
||||
this.journal = options.journal;
|
||||
this.offers = options.offers;
|
||||
this.processor = options.processor;
|
||||
this.control = options.control;
|
||||
this.currentSessionProvider = options.currentSession;
|
||||
this.maximumRecordsPerTick = maximumRecordsPerTick;
|
||||
this.maximumSupervisionRecordsPerTick = maximumSupervisionRecordsPerTick;
|
||||
this.nowProvider = options.now ?? Date.now;
|
||||
}
|
||||
|
||||
async start(): Promise<'started' | 'already_started'> {
|
||||
if (this.mode === 'running') return 'already_started';
|
||||
if (this.mode === 'stopping') {
|
||||
throw new WorkerRemoteExecutionLifecycleError('stopping');
|
||||
}
|
||||
await this.journal.acquireOwnership();
|
||||
this.mode = 'running';
|
||||
this.startupAfterOfferId = undefined;
|
||||
this.startupComplete = false;
|
||||
this.supervisionAfterOfferId = undefined;
|
||||
this.retryOfferId = undefined;
|
||||
this.recoveryOfferId = undefined;
|
||||
this.draining = false;
|
||||
this.drainOperation = undefined;
|
||||
this.stopController = new AbortController();
|
||||
return 'started';
|
||||
}
|
||||
|
||||
beginDrain(): Promise<void> {
|
||||
if (this.mode === 'stopping') {
|
||||
return Promise.reject(
|
||||
new WorkerRemoteExecutionLifecycleError('stopping'),
|
||||
);
|
||||
}
|
||||
if (this.mode !== 'running' || !this.stopController) {
|
||||
return Promise.reject(
|
||||
new WorkerRemoteExecutionLifecycleError('inactive'),
|
||||
);
|
||||
}
|
||||
if (this.drainOperation) return this.drainOperation;
|
||||
if (this.draining) return Promise.resolve();
|
||||
this.draining = true;
|
||||
this.stopController.abort(
|
||||
new WorkerRemoteExecutionLifecycleError('draining'),
|
||||
);
|
||||
const operation = (async () => {
|
||||
await this.inFlight?.catch(() => undefined);
|
||||
if (this.mode === 'running') this.stopController = new AbortController();
|
||||
})().finally(() => {
|
||||
if (this.drainOperation === operation) this.drainOperation = undefined;
|
||||
});
|
||||
this.drainOperation = operation;
|
||||
return operation;
|
||||
}
|
||||
|
||||
tick(signal?: AbortSignal): Promise<WorkerRemoteExecutionLifecycleTickResult> {
|
||||
if (this.mode === 'stopping') {
|
||||
return Promise.reject(
|
||||
new WorkerRemoteExecutionLifecycleError('stopping'),
|
||||
);
|
||||
}
|
||||
if (this.mode !== 'running' || !this.stopController) {
|
||||
return Promise.reject(
|
||||
new WorkerRemoteExecutionLifecycleError('inactive'),
|
||||
);
|
||||
}
|
||||
if (this.inFlight) return this.inFlight;
|
||||
const combinedSignal = signal === undefined
|
||||
? this.stopController.signal
|
||||
: AbortSignal.any([signal, this.stopController.signal]);
|
||||
const operation = this.tickOnce(combinedSignal).finally(() => {
|
||||
if (this.inFlight === operation) this.inFlight = undefined;
|
||||
});
|
||||
this.inFlight = operation;
|
||||
return operation;
|
||||
}
|
||||
|
||||
stop(): Promise<void> {
|
||||
if (this.stopOperation) return this.stopOperation;
|
||||
if (this.mode === 'inactive') return Promise.resolve();
|
||||
this.mode = 'stopping';
|
||||
this.stopController?.abort(
|
||||
new WorkerRemoteExecutionLifecycleError('stopping'),
|
||||
);
|
||||
const operation = (async () => {
|
||||
try {
|
||||
await this.drainOperation?.catch(() => undefined);
|
||||
await this.inFlight?.catch(() => undefined);
|
||||
await this.journal.releaseOwnership();
|
||||
} finally {
|
||||
this.mode = 'inactive';
|
||||
this.draining = false;
|
||||
this.stopController = undefined;
|
||||
this.drainOperation = undefined;
|
||||
this.stopOperation = undefined;
|
||||
}
|
||||
})();
|
||||
this.stopOperation = operation;
|
||||
return operation;
|
||||
}
|
||||
|
||||
private async tickOnce(
|
||||
signal: AbortSignal,
|
||||
): Promise<WorkerRemoteExecutionLifecycleTickResult> {
|
||||
if (this.recoveryOfferId) {
|
||||
return Object.freeze({
|
||||
status: 'recovery_required' as const,
|
||||
offerId: this.recoveryOfferId,
|
||||
});
|
||||
}
|
||||
if (!this.startupComplete) {
|
||||
return this.reconcileStartupPage();
|
||||
}
|
||||
if (this.retryOfferId) {
|
||||
const offerId = this.retryOfferId;
|
||||
const execution = await this.processor.process(offerId);
|
||||
return this.observeExecution(offerId, execution);
|
||||
}
|
||||
const supervision = await this.supervisePage();
|
||||
if (supervision) return supervision;
|
||||
if (this.draining) {
|
||||
return Object.freeze({ status: 'draining' as const });
|
||||
}
|
||||
const session = this.currentSessionProvider();
|
||||
const now = this.nowProvider();
|
||||
if (
|
||||
!session ||
|
||||
session.status !== 'available' ||
|
||||
!Number.isSafeInteger(now) ||
|
||||
now < 0 ||
|
||||
session.leaseExpiresAtMs <= now
|
||||
) {
|
||||
return Object.freeze({ status: 'session_unavailable' as const });
|
||||
}
|
||||
if (signal.aborted) throw signal.reason;
|
||||
const pull = await this.offers.pull(session, signal);
|
||||
if (pull.status !== 'accepted' && pull.status !== 'replayed') {
|
||||
return Object.freeze({ status: 'pull_result' as const, pull });
|
||||
}
|
||||
this.retryOfferId = pull.offerId;
|
||||
const execution = await this.processor.process(pull.offerId);
|
||||
return this.observeExecution(pull.offerId, execution, pull);
|
||||
}
|
||||
|
||||
private async supervisePage(): Promise<
|
||||
WorkerRemoteExecutionLifecycleTickResult | undefined
|
||||
> {
|
||||
const page = await this.journal.listOffers({
|
||||
...(this.supervisionAfterOfferId === undefined
|
||||
? {}
|
||||
: { afterOfferId: this.supervisionAfterOfferId }),
|
||||
limit: this.maximumSupervisionRecordsPerTick,
|
||||
});
|
||||
for (const record of page.records) {
|
||||
if (record.state === 'recovery_required') {
|
||||
this.recoveryOfferId = record.offer.offerId;
|
||||
return Object.freeze({
|
||||
status: 'recovery_required' as const,
|
||||
offerId: record.offer.offerId,
|
||||
});
|
||||
}
|
||||
if (
|
||||
record.state !== 'launching' &&
|
||||
record.state !== 'started' &&
|
||||
record.state !== 'running_acknowledged'
|
||||
) continue;
|
||||
const control = await this.control.reconcile(record.offer.offerId);
|
||||
if (this.controlRequiresRecovery(control)) {
|
||||
this.recoveryOfferId = record.offer.offerId;
|
||||
return Object.freeze({
|
||||
status: 'recovery_required' as const,
|
||||
offerId: record.offer.offerId,
|
||||
});
|
||||
}
|
||||
}
|
||||
this.supervisionAfterOfferId = page.nextAfterOfferId;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private controlRequiresRecovery(
|
||||
result: WorkerRemoteExecutionControlResult,
|
||||
): boolean {
|
||||
return result.status === 'lease_expired' ||
|
||||
result.status === 'terminal' ||
|
||||
result.status === 'completion_terminal';
|
||||
}
|
||||
|
||||
private async reconcileStartupPage(): Promise<
|
||||
WorkerRemoteExecutionLifecycleTickResult
|
||||
> {
|
||||
const page = await this.journal.listOffers({
|
||||
...(this.startupAfterOfferId === undefined
|
||||
? {}
|
||||
: { afterOfferId: this.startupAfterOfferId }),
|
||||
limit: this.maximumRecordsPerTick,
|
||||
});
|
||||
let processed = 0;
|
||||
for (const record of page.records) {
|
||||
if (record.state === 'recovery_required') {
|
||||
this.recoveryOfferId = record.offer.offerId;
|
||||
return Object.freeze({
|
||||
status: 'recovery_required' as const,
|
||||
offerId: record.offer.offerId,
|
||||
});
|
||||
}
|
||||
if (
|
||||
record.state === 'accepted' ||
|
||||
record.state === 'starting_acknowledged' ||
|
||||
record.state === 'launching' ||
|
||||
record.state === 'started' ||
|
||||
record.state === 'start_failed'
|
||||
) {
|
||||
const execution = await this.processor.process(record.offer.offerId);
|
||||
processed += 1;
|
||||
const observed = this.observeExecution(record.offer.offerId, execution);
|
||||
if (observed.status === 'recovery_required') return observed;
|
||||
}
|
||||
}
|
||||
if (page.nextAfterOfferId !== undefined) {
|
||||
this.startupAfterOfferId = page.nextAfterOfferId;
|
||||
return Object.freeze({
|
||||
status: 'reconciling' as const,
|
||||
processed,
|
||||
nextAfterOfferId: page.nextAfterOfferId,
|
||||
});
|
||||
}
|
||||
this.startupAfterOfferId = undefined;
|
||||
this.startupComplete = true;
|
||||
return Object.freeze({
|
||||
status: 'reconciled' as const,
|
||||
processed,
|
||||
});
|
||||
}
|
||||
|
||||
private observeExecution(
|
||||
offerId: string,
|
||||
execution: WorkerRemoteExecutionProcessResult,
|
||||
pull?: WorkerRemoteOfferPullResult,
|
||||
): WorkerRemoteExecutionLifecycleTickResult {
|
||||
if (execution.status === 'recovery_required') {
|
||||
this.recoveryOfferId = offerId;
|
||||
this.retryOfferId = undefined;
|
||||
return Object.freeze({
|
||||
status: 'recovery_required' as const,
|
||||
offerId,
|
||||
});
|
||||
}
|
||||
this.retryOfferId = undefined;
|
||||
return Object.freeze({
|
||||
status: 'processed' as const,
|
||||
offerId,
|
||||
execution,
|
||||
...(pull === undefined ? {} : { pull }),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
// Remote Execution owns stable offer claiming, delivery, and durable admission.
|
||||
import { randomBytes, randomUUID } from 'node:crypto';
|
||||
import type { ClusterRemoteExecutionOffer } from '@qinglong/runtime-core/remote-dispatch';
|
||||
import { createClusterRemoteExecutionOffer } from '@qinglong/runtime-core/remote-dispatch';
|
||||
import {
|
||||
assertWorkerId,
|
||||
assertWorkerSessionId,
|
||||
} from '@qinglong/runtime-core/worker-session';
|
||||
import {
|
||||
InvalidRemoteExecutionOfferDeliveryError,
|
||||
MAX_REMOTE_EXECUTION_OFFER_RESPONSE_BYTES,
|
||||
normalizeRemoteExecutionOfferClaimAuthority,
|
||||
parseRemoteExecutionOfferPullResponse,
|
||||
type RemoteExecutionOfferClaimAuthority,
|
||||
type RemoteExecutionOfferDeliveryStats,
|
||||
type RemoteExecutionOfferIdleReason,
|
||||
} from '@qinglong/runtime-core/remote-offer-delivery';
|
||||
import {
|
||||
normalizeWorkerRemoteExecutionInboxRecord,
|
||||
type WorkerRemoteExecutionInboxRecord,
|
||||
} from './executionInbox';
|
||||
|
||||
export const MAX_WORKER_REMOTE_OFFER_ATTEMPTS = 16;
|
||||
export const MAX_WORKER_REMOTE_OFFER_BACKOFF_MS = 60_000;
|
||||
export const MAX_WORKER_REMOTE_OFFER_INBOX_ENTRIES = 1024;
|
||||
export const DEFAULT_WORKER_REMOTE_OFFER_INBOX_ENTRIES = 64;
|
||||
export const MAX_WORKER_REMOTE_OFFER_RECORD_BYTES = 160 * 1024;
|
||||
|
||||
export interface WorkerRemoteOfferClaimRecord
|
||||
extends RemoteExecutionOfferClaimAuthority {
|
||||
readonly schemaVersion: 1;
|
||||
readonly revision: number;
|
||||
readonly attemptCount: number;
|
||||
readonly createdAtMs: number;
|
||||
readonly updatedAtMs: number;
|
||||
readonly lastAttemptAtMs: number | null;
|
||||
readonly nextAttemptAtMs: number;
|
||||
}
|
||||
|
||||
/** @deprecated Use WorkerRemoteExecutionInboxRecord. */
|
||||
export type WorkerRemoteOfferInboxRecord = WorkerRemoteExecutionInboxRecord;
|
||||
|
||||
export type WorkerRemoteOfferInboxAcceptResult = Readonly<{
|
||||
status: 'accepted' | 'replayed';
|
||||
record: WorkerRemoteOfferInboxRecord;
|
||||
}>;
|
||||
|
||||
export interface WorkerRemoteOfferDeliveryJournal {
|
||||
readPendingClaim(): Promise<WorkerRemoteOfferClaimRecord | undefined>;
|
||||
createPendingClaim(
|
||||
record: WorkerRemoteOfferClaimRecord,
|
||||
): Promise<WorkerRemoteOfferClaimRecord>;
|
||||
replacePendingClaim(
|
||||
record: WorkerRemoteOfferClaimRecord,
|
||||
expectedRevision: number,
|
||||
): Promise<WorkerRemoteOfferClaimRecord>;
|
||||
clearPendingClaim(offerId: string, expectedRevision: number): Promise<void>;
|
||||
acceptOffer(
|
||||
offer: ClusterRemoteExecutionOffer,
|
||||
acceptedAtMs: number,
|
||||
): Promise<WorkerRemoteOfferInboxAcceptResult>;
|
||||
readOffer(offerId: string): Promise<WorkerRemoteOfferInboxRecord | undefined>;
|
||||
}
|
||||
|
||||
export interface WorkerRemoteOfferTransport {
|
||||
exchange(request: Readonly<{
|
||||
path: string;
|
||||
body: Readonly<{
|
||||
workerGeneration: number;
|
||||
offerId: string;
|
||||
leaseToken: string;
|
||||
}>;
|
||||
maximumResponseBytes: number;
|
||||
signal?: AbortSignal;
|
||||
}>): Promise<Uint8Array | string>;
|
||||
}
|
||||
|
||||
export interface WorkerRemoteOfferSession {
|
||||
readonly workerId: string;
|
||||
readonly sessionId: string;
|
||||
readonly generation: number;
|
||||
}
|
||||
|
||||
export interface WorkerRemoteOfferPullCoordinatorOptions {
|
||||
readonly journal: WorkerRemoteOfferDeliveryJournal;
|
||||
readonly transport: WorkerRemoteOfferTransport;
|
||||
readonly currentSession: () => WorkerRemoteOfferSession | undefined;
|
||||
readonly now?: () => number;
|
||||
readonly random?: () => number;
|
||||
readonly backoffBaseMs?: number;
|
||||
}
|
||||
|
||||
export type WorkerRemoteOfferPullResult =
|
||||
| Readonly<{
|
||||
status: 'accepted' | 'replayed';
|
||||
offerId: string;
|
||||
stats: RemoteExecutionOfferDeliveryStats;
|
||||
truncated: boolean;
|
||||
}>
|
||||
| Readonly<{
|
||||
status: 'idle';
|
||||
reason: RemoteExecutionOfferIdleReason;
|
||||
stats: RemoteExecutionOfferDeliveryStats;
|
||||
truncated: boolean;
|
||||
}>
|
||||
| Readonly<{
|
||||
status: 'backoff' | 'unavailable' | 'invalid_response';
|
||||
offerId: string;
|
||||
nextAttemptAtMs: number;
|
||||
}>;
|
||||
|
||||
export class WorkerRemoteOfferDeliveryError extends Error {
|
||||
constructor(
|
||||
readonly reason:
|
||||
| 'invalid_configuration'
|
||||
| 'claim_conflict'
|
||||
| 'claim_revision_conflict'
|
||||
| 'offer_conflict'
|
||||
| 'attempt_budget_exhausted',
|
||||
) {
|
||||
super(`Worker remote offer delivery failed: ${reason}`);
|
||||
this.name = 'WorkerRemoteOfferDeliveryError';
|
||||
}
|
||||
}
|
||||
|
||||
function safeTime(value: number, label: string): number {
|
||||
if (!Number.isSafeInteger(value) || value < 0) {
|
||||
throw new WorkerRemoteOfferDeliveryError('invalid_configuration');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function safeRevision(value: number): number {
|
||||
if (!Number.isSafeInteger(value) || value < 0) {
|
||||
throw new WorkerRemoteOfferDeliveryError('claim_revision_conflict');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function normalizeWorkerRemoteOfferClaimRecord(
|
||||
value: WorkerRemoteOfferClaimRecord,
|
||||
): WorkerRemoteOfferClaimRecord {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new WorkerRemoteOfferDeliveryError('claim_conflict');
|
||||
}
|
||||
const expected = [
|
||||
'schemaVersion', 'revision', 'workerId', 'workerSessionId',
|
||||
'workerGeneration', 'offerId', 'leaseToken', 'attemptCount',
|
||||
'createdAtMs', 'updatedAtMs', 'lastAttemptAtMs', 'nextAttemptAtMs',
|
||||
].sort();
|
||||
const actual = Object.keys(value).sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index]) ||
|
||||
value.schemaVersion !== 1
|
||||
) {
|
||||
throw new WorkerRemoteOfferDeliveryError('claim_conflict');
|
||||
}
|
||||
const authority = normalizeRemoteExecutionOfferClaimAuthority({
|
||||
workerId: value.workerId,
|
||||
workerSessionId: value.workerSessionId,
|
||||
workerGeneration: value.workerGeneration,
|
||||
offerId: value.offerId,
|
||||
leaseToken: value.leaseToken,
|
||||
});
|
||||
safeRevision(value.revision);
|
||||
if (
|
||||
!Number.isSafeInteger(value.attemptCount) ||
|
||||
value.attemptCount < 0 ||
|
||||
value.attemptCount > MAX_WORKER_REMOTE_OFFER_ATTEMPTS
|
||||
) {
|
||||
throw new WorkerRemoteOfferDeliveryError('claim_conflict');
|
||||
}
|
||||
const createdAtMs = safeTime(value.createdAtMs, 'createdAtMs');
|
||||
const updatedAtMs = safeTime(value.updatedAtMs, 'updatedAtMs');
|
||||
const nextAttemptAtMs = safeTime(value.nextAttemptAtMs, 'nextAttemptAtMs');
|
||||
if (
|
||||
updatedAtMs < createdAtMs ||
|
||||
nextAttemptAtMs < createdAtMs ||
|
||||
(value.lastAttemptAtMs !== null &&
|
||||
(!Number.isSafeInteger(value.lastAttemptAtMs) ||
|
||||
value.lastAttemptAtMs < createdAtMs ||
|
||||
value.lastAttemptAtMs > updatedAtMs))
|
||||
) {
|
||||
throw new WorkerRemoteOfferDeliveryError('claim_conflict');
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
revision: value.revision,
|
||||
...authority,
|
||||
attemptCount: value.attemptCount,
|
||||
createdAtMs,
|
||||
updatedAtMs,
|
||||
lastAttemptAtMs: value.lastAttemptAtMs,
|
||||
nextAttemptAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
export function createWorkerRemoteOfferClaimRecord(
|
||||
authority: RemoteExecutionOfferClaimAuthority,
|
||||
createdAtMs: number,
|
||||
): WorkerRemoteOfferClaimRecord {
|
||||
const normalized = normalizeRemoteExecutionOfferClaimAuthority(authority);
|
||||
const now = safeTime(createdAtMs, 'createdAtMs');
|
||||
return normalizeWorkerRemoteOfferClaimRecord({
|
||||
schemaVersion: 1,
|
||||
revision: 0,
|
||||
...normalized,
|
||||
attemptCount: 0,
|
||||
createdAtMs: now,
|
||||
updatedAtMs: now,
|
||||
lastAttemptAtMs: null,
|
||||
nextAttemptAtMs: now,
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeWorkerRemoteOfferInboxRecord(
|
||||
value: WorkerRemoteOfferInboxRecord,
|
||||
): WorkerRemoteOfferInboxRecord {
|
||||
try {
|
||||
return normalizeWorkerRemoteExecutionInboxRecord(value);
|
||||
} catch {
|
||||
throw new WorkerRemoteOfferDeliveryError('offer_conflict');
|
||||
}
|
||||
}
|
||||
|
||||
export function sameWorkerRemoteOfferAuthority(
|
||||
left: ClusterRemoteExecutionOffer,
|
||||
right: ClusterRemoteExecutionOffer,
|
||||
): boolean {
|
||||
const leftOffer = createClusterRemoteExecutionOffer(left);
|
||||
const rightOffer = createClusterRemoteExecutionOffer(right);
|
||||
return (
|
||||
leftOffer.offerId === rightOffer.offerId &&
|
||||
leftOffer.executionDigest === rightOffer.executionDigest &&
|
||||
JSON.stringify(leftOffer.candidate) === JSON.stringify(rightOffer.candidate) &&
|
||||
JSON.stringify(leftOffer.worker) === JSON.stringify(rightOffer.worker) &&
|
||||
leftOffer.lease.runId === rightOffer.lease.runId &&
|
||||
leftOffer.lease.attemptId === rightOffer.lease.attemptId &&
|
||||
leftOffer.lease.leaseGeneration === rightOffer.lease.leaseGeneration &&
|
||||
leftOffer.lease.leaseTokenDigest === rightOffer.lease.leaseTokenDigest &&
|
||||
leftOffer.leaseToken === rightOffer.leaseToken &&
|
||||
JSON.stringify(leftOffer.executionRevision) ===
|
||||
JSON.stringify(rightOffer.executionRevision)
|
||||
);
|
||||
}
|
||||
|
||||
export class WorkerRemoteOfferPullCoordinator {
|
||||
private readonly journal: WorkerRemoteOfferDeliveryJournal;
|
||||
private readonly transport: WorkerRemoteOfferTransport;
|
||||
private readonly currentSessionProvider: () =>
|
||||
WorkerRemoteOfferSession | undefined;
|
||||
private readonly nowProvider: () => number;
|
||||
private readonly randomProvider: () => number;
|
||||
private readonly backoffBaseMs: number;
|
||||
private inFlight?: Readonly<{
|
||||
session: WorkerRemoteOfferSession;
|
||||
operation: Promise<WorkerRemoteOfferPullResult>;
|
||||
}>;
|
||||
|
||||
constructor(options: WorkerRemoteOfferPullCoordinatorOptions) {
|
||||
if (
|
||||
!options ||
|
||||
typeof options.journal?.readPendingClaim !== 'function' ||
|
||||
typeof options.transport?.exchange !== 'function' ||
|
||||
typeof options.currentSession !== 'function'
|
||||
) {
|
||||
throw new WorkerRemoteOfferDeliveryError('invalid_configuration');
|
||||
}
|
||||
const backoffBaseMs = options.backoffBaseMs ?? 1_000;
|
||||
if (
|
||||
!Number.isSafeInteger(backoffBaseMs) ||
|
||||
backoffBaseMs < 100 ||
|
||||
backoffBaseMs > MAX_WORKER_REMOTE_OFFER_BACKOFF_MS
|
||||
) {
|
||||
throw new WorkerRemoteOfferDeliveryError('invalid_configuration');
|
||||
}
|
||||
this.journal = options.journal;
|
||||
this.transport = options.transport;
|
||||
this.currentSessionProvider = options.currentSession;
|
||||
this.nowProvider = options.now ?? Date.now;
|
||||
this.randomProvider = options.random ?? Math.random;
|
||||
this.backoffBaseMs = backoffBaseMs;
|
||||
}
|
||||
|
||||
pull(
|
||||
session: WorkerRemoteOfferSession,
|
||||
signal?: AbortSignal,
|
||||
): Promise<WorkerRemoteOfferPullResult> {
|
||||
assertWorkerId(session.workerId);
|
||||
assertWorkerSessionId(session.sessionId);
|
||||
if (!Number.isSafeInteger(session.generation) || session.generation < 1) {
|
||||
throw new WorkerRemoteOfferDeliveryError('invalid_configuration');
|
||||
}
|
||||
const normalizedSession = Object.freeze({
|
||||
workerId: session.workerId,
|
||||
sessionId: session.sessionId,
|
||||
generation: session.generation,
|
||||
});
|
||||
this.assertCurrentSession(normalizedSession);
|
||||
if (this.inFlight) {
|
||||
if (
|
||||
this.inFlight.session.workerId !== normalizedSession.workerId ||
|
||||
this.inFlight.session.sessionId !== normalizedSession.sessionId ||
|
||||
this.inFlight.session.generation !== normalizedSession.generation
|
||||
) {
|
||||
return Promise.reject(
|
||||
new WorkerRemoteOfferDeliveryError('claim_conflict'),
|
||||
);
|
||||
}
|
||||
return this.inFlight.operation;
|
||||
}
|
||||
const operation = this.pullOnce(normalizedSession, signal)
|
||||
.finally(() => {
|
||||
if (this.inFlight?.operation === operation) this.inFlight = undefined;
|
||||
});
|
||||
this.inFlight = Object.freeze({
|
||||
session: normalizedSession,
|
||||
operation,
|
||||
});
|
||||
return operation;
|
||||
}
|
||||
|
||||
private async pullOnce(
|
||||
session: WorkerRemoteOfferSession,
|
||||
signal?: AbortSignal,
|
||||
): Promise<WorkerRemoteOfferPullResult> {
|
||||
const now = this.now();
|
||||
let claim = await this.journal.readPendingClaim();
|
||||
if (claim) {
|
||||
if (
|
||||
claim.workerId !== session.workerId ||
|
||||
claim.workerSessionId !== session.sessionId ||
|
||||
claim.workerGeneration !== session.generation
|
||||
) {
|
||||
throw new WorkerRemoteOfferDeliveryError('claim_conflict');
|
||||
}
|
||||
} else {
|
||||
const generated = normalizeRemoteExecutionOfferClaimAuthority({
|
||||
workerId: session.workerId,
|
||||
workerSessionId: session.sessionId,
|
||||
workerGeneration: session.generation,
|
||||
offerId: randomUUID(),
|
||||
leaseToken: randomBytes(32).toString('base64url'),
|
||||
});
|
||||
claim = await this.journal.createPendingClaim(
|
||||
createWorkerRemoteOfferClaimRecord(generated, now),
|
||||
);
|
||||
}
|
||||
if (claim.nextAttemptAtMs > now) {
|
||||
return Object.freeze({
|
||||
status: 'backoff' as const,
|
||||
offerId: claim.offerId,
|
||||
nextAttemptAtMs: claim.nextAttemptAtMs,
|
||||
});
|
||||
}
|
||||
if (claim.attemptCount >= MAX_WORKER_REMOTE_OFFER_ATTEMPTS) {
|
||||
throw new WorkerRemoteOfferDeliveryError('attempt_budget_exhausted');
|
||||
}
|
||||
claim = await this.journal.replacePendingClaim(
|
||||
normalizeWorkerRemoteOfferClaimRecord({
|
||||
...claim,
|
||||
revision: claim.revision + 1,
|
||||
attemptCount: claim.attemptCount + 1,
|
||||
lastAttemptAtMs: now,
|
||||
updatedAtMs: now,
|
||||
nextAttemptAtMs: now,
|
||||
}),
|
||||
claim.revision,
|
||||
);
|
||||
try {
|
||||
const serialized = await this.transport.exchange({
|
||||
path: `/api/v3/worker-ingress/workers/${claim.workerId}/sessions/${claim.workerSessionId}/offers`,
|
||||
body: Object.freeze({
|
||||
workerGeneration: claim.workerGeneration,
|
||||
offerId: claim.offerId,
|
||||
leaseToken: claim.leaseToken,
|
||||
}),
|
||||
maximumResponseBytes: MAX_REMOTE_EXECUTION_OFFER_RESPONSE_BYTES,
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
});
|
||||
const result = parseRemoteExecutionOfferPullResponse(serialized, {
|
||||
workerId: claim.workerId,
|
||||
workerSessionId: claim.workerSessionId,
|
||||
workerGeneration: claim.workerGeneration,
|
||||
offerId: claim.offerId,
|
||||
leaseToken: claim.leaseToken,
|
||||
});
|
||||
if (result.status === 'idle') {
|
||||
this.assertCurrentSession(session);
|
||||
await this.journal.clearPendingClaim(claim.offerId, claim.revision);
|
||||
return Object.freeze({
|
||||
status: 'idle' as const,
|
||||
reason: result.reason,
|
||||
stats: result.stats,
|
||||
truncated: result.truncated,
|
||||
});
|
||||
}
|
||||
this.assertCurrentSession(session);
|
||||
const accepted = await this.journal.acceptOffer(result.offer, this.now());
|
||||
await this.journal.clearPendingClaim(claim.offerId, claim.revision);
|
||||
return Object.freeze({
|
||||
status: accepted.status,
|
||||
offerId: accepted.record.offer.offerId,
|
||||
stats: result.stats,
|
||||
truncated: result.truncated,
|
||||
});
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw signal.reason ?? error;
|
||||
const nextAttemptAtMs = this.nextAttemptAt(claim.attemptCount, this.now());
|
||||
await this.journal.replacePendingClaim(
|
||||
normalizeWorkerRemoteOfferClaimRecord({
|
||||
...claim,
|
||||
revision: claim.revision + 1,
|
||||
updatedAtMs: this.now(),
|
||||
nextAttemptAtMs,
|
||||
}),
|
||||
claim.revision,
|
||||
);
|
||||
return Object.freeze({
|
||||
status:
|
||||
error instanceof InvalidRemoteExecutionOfferDeliveryError
|
||||
? 'invalid_response' as const
|
||||
: 'unavailable' as const,
|
||||
offerId: claim.offerId,
|
||||
nextAttemptAtMs,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private nextAttemptAt(attemptCount: number, now: number): number {
|
||||
const random = this.randomProvider();
|
||||
if (!Number.isFinite(random) || random < 0 || random >= 1) {
|
||||
throw new WorkerRemoteOfferDeliveryError('invalid_configuration');
|
||||
}
|
||||
const ceiling = Math.min(
|
||||
MAX_WORKER_REMOTE_OFFER_BACKOFF_MS,
|
||||
this.backoffBaseMs * 2 ** Math.max(0, attemptCount - 1),
|
||||
);
|
||||
return safeTime(now + Math.floor(random * ceiling), 'nextAttemptAtMs');
|
||||
}
|
||||
|
||||
private assertCurrentSession(expected: WorkerRemoteOfferSession): void {
|
||||
const current = this.currentSessionProvider();
|
||||
if (
|
||||
!current ||
|
||||
current.workerId !== expected.workerId ||
|
||||
current.sessionId !== expected.sessionId ||
|
||||
current.generation !== expected.generation
|
||||
) {
|
||||
throw new WorkerRemoteOfferDeliveryError('claim_conflict');
|
||||
}
|
||||
}
|
||||
|
||||
private now(): number {
|
||||
return safeTime(this.nowProvider(), 'now');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// Remote Execution owns its public delivery composition and transport exports.
|
||||
export * from './executionInbox';
|
||||
export * from './executionInboxProcessor';
|
||||
export * from './executionContextMaterializer';
|
||||
export * from './headlessExecutionLifecycle';
|
||||
export * from './remoteOfferDelivery';
|
||||
export * from './remoteOfferFileJournal';
|
||||
export * from './transport/remoteActivationHttpsClient';
|
||||
export * from './transport/remoteOfferHttpsTransport';
|
||||
export * from './transport/workerIngressHttpsClient';
|
||||
export * from './transport/remoteSecretHttpsProvider';
|
||||
export * from '../execution/workerFileLogArtifactAllocator';
|
||||
export * from './transport/remoteWorkerCompletionHttpsClient';
|
||||
export * from './transport/remoteWorkerLeaseControlHttpsClient';
|
||||
export * from '../execution/workerExecutionControlCoordinator';
|
||||
@@ -0,0 +1,543 @@
|
||||
// Remote Execution owns the private atomic offer journal and its single-owner fence.
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { constants } from 'node:fs';
|
||||
import {
|
||||
chmod,
|
||||
link,
|
||||
lstat,
|
||||
mkdir,
|
||||
open,
|
||||
readdir,
|
||||
rename,
|
||||
rm,
|
||||
unlink,
|
||||
} from 'node:fs/promises';
|
||||
import { isAbsolute, join } from 'node:path';
|
||||
import { lock } from 'proper-lockfile';
|
||||
import { assertRunDispatchId } from '@qinglong/runtime-core/run-dispatch-lease';
|
||||
import { createClusterRemoteExecutionOffer } from '@qinglong/runtime-core/remote-dispatch';
|
||||
import {
|
||||
DEFAULT_WORKER_REMOTE_OFFER_INBOX_ENTRIES,
|
||||
MAX_WORKER_REMOTE_OFFER_INBOX_ENTRIES,
|
||||
MAX_WORKER_REMOTE_OFFER_RECORD_BYTES,
|
||||
normalizeWorkerRemoteOfferClaimRecord,
|
||||
sameWorkerRemoteOfferAuthority,
|
||||
type WorkerRemoteOfferClaimRecord,
|
||||
type WorkerRemoteOfferDeliveryJournal,
|
||||
type WorkerRemoteOfferInboxAcceptResult,
|
||||
} from './remoteOfferDelivery';
|
||||
import type { ClusterRemoteExecutionOffer } from '@qinglong/runtime-core/remote-dispatch';
|
||||
import {
|
||||
assertWorkerRemoteExecutionInboxTransition,
|
||||
createWorkerRemoteExecutionInboxRecord,
|
||||
normalizeWorkerRemoteExecutionInboxRecord,
|
||||
WorkerRemoteExecutionInboxError,
|
||||
type WorkerRemoteExecutionInbox,
|
||||
type WorkerRemoteExecutionInboxPage,
|
||||
type WorkerRemoteExecutionInboxRecord,
|
||||
} from './executionInbox';
|
||||
|
||||
const OFFER_FILE = /^([A-Za-z0-9._:-]{1,128})\.json$/;
|
||||
const MIN_OWNERSHIP_STALE_MS = 5_000;
|
||||
const MAX_OWNERSHIP_STALE_MS = 5 * 60_000;
|
||||
|
||||
export interface WorkerRemoteOfferFileJournalOptions {
|
||||
readonly rootDirectory: string;
|
||||
readonly maximumEntries?: number;
|
||||
readonly ownershipStaleMs?: number;
|
||||
}
|
||||
|
||||
export class WorkerRemoteOfferFileJournalError extends Error {
|
||||
constructor(
|
||||
readonly reason:
|
||||
| 'invalid_configuration'
|
||||
| 'not_owned'
|
||||
| 'already_owned'
|
||||
| 'ownership_compromised'
|
||||
| 'unsafe_storage'
|
||||
| 'capacity_exhausted'
|
||||
| 'claim_revision_conflict'
|
||||
| 'offer_revision_conflict'
|
||||
| 'invalid_transition'
|
||||
| 'offer_conflict',
|
||||
) {
|
||||
super(`Worker remote offer file journal failed: ${reason}`);
|
||||
this.name = 'WorkerRemoteOfferFileJournalError';
|
||||
}
|
||||
}
|
||||
|
||||
function isCode(error: unknown, code: string): boolean {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
(error as NodeJS.ErrnoException).code === code
|
||||
);
|
||||
}
|
||||
|
||||
async function safeDirectory(path: string): Promise<void> {
|
||||
try {
|
||||
let created = false;
|
||||
try {
|
||||
await lstat(path);
|
||||
} catch (error) {
|
||||
if (!isCode(error, 'ENOENT')) throw error;
|
||||
await mkdir(path, { recursive: true, mode: 0o700 });
|
||||
created = true;
|
||||
}
|
||||
const stat = await lstat(path);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink() || (stat.mode & 0o077) !== 0) {
|
||||
throw new WorkerRemoteOfferFileJournalError('unsafe_storage');
|
||||
}
|
||||
if (created) await chmod(path, 0o700);
|
||||
} catch (error) {
|
||||
if (error instanceof WorkerRemoteOfferFileJournalError) throw error;
|
||||
throw new WorkerRemoteOfferFileJournalError('unsafe_storage');
|
||||
}
|
||||
}
|
||||
|
||||
async function syncDirectory(path: string): Promise<void> {
|
||||
const handle = await open(path, constants.O_RDONLY);
|
||||
try {
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
function serialize(value: unknown): Buffer {
|
||||
const bytes = Buffer.from(`${JSON.stringify(value)}\n`, 'utf8');
|
||||
if (bytes.byteLength < 2 || bytes.byteLength > MAX_WORKER_REMOTE_OFFER_RECORD_BYTES) {
|
||||
bytes.fill(0);
|
||||
throw new WorkerRemoteOfferFileJournalError('unsafe_storage');
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
async function readJson(path: string): Promise<unknown> {
|
||||
let handle;
|
||||
try {
|
||||
handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
||||
const stat = await handle.stat();
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
stat.size < 2 ||
|
||||
stat.size > MAX_WORKER_REMOTE_OFFER_RECORD_BYTES ||
|
||||
(stat.mode & 0o077) !== 0
|
||||
) {
|
||||
throw new WorkerRemoteOfferFileJournalError('unsafe_storage');
|
||||
}
|
||||
const bytes = await handle.readFile();
|
||||
try {
|
||||
return JSON.parse(bytes.toString('utf8')) as unknown;
|
||||
} finally {
|
||||
bytes.fill(0);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isCode(error, 'ENOENT')) return undefined;
|
||||
if (error instanceof WorkerRemoteOfferFileJournalError) throw error;
|
||||
throw new WorkerRemoteOfferFileJournalError('unsafe_storage');
|
||||
} finally {
|
||||
await handle?.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
export class WorkerRemoteOfferFileJournal
|
||||
implements WorkerRemoteOfferDeliveryJournal, WorkerRemoteExecutionInbox {
|
||||
private readonly rootDirectory: string;
|
||||
private readonly offersDirectory: string;
|
||||
private readonly maximumEntries: number;
|
||||
private readonly ownershipStaleMs: number;
|
||||
private releaseOwnershipLock?: () => Promise<void>;
|
||||
private compromised = false;
|
||||
private mutationTail: Promise<void> = Promise.resolve();
|
||||
|
||||
constructor(options: WorkerRemoteOfferFileJournalOptions) {
|
||||
if (
|
||||
!options ||
|
||||
typeof options.rootDirectory !== 'string' ||
|
||||
!isAbsolute(options.rootDirectory) ||
|
||||
options.rootDirectory.length > 4096 ||
|
||||
/[\0\r\n]/.test(options.rootDirectory)
|
||||
) {
|
||||
throw new WorkerRemoteOfferFileJournalError('invalid_configuration');
|
||||
}
|
||||
const maximumEntries =
|
||||
options.maximumEntries ?? DEFAULT_WORKER_REMOTE_OFFER_INBOX_ENTRIES;
|
||||
if (
|
||||
!Number.isSafeInteger(maximumEntries) ||
|
||||
maximumEntries < 1 ||
|
||||
maximumEntries > MAX_WORKER_REMOTE_OFFER_INBOX_ENTRIES
|
||||
) {
|
||||
throw new WorkerRemoteOfferFileJournalError('invalid_configuration');
|
||||
}
|
||||
const ownershipStaleMs = options.ownershipStaleMs ?? 30_000;
|
||||
if (
|
||||
!Number.isSafeInteger(ownershipStaleMs) ||
|
||||
ownershipStaleMs < MIN_OWNERSHIP_STALE_MS ||
|
||||
ownershipStaleMs > MAX_OWNERSHIP_STALE_MS
|
||||
) {
|
||||
throw new WorkerRemoteOfferFileJournalError('invalid_configuration');
|
||||
}
|
||||
this.rootDirectory = options.rootDirectory;
|
||||
this.offersDirectory = join(options.rootDirectory, 'offers');
|
||||
this.maximumEntries = maximumEntries;
|
||||
this.ownershipStaleMs = ownershipStaleMs;
|
||||
}
|
||||
|
||||
async acquireOwnership(): Promise<void> {
|
||||
if (this.releaseOwnershipLock) {
|
||||
throw new WorkerRemoteOfferFileJournalError('already_owned');
|
||||
}
|
||||
await safeDirectory(this.rootDirectory);
|
||||
await safeDirectory(this.offersDirectory);
|
||||
try {
|
||||
this.compromised = false;
|
||||
this.releaseOwnershipLock = await lock(this.rootDirectory, {
|
||||
stale: this.ownershipStaleMs,
|
||||
update: Math.floor(this.ownershipStaleMs / 2),
|
||||
retries: 0,
|
||||
realpath: true,
|
||||
lockfilePath: join(this.rootDirectory, '.owner.lock'),
|
||||
onCompromised: () => {
|
||||
this.compromised = true;
|
||||
this.releaseOwnershipLock = undefined;
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
throw new WorkerRemoteOfferFileJournalError('already_owned');
|
||||
}
|
||||
}
|
||||
|
||||
async releaseOwnership(): Promise<void> {
|
||||
this.assertOwned();
|
||||
const release = this.releaseOwnershipLock!;
|
||||
this.releaseOwnershipLock = undefined;
|
||||
await this.mutationTail.catch(() => undefined);
|
||||
try {
|
||||
await release();
|
||||
} catch {
|
||||
throw new WorkerRemoteOfferFileJournalError('ownership_compromised');
|
||||
}
|
||||
}
|
||||
|
||||
async readPendingClaim(): Promise<WorkerRemoteOfferClaimRecord | undefined> {
|
||||
this.assertOwned();
|
||||
const value = await readJson(join(this.rootDirectory, 'pending-claim.json'));
|
||||
if (value === undefined) return undefined;
|
||||
return normalizeWorkerRemoteOfferClaimRecord(
|
||||
value as WorkerRemoteOfferClaimRecord,
|
||||
);
|
||||
}
|
||||
|
||||
createPendingClaim(
|
||||
record: WorkerRemoteOfferClaimRecord,
|
||||
): Promise<WorkerRemoteOfferClaimRecord> {
|
||||
return this.mutate(async () => {
|
||||
const candidate = normalizeWorkerRemoteOfferClaimRecord(record);
|
||||
const existing = await this.readPendingClaim();
|
||||
if (existing) {
|
||||
if (!this.sameClaim(existing, candidate)) {
|
||||
throw new WorkerRemoteOfferFileJournalError('offer_conflict');
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
await this.writeFirst(join(this.rootDirectory, 'pending-claim.json'), candidate);
|
||||
return candidate;
|
||||
});
|
||||
}
|
||||
|
||||
replacePendingClaim(
|
||||
record: WorkerRemoteOfferClaimRecord,
|
||||
expectedRevision: number,
|
||||
): Promise<WorkerRemoteOfferClaimRecord> {
|
||||
return this.mutate(async () => {
|
||||
const candidate = normalizeWorkerRemoteOfferClaimRecord(record);
|
||||
const existing = await this.readPendingClaim();
|
||||
if (
|
||||
!existing ||
|
||||
existing.revision !== expectedRevision ||
|
||||
candidate.revision !== expectedRevision + 1 ||
|
||||
!this.sameClaim(existing, candidate)
|
||||
) {
|
||||
throw new WorkerRemoteOfferFileJournalError('claim_revision_conflict');
|
||||
}
|
||||
await this.writeReplacement(
|
||||
join(this.rootDirectory, 'pending-claim.json'),
|
||||
candidate,
|
||||
);
|
||||
return candidate;
|
||||
});
|
||||
}
|
||||
|
||||
clearPendingClaim(offerId: string, expectedRevision: number): Promise<void> {
|
||||
return this.mutate(async () => {
|
||||
assertRunDispatchId('offerId', offerId);
|
||||
const existing = await this.readPendingClaim();
|
||||
if (
|
||||
!existing ||
|
||||
existing.offerId !== offerId ||
|
||||
existing.revision !== expectedRevision
|
||||
) {
|
||||
throw new WorkerRemoteOfferFileJournalError('claim_revision_conflict');
|
||||
}
|
||||
try {
|
||||
await unlink(join(this.rootDirectory, 'pending-claim.json'));
|
||||
await syncDirectory(this.rootDirectory);
|
||||
} catch {
|
||||
throw new WorkerRemoteOfferFileJournalError('unsafe_storage');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
acceptOffer(
|
||||
delivered: ClusterRemoteExecutionOffer,
|
||||
acceptedAtMs: number,
|
||||
): Promise<WorkerRemoteOfferInboxAcceptResult> {
|
||||
return this.mutate(async () => {
|
||||
const offer = createClusterRemoteExecutionOffer(delivered);
|
||||
const existing = await this.readOffer(offer.offerId);
|
||||
if (existing) {
|
||||
if (!sameWorkerRemoteOfferAuthority(existing.offer, offer)) {
|
||||
throw new WorkerRemoteOfferFileJournalError('offer_conflict');
|
||||
}
|
||||
if (offer.lease.version > existing.offer.lease.version) {
|
||||
const updated = normalizeWorkerRemoteExecutionInboxRecord({
|
||||
...existing,
|
||||
revision: existing.revision + 1,
|
||||
offer,
|
||||
updatedAtMs: acceptedAtMs,
|
||||
});
|
||||
this.assertOfferTransition(existing, updated);
|
||||
await this.writeReplacement(this.offerPath(offer.offerId), updated);
|
||||
return Object.freeze({ status: 'replayed' as const, record: updated });
|
||||
}
|
||||
return Object.freeze({ status: 'replayed' as const, record: existing });
|
||||
}
|
||||
const names = await this.offerNames();
|
||||
if (names.length >= this.maximumEntries) {
|
||||
throw new WorkerRemoteOfferFileJournalError('capacity_exhausted');
|
||||
}
|
||||
const record = createWorkerRemoteExecutionInboxRecord(offer, acceptedAtMs);
|
||||
await this.writeFirst(this.offerPath(offer.offerId), record);
|
||||
return Object.freeze({ status: 'accepted' as const, record });
|
||||
});
|
||||
}
|
||||
|
||||
async readOffer(
|
||||
offerId: string,
|
||||
): Promise<WorkerRemoteExecutionInboxRecord | undefined> {
|
||||
this.assertOwned();
|
||||
const value = await readJson(this.offerPath(offerId));
|
||||
if (value === undefined) return undefined;
|
||||
return normalizeWorkerRemoteExecutionInboxRecord(
|
||||
value as WorkerRemoteExecutionInboxRecord,
|
||||
);
|
||||
}
|
||||
|
||||
replaceOffer(
|
||||
record: WorkerRemoteExecutionInboxRecord,
|
||||
expectedRevision: number,
|
||||
): Promise<void> {
|
||||
return this.mutate(async () => {
|
||||
const candidate = normalizeWorkerRemoteExecutionInboxRecord(record);
|
||||
const existing = await this.readOffer(candidate.offer.offerId);
|
||||
if (
|
||||
!existing ||
|
||||
existing.revision !== expectedRevision ||
|
||||
candidate.revision !== expectedRevision + 1
|
||||
) {
|
||||
throw new WorkerRemoteOfferFileJournalError('offer_revision_conflict');
|
||||
}
|
||||
this.assertOfferTransition(existing, candidate);
|
||||
await this.writeReplacement(this.offerPath(candidate.offer.offerId), candidate);
|
||||
});
|
||||
}
|
||||
|
||||
async listOffers(options: Readonly<{
|
||||
afterOfferId?: string;
|
||||
limit?: number;
|
||||
}> = {}): Promise<WorkerRemoteExecutionInboxPage> {
|
||||
this.assertOwned();
|
||||
const limit = options.limit ?? 16;
|
||||
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 64) {
|
||||
throw new WorkerRemoteOfferFileJournalError('invalid_configuration');
|
||||
}
|
||||
if (options.afterOfferId !== undefined) {
|
||||
this.offerPath(options.afterOfferId);
|
||||
}
|
||||
const names = await this.offerNames();
|
||||
const selected = names
|
||||
.filter((offerId) =>
|
||||
options.afterOfferId === undefined || offerId > options.afterOfferId)
|
||||
.slice(0, limit);
|
||||
const records: WorkerRemoteExecutionInboxRecord[] = [];
|
||||
for (const offerId of selected) {
|
||||
const record = await this.readOffer(offerId);
|
||||
if (!record) {
|
||||
throw new WorkerRemoteOfferFileJournalError('unsafe_storage');
|
||||
}
|
||||
records.push(record);
|
||||
}
|
||||
const hasMore = selected.length > 0 &&
|
||||
names.some((offerId) => offerId > selected[selected.length - 1]!);
|
||||
return Object.freeze({
|
||||
records: Object.freeze(records),
|
||||
...(hasMore
|
||||
? { nextAfterOfferId: selected[selected.length - 1]! }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
private assertOfferTransition(
|
||||
previous: WorkerRemoteExecutionInboxRecord,
|
||||
next: WorkerRemoteExecutionInboxRecord,
|
||||
): void {
|
||||
try {
|
||||
assertWorkerRemoteExecutionInboxTransition(previous, next);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof WorkerRemoteExecutionInboxError &&
|
||||
error.reason === 'revision_conflict'
|
||||
) {
|
||||
throw new WorkerRemoteOfferFileJournalError('offer_revision_conflict');
|
||||
}
|
||||
if (
|
||||
error instanceof WorkerRemoteExecutionInboxError &&
|
||||
error.reason === 'invalid_transition'
|
||||
) {
|
||||
throw new WorkerRemoteOfferFileJournalError('invalid_transition');
|
||||
}
|
||||
throw new WorkerRemoteOfferFileJournalError('offer_conflict');
|
||||
}
|
||||
}
|
||||
|
||||
private mutate<T>(operation: () => Promise<T>): Promise<T> {
|
||||
this.assertOwned();
|
||||
const result = this.mutationTail.then(operation, operation);
|
||||
this.mutationTail = result.then(() => undefined, () => undefined);
|
||||
return result;
|
||||
}
|
||||
|
||||
private assertOwned(): void {
|
||||
if (this.compromised) {
|
||||
throw new WorkerRemoteOfferFileJournalError('ownership_compromised');
|
||||
}
|
||||
if (!this.releaseOwnershipLock) {
|
||||
throw new WorkerRemoteOfferFileJournalError('not_owned');
|
||||
}
|
||||
}
|
||||
|
||||
private sameClaim(
|
||||
left: WorkerRemoteOfferClaimRecord,
|
||||
right: WorkerRemoteOfferClaimRecord,
|
||||
): boolean {
|
||||
return (
|
||||
left.workerId === right.workerId &&
|
||||
left.workerSessionId === right.workerSessionId &&
|
||||
left.workerGeneration === right.workerGeneration &&
|
||||
left.offerId === right.offerId &&
|
||||
left.leaseToken === right.leaseToken
|
||||
);
|
||||
}
|
||||
|
||||
private offerPath(offerId: string): string {
|
||||
assertRunDispatchId('offerId', offerId);
|
||||
if (!/^[A-Za-z0-9._:-]+$/.test(offerId)) {
|
||||
throw new WorkerRemoteOfferFileJournalError('offer_conflict');
|
||||
}
|
||||
return join(this.offersDirectory, `${offerId}.json`);
|
||||
}
|
||||
|
||||
private async offerNames(): Promise<string[]> {
|
||||
this.assertOwned();
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(this.offersDirectory, { withFileTypes: true });
|
||||
} catch {
|
||||
throw new WorkerRemoteOfferFileJournalError('unsafe_storage');
|
||||
}
|
||||
const names: string[] = [];
|
||||
for (const entry of entries) {
|
||||
const match = OFFER_FILE.exec(entry.name);
|
||||
if (!entry.isFile() || !match) {
|
||||
throw new WorkerRemoteOfferFileJournalError('unsafe_storage');
|
||||
}
|
||||
names.push(match[1]!);
|
||||
}
|
||||
return names.sort();
|
||||
}
|
||||
|
||||
private temporary(target: string): string {
|
||||
return join(
|
||||
this.rootDirectory,
|
||||
`.${target.split('/').at(-1)}.${randomBytes(16).toString('hex')}.tmp`,
|
||||
);
|
||||
}
|
||||
|
||||
private async writeFirst(target: string, value: unknown): Promise<void> {
|
||||
const temporary = this.temporary(target);
|
||||
const bytes = serialize(value);
|
||||
try {
|
||||
const handle = await open(
|
||||
temporary,
|
||||
constants.O_WRONLY |
|
||||
constants.O_CREAT |
|
||||
constants.O_EXCL |
|
||||
(constants.O_NOFOLLOW ?? 0),
|
||||
0o600,
|
||||
);
|
||||
try {
|
||||
await handle.writeFile(bytes);
|
||||
await handle.sync();
|
||||
await handle.chmod(0o600);
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
await link(temporary, target);
|
||||
await syncDirectory(target.startsWith(this.offersDirectory)
|
||||
? this.offersDirectory
|
||||
: this.rootDirectory);
|
||||
} catch (error) {
|
||||
if (isCode(error, 'EEXIST')) {
|
||||
throw new WorkerRemoteOfferFileJournalError('offer_conflict');
|
||||
}
|
||||
if (error instanceof WorkerRemoteOfferFileJournalError) throw error;
|
||||
throw new WorkerRemoteOfferFileJournalError('unsafe_storage');
|
||||
} finally {
|
||||
bytes.fill(0);
|
||||
await rm(temporary, { force: true }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
private async writeReplacement(target: string, value: unknown): Promise<void> {
|
||||
const temporary = this.temporary(target);
|
||||
const bytes = serialize(value);
|
||||
try {
|
||||
const handle = await open(
|
||||
temporary,
|
||||
constants.O_WRONLY |
|
||||
constants.O_CREAT |
|
||||
constants.O_EXCL |
|
||||
(constants.O_NOFOLLOW ?? 0),
|
||||
0o600,
|
||||
);
|
||||
try {
|
||||
await handle.writeFile(bytes);
|
||||
await handle.sync();
|
||||
await handle.chmod(0o600);
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
await rename(temporary, target);
|
||||
await syncDirectory(target.startsWith(this.offersDirectory)
|
||||
? this.offersDirectory
|
||||
: this.rootDirectory);
|
||||
} catch (error) {
|
||||
if (error instanceof WorkerRemoteOfferFileJournalError) throw error;
|
||||
throw new WorkerRemoteOfferFileJournalError('unsafe_storage');
|
||||
} finally {
|
||||
bytes.fill(0);
|
||||
await rm(temporary, { force: true }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
// Remote Execution transport owns starting, running, and start-failure acknowledgements.
|
||||
import {
|
||||
assertAcknowledgeRemoteRunRunningCommand,
|
||||
assertAcknowledgeRemoteRunStartingCommand,
|
||||
assertFailRemoteRunStartCommand,
|
||||
type AcknowledgeRemoteRunRunningCommand,
|
||||
type AcknowledgeRemoteRunStartingCommand,
|
||||
type FailRemoteRunStartCommand,
|
||||
type RemoteRunActivationResult,
|
||||
} from '@qinglong/runtime-core/remote-activation';
|
||||
import {
|
||||
MAX_REMOTE_RUN_ACTIVATION_RESPONSE_BYTES,
|
||||
parseRemoteRunActivationResponse,
|
||||
} from '@qinglong/runtime-core/remote-activation-delivery';
|
||||
import type { WorkerRemoteExecutionActivationClient } from '../executionInboxProcessor';
|
||||
import {
|
||||
WorkerIngressHttpsClient,
|
||||
WorkerIngressHttpsClientError,
|
||||
} from './workerIngressHttpsClient';
|
||||
|
||||
type ActivationCommand =
|
||||
| AcknowledgeRemoteRunStartingCommand
|
||||
| AcknowledgeRemoteRunRunningCommand
|
||||
| FailRemoteRunStartCommand;
|
||||
|
||||
export interface WorkerRemoteExecutionHttpsActivationClientOptions {
|
||||
readonly client: WorkerIngressHttpsClient;
|
||||
}
|
||||
|
||||
export class WorkerRemoteExecutionHttpsActivationError extends Error {
|
||||
constructor(
|
||||
readonly reason:
|
||||
| 'invalid_configuration'
|
||||
| 'request_invalid'
|
||||
| 'transport_unavailable'
|
||||
| 'response_invalid',
|
||||
options?: ErrorOptions,
|
||||
) {
|
||||
super(`Worker remote execution activation failed: ${reason}`, options);
|
||||
this.name = 'WorkerRemoteExecutionHttpsActivationError';
|
||||
}
|
||||
}
|
||||
|
||||
function path(command: ActivationCommand, operation: string): string {
|
||||
return '/api/v3/worker-ingress/workers/' + command.workerId +
|
||||
'/sessions/' + command.workerSessionId + '/' + operation;
|
||||
}
|
||||
|
||||
function fenceBody(command: ActivationCommand): Readonly<{
|
||||
runId: string;
|
||||
attemptId: string;
|
||||
workerGeneration: number;
|
||||
offerId: string;
|
||||
leaseGeneration: number;
|
||||
leaseToken: string;
|
||||
expectedLeaseVersion: number;
|
||||
}> {
|
||||
return Object.freeze({
|
||||
runId: command.runId,
|
||||
attemptId: command.attemptId,
|
||||
workerGeneration: command.workerGeneration,
|
||||
offerId: command.offerId,
|
||||
leaseGeneration: command.leaseGeneration,
|
||||
leaseToken: command.leaseToken,
|
||||
expectedLeaseVersion: command.expectedLeaseVersion,
|
||||
});
|
||||
}
|
||||
|
||||
export class WorkerRemoteExecutionHttpsActivationClient
|
||||
implements WorkerRemoteExecutionActivationClient {
|
||||
private readonly client: WorkerIngressHttpsClient;
|
||||
|
||||
constructor(options: WorkerRemoteExecutionHttpsActivationClientOptions) {
|
||||
if (!options || !(options.client instanceof WorkerIngressHttpsClient)) {
|
||||
throw new WorkerRemoteExecutionHttpsActivationError(
|
||||
'invalid_configuration',
|
||||
);
|
||||
}
|
||||
this.client = options.client;
|
||||
}
|
||||
|
||||
async acknowledgeStarting(
|
||||
command: AcknowledgeRemoteRunStartingCommand,
|
||||
): Promise<Readonly<RemoteRunActivationResult>> {
|
||||
try {
|
||||
assertAcknowledgeRemoteRunStartingCommand(command);
|
||||
} catch (error) {
|
||||
throw new WorkerRemoteExecutionHttpsActivationError(
|
||||
'request_invalid',
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
return this.exchange('starting', command, fenceBody(command));
|
||||
}
|
||||
|
||||
async acknowledgeRunning(
|
||||
command: AcknowledgeRemoteRunRunningCommand,
|
||||
): Promise<Readonly<RemoteRunActivationResult>> {
|
||||
try {
|
||||
assertAcknowledgeRemoteRunRunningCommand(command);
|
||||
} catch (error) {
|
||||
throw new WorkerRemoteExecutionHttpsActivationError(
|
||||
'request_invalid',
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
return this.exchange('running', command, Object.freeze({
|
||||
...fenceBody(command),
|
||||
executorHandle: command.executorHandle,
|
||||
logArtifactId: command.logArtifactId ?? null,
|
||||
callbackSequence: command.callbackSequence,
|
||||
callbackTokenDigest: command.callbackTokenDigest,
|
||||
}));
|
||||
}
|
||||
|
||||
async failStart(
|
||||
command: FailRemoteRunStartCommand,
|
||||
): Promise<Readonly<RemoteRunActivationResult>> {
|
||||
try {
|
||||
assertFailRemoteRunStartCommand(command);
|
||||
} catch (error) {
|
||||
throw new WorkerRemoteExecutionHttpsActivationError(
|
||||
'request_invalid',
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
return this.exchange('start-failure', command, fenceBody(command));
|
||||
}
|
||||
|
||||
private async exchange(
|
||||
operation: 'starting' | 'running' | 'start-failure',
|
||||
command: ActivationCommand,
|
||||
body: unknown,
|
||||
): Promise<Readonly<RemoteRunActivationResult>> {
|
||||
let serialized: Uint8Array;
|
||||
try {
|
||||
serialized = await this.client.postJson({
|
||||
path: path(command, operation),
|
||||
body,
|
||||
maximumResponseBytes: MAX_REMOTE_RUN_ACTIVATION_RESPONSE_BYTES,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof WorkerIngressHttpsClientError) {
|
||||
throw new WorkerRemoteExecutionHttpsActivationError(
|
||||
'transport_unavailable',
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
const result = parseRemoteRunActivationResponse(serialized);
|
||||
if (
|
||||
result.snapshot.runId !== command.runId ||
|
||||
result.snapshot.attemptId !== command.attemptId ||
|
||||
result.snapshot.leaseGeneration !== command.leaseGeneration
|
||||
) {
|
||||
throw new TypeError('activation response authority mismatch');
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
throw new WorkerRemoteExecutionHttpsActivationError(
|
||||
'response_invalid',
|
||||
{ cause: error },
|
||||
);
|
||||
} finally {
|
||||
Buffer.from(serialized.buffer, serialized.byteOffset, serialized.byteLength)
|
||||
.fill(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
// Remote Execution transport owns bounded offer exchange over the shared mTLS client.
|
||||
import type { Agent } from 'node:https';
|
||||
import type { WorkerRemoteOfferTransport } from '../remoteOfferDelivery';
|
||||
import {
|
||||
WorkerIngressHttpsClient,
|
||||
WorkerIngressHttpsClientError,
|
||||
type WorkerIngressHttpsCredentialProvider,
|
||||
type WorkerIngressHttpsCredentials,
|
||||
type WorkerIngressHttpsRequestFactory,
|
||||
} from './workerIngressHttpsClient';
|
||||
|
||||
const OFFER_PATH =
|
||||
/^\/api\/v3\/worker-ingress\/workers\/[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\/sessions\/[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\/offers$/;
|
||||
|
||||
export type WorkerRemoteOfferHttpsCredentials = WorkerIngressHttpsCredentials;
|
||||
export type WorkerRemoteOfferHttpsCredentialProvider =
|
||||
WorkerIngressHttpsCredentialProvider;
|
||||
export type WorkerRemoteOfferHttpsRequestFactory =
|
||||
WorkerIngressHttpsRequestFactory;
|
||||
|
||||
export interface WorkerRemoteOfferHttpsTransportOptions {
|
||||
readonly client?: WorkerIngressHttpsClient;
|
||||
readonly origin?: string | URL;
|
||||
readonly credentials?: WorkerRemoteOfferHttpsCredentialProvider;
|
||||
readonly requestTimeoutMs?: number;
|
||||
readonly agent?: Agent;
|
||||
/** Injectable only for deterministic transport contract tests. */
|
||||
readonly requestFactory?: WorkerRemoteOfferHttpsRequestFactory;
|
||||
}
|
||||
|
||||
export class WorkerRemoteOfferHttpsTransportError extends Error {
|
||||
constructor(
|
||||
readonly reason:
|
||||
| 'invalid_configuration'
|
||||
| 'credentials_unavailable'
|
||||
| 'request_rejected'
|
||||
| 'response_rejected'
|
||||
| 'response_too_large'
|
||||
| 'closed',
|
||||
) {
|
||||
super(`Worker remote offer HTTPS transport failed: ${reason}`);
|
||||
this.name = 'WorkerRemoteOfferHttpsTransportError';
|
||||
}
|
||||
}
|
||||
|
||||
export class WorkerRemoteOfferHttpsTransport
|
||||
implements WorkerRemoteOfferTransport {
|
||||
private readonly client: WorkerIngressHttpsClient;
|
||||
private readonly ownsClient: boolean;
|
||||
private closed = false;
|
||||
|
||||
constructor(options: WorkerRemoteOfferHttpsTransportOptions) {
|
||||
if (!options) {
|
||||
throw new WorkerRemoteOfferHttpsTransportError('invalid_configuration');
|
||||
}
|
||||
if (options.client) {
|
||||
if (
|
||||
options.origin !== undefined ||
|
||||
options.credentials !== undefined ||
|
||||
options.requestTimeoutMs !== undefined ||
|
||||
options.agent !== undefined ||
|
||||
options.requestFactory !== undefined
|
||||
) {
|
||||
throw new WorkerRemoteOfferHttpsTransportError('invalid_configuration');
|
||||
}
|
||||
this.client = options.client;
|
||||
this.ownsClient = false;
|
||||
return;
|
||||
}
|
||||
if (options.origin === undefined || options.credentials === undefined) {
|
||||
throw new WorkerRemoteOfferHttpsTransportError('invalid_configuration');
|
||||
}
|
||||
try {
|
||||
this.client = new WorkerIngressHttpsClient({
|
||||
origin: options.origin,
|
||||
credentials: options.credentials,
|
||||
...(options.requestTimeoutMs === undefined
|
||||
? {}
|
||||
: { requestTimeoutMs: options.requestTimeoutMs }),
|
||||
...(options.agent === undefined ? {} : { agent: options.agent }),
|
||||
...(options.requestFactory === undefined
|
||||
? {}
|
||||
: { requestFactory: options.requestFactory }),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof WorkerIngressHttpsClientError) {
|
||||
throw new WorkerRemoteOfferHttpsTransportError(error.reason);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
this.ownsClient = true;
|
||||
}
|
||||
|
||||
async exchange(request: Readonly<{
|
||||
path: string;
|
||||
body: Readonly<{
|
||||
workerGeneration: number;
|
||||
offerId: string;
|
||||
leaseToken: string;
|
||||
}>;
|
||||
maximumResponseBytes: number;
|
||||
signal?: AbortSignal;
|
||||
}>): Promise<Uint8Array> {
|
||||
if (this.closed) {
|
||||
throw new WorkerRemoteOfferHttpsTransportError('closed');
|
||||
}
|
||||
if (!request || typeof request.path !== 'string' || !OFFER_PATH.test(request.path)) {
|
||||
throw new WorkerRemoteOfferHttpsTransportError('request_rejected');
|
||||
}
|
||||
try {
|
||||
return await this.client.postJson(request);
|
||||
} catch (error) {
|
||||
if (error instanceof WorkerIngressHttpsClientError) {
|
||||
throw new WorkerRemoteOfferHttpsTransportError(error.reason);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
if (this.ownsClient) this.client.close();
|
||||
}
|
||||
}
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
// Remote Execution transport owns capability-bound Secret delivery.
|
||||
import {
|
||||
MAX_REMOTE_SECRET_DELIVERY_REQUEST_BYTES,
|
||||
MAX_REMOTE_SECRET_DELIVERY_RESPONSE_BYTES,
|
||||
createRemoteWorkerSecretDeliveryRequestBody,
|
||||
parseRemoteWorkerSecretDeliveryResponse,
|
||||
} from '@qinglong/runtime-core/remote-secret-delivery';
|
||||
import { createClusterRemoteExecutionOffer } from '@qinglong/runtime-core/remote-dispatch';
|
||||
import type { WorkerRemoteExecutionInbox } from '../executionInbox';
|
||||
import type {
|
||||
WorkerRemoteSecretEnvironmentProvider,
|
||||
WorkerRemoteSecretResolution,
|
||||
} from '../executionContextMaterializer';
|
||||
import {
|
||||
WorkerIngressHttpsClient,
|
||||
type WorkerIngressHttpsPostRequest,
|
||||
} from './workerIngressHttpsClient';
|
||||
|
||||
export class WorkerRemoteSecretHttpsProviderError extends Error {
|
||||
constructor(
|
||||
readonly reason:
|
||||
| 'invalid_configuration'
|
||||
| 'offer_unavailable'
|
||||
| 'authority_mismatch'
|
||||
| 'delivery_unavailable'
|
||||
| 'response_invalid',
|
||||
) {
|
||||
super(`Worker remote Secret HTTPS provider failed: ${reason}`);
|
||||
this.name = 'WorkerRemoteSecretHttpsProviderError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface WorkerRemoteSecretHttpsProviderOptions {
|
||||
readonly client: Pick<WorkerIngressHttpsClient, 'postJson'>;
|
||||
readonly inbox: Pick<WorkerRemoteExecutionInbox, 'readOffer'>;
|
||||
}
|
||||
|
||||
export class WorkerRemoteSecretHttpsProvider
|
||||
implements WorkerRemoteSecretEnvironmentProvider {
|
||||
private readonly client: Pick<WorkerIngressHttpsClient, 'postJson'>;
|
||||
private readonly inbox: Pick<WorkerRemoteExecutionInbox, 'readOffer'>;
|
||||
|
||||
constructor(options: WorkerRemoteSecretHttpsProviderOptions) {
|
||||
if (
|
||||
!options ||
|
||||
typeof options.client?.postJson !== 'function' ||
|
||||
typeof options.inbox?.readOffer !== 'function'
|
||||
) throw new WorkerRemoteSecretHttpsProviderError('invalid_configuration');
|
||||
this.client = options.client;
|
||||
this.inbox = options.inbox;
|
||||
}
|
||||
|
||||
async resolve(request: Parameters<WorkerRemoteSecretEnvironmentProvider['resolve']>[0])
|
||||
: Promise<WorkerRemoteSecretResolution | undefined> {
|
||||
let record;
|
||||
try {
|
||||
record = await this.inbox.readOffer(request.offerId);
|
||||
} catch {
|
||||
throw new WorkerRemoteSecretHttpsProviderError('offer_unavailable');
|
||||
}
|
||||
if (!record || record.state !== 'starting_acknowledged') {
|
||||
throw new WorkerRemoteSecretHttpsProviderError('offer_unavailable');
|
||||
}
|
||||
let offer;
|
||||
try {
|
||||
offer = createClusterRemoteExecutionOffer(record.offer);
|
||||
} catch {
|
||||
throw new WorkerRemoteSecretHttpsProviderError('authority_mismatch');
|
||||
}
|
||||
const expectedRefs = Object.freeze([
|
||||
...new Set(offer.executionRevision.environment.flatMap((binding) =>
|
||||
binding.kind === 'secret' ? [binding.secretRef] : [])),
|
||||
]);
|
||||
if (
|
||||
offer.offerId !== request.offerId ||
|
||||
offer.executionDigest !== request.executionDigest ||
|
||||
offer.candidate.projectId !== request.projectId ||
|
||||
offer.candidate.taskId !== request.taskId ||
|
||||
offer.candidate.taskRevision !== request.taskRevision ||
|
||||
offer.candidate.runId !== request.runId ||
|
||||
offer.candidate.attemptId !== request.attemptId ||
|
||||
JSON.stringify(expectedRefs) !== JSON.stringify(request.secretRefs)
|
||||
) throw new WorkerRemoteSecretHttpsProviderError('authority_mismatch');
|
||||
|
||||
const path = `/api/v3/worker-ingress/workers/${offer.worker.workerId}` +
|
||||
`/sessions/${offer.worker.sessionId}/secrets`;
|
||||
const body = createRemoteWorkerSecretDeliveryRequestBody({
|
||||
workerId: offer.worker.workerId,
|
||||
workerSessionId: offer.worker.sessionId,
|
||||
workerGeneration: offer.worker.generation,
|
||||
runId: offer.candidate.runId,
|
||||
attemptId: offer.candidate.attemptId,
|
||||
projectId: offer.candidate.projectId,
|
||||
taskId: offer.candidate.taskId,
|
||||
taskRevision: offer.candidate.taskRevision,
|
||||
executionDigest: offer.executionDigest,
|
||||
offerId: offer.offerId,
|
||||
leaseGeneration: offer.lease.leaseGeneration,
|
||||
leaseToken: offer.leaseToken,
|
||||
expectedLeaseVersion: offer.lease.version,
|
||||
secretRefs: expectedRefs,
|
||||
});
|
||||
let serialized: Uint8Array;
|
||||
try {
|
||||
const transportRequest: WorkerIngressHttpsPostRequest = {
|
||||
path,
|
||||
body,
|
||||
maximumRequestBytes: MAX_REMOTE_SECRET_DELIVERY_REQUEST_BYTES,
|
||||
maximumResponseBytes: MAX_REMOTE_SECRET_DELIVERY_RESPONSE_BYTES,
|
||||
};
|
||||
serialized = await this.client.postJson(transportRequest);
|
||||
} catch {
|
||||
throw new WorkerRemoteSecretHttpsProviderError('delivery_unavailable');
|
||||
}
|
||||
try {
|
||||
const delivered = parseRemoteWorkerSecretDeliveryResponse(serialized, {
|
||||
runId: offer.candidate.runId,
|
||||
attemptId: offer.candidate.attemptId,
|
||||
offerId: offer.offerId,
|
||||
executionDigest: offer.executionDigest,
|
||||
secretRefs: expectedRefs,
|
||||
});
|
||||
const values = Object.freeze(delivered.values.map((entry) =>
|
||||
Object.freeze({ secretRef: entry.secretRef, value: entry.value })));
|
||||
return Object.freeze({
|
||||
values,
|
||||
dispose() {
|
||||
// JavaScript strings cannot be zeroized. Drop all retained references;
|
||||
// the transport bytes were already scrubbed by the parser.
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
throw new WorkerRemoteSecretHttpsProviderError('response_invalid');
|
||||
} finally {
|
||||
if (Buffer.isBuffer(serialized)) serialized.fill(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
+214
@@ -0,0 +1,214 @@
|
||||
// Remote Execution transport owns Artifact upload and completion acknowledgement.
|
||||
import {
|
||||
MAX_REMOTE_WORKER_ARTIFACT_RESPONSE_BYTES,
|
||||
MAX_REMOTE_WORKER_COMPLETION_REQUEST_BYTES,
|
||||
MAX_REMOTE_WORKER_COMPLETION_RESPONSE_BYTES,
|
||||
createRemoteWorkerArtifactUploadPreamble,
|
||||
createRemoteWorkerCompletionRequestBody,
|
||||
parseRemoteWorkerArtifactUploadResponse,
|
||||
parseRemoteWorkerCompletionResponse,
|
||||
type RemoteWorkerCompletionCommand,
|
||||
} from '@qinglong/runtime-core/remote-worker-completion';
|
||||
import type {
|
||||
WorkerRemoteExecutionCompletionClient,
|
||||
WorkerRemoteExecutionCompletionCommand,
|
||||
WorkerRemoteExecutionCompletionResult,
|
||||
WorkerRemoteLogArtifactUploadCommand,
|
||||
WorkerRemoteLogArtifactUploadResult,
|
||||
WorkerRemoteLogArtifactUploader,
|
||||
} from '../../execution/workerCompletionCoordinator';
|
||||
import type { WorkerIngressHttpsClient } from './workerIngressHttpsClient';
|
||||
|
||||
export class WorkerRemoteCompletionHttpsError extends Error {
|
||||
constructor(
|
||||
readonly reason:
|
||||
| 'invalid_configuration'
|
||||
| 'request_invalid'
|
||||
| 'transport_unavailable'
|
||||
| 'response_invalid',
|
||||
options?: ErrorOptions,
|
||||
) {
|
||||
super(`Worker remote completion HTTPS failed: ${reason}`, options);
|
||||
this.name = 'WorkerRemoteCompletionHttpsError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface WorkerRemoteArtifactHttpsUploaderOptions {
|
||||
readonly client: Pick<WorkerIngressHttpsClient, 'postStream'>;
|
||||
}
|
||||
|
||||
export interface WorkerRemoteExecutionHttpsCompletionClientOptions {
|
||||
readonly client: Pick<WorkerIngressHttpsClient, 'postJson'>;
|
||||
}
|
||||
|
||||
function path(
|
||||
command: Readonly<{ workerId: string; workerSessionId: string }>,
|
||||
operation: 'artifacts' | 'completion',
|
||||
): string {
|
||||
return `/api/v3/worker-ingress/workers/${command.workerId}` +
|
||||
`/sessions/${command.workerSessionId}/${operation}`;
|
||||
}
|
||||
|
||||
function erase(value: Uint8Array | undefined): void {
|
||||
if (!value) return;
|
||||
Buffer.from(value.buffer, value.byteOffset, value.byteLength).fill(0);
|
||||
}
|
||||
|
||||
export class WorkerRemoteArtifactHttpsUploader
|
||||
implements WorkerRemoteLogArtifactUploader {
|
||||
private readonly client: Pick<WorkerIngressHttpsClient, 'postStream'>;
|
||||
|
||||
constructor(options: WorkerRemoteArtifactHttpsUploaderOptions) {
|
||||
if (!options || typeof options.client?.postStream !== 'function') {
|
||||
throw new WorkerRemoteCompletionHttpsError('invalid_configuration');
|
||||
}
|
||||
this.client = options.client;
|
||||
}
|
||||
|
||||
async upload(
|
||||
command: WorkerRemoteLogArtifactUploadCommand,
|
||||
): Promise<Readonly<WorkerRemoteLogArtifactUploadResult>> {
|
||||
let preamble: Buffer;
|
||||
try {
|
||||
preamble = createRemoteWorkerArtifactUploadPreamble({
|
||||
workerId: command.workerId,
|
||||
workerSessionId: command.workerSessionId,
|
||||
workerGeneration: command.workerGeneration,
|
||||
projectId: command.projectId,
|
||||
runId: command.runId,
|
||||
attemptId: command.attemptId,
|
||||
offerId: command.offerId,
|
||||
leaseGeneration: command.leaseGeneration,
|
||||
leaseToken: command.leaseToken,
|
||||
expectedLeaseVersion: command.expectedLeaseVersion,
|
||||
logArtifactId: command.logArtifactId,
|
||||
byteLength: command.byteLength,
|
||||
...(command.truncated === undefined
|
||||
? {}
|
||||
: { truncated: command.truncated }),
|
||||
});
|
||||
} catch (error) {
|
||||
throw new WorkerRemoteCompletionHttpsError('request_invalid', {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
let serialized: Uint8Array | undefined;
|
||||
try {
|
||||
const content = command.content;
|
||||
serialized = await this.client.postStream({
|
||||
path: path(command, 'artifacts'),
|
||||
body: (async function* () {
|
||||
yield preamble;
|
||||
for await (const chunk of content) yield chunk;
|
||||
})(),
|
||||
byteLength: preamble.byteLength + command.byteLength,
|
||||
maximumResponseBytes: MAX_REMOTE_WORKER_ARTIFACT_RESPONSE_BYTES,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new WorkerRemoteCompletionHttpsError('transport_unavailable', {
|
||||
cause: error,
|
||||
});
|
||||
} finally {
|
||||
preamble.fill(0);
|
||||
}
|
||||
try {
|
||||
const receipt = parseRemoteWorkerArtifactUploadResponse(serialized);
|
||||
if (
|
||||
receipt.projectId !== command.projectId ||
|
||||
receipt.runId !== command.runId ||
|
||||
receipt.attemptId !== command.attemptId ||
|
||||
receipt.logArtifactId !== command.logArtifactId ||
|
||||
receipt.byteLength !== command.byteLength ||
|
||||
receipt.truncated !== command.truncated
|
||||
) {
|
||||
throw new TypeError('Artifact response authority does not match');
|
||||
}
|
||||
return Object.freeze({
|
||||
status: receipt.status,
|
||||
logArtifactId: receipt.logArtifactId,
|
||||
byteLength: receipt.byteLength,
|
||||
sha256: receipt.sha256,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new WorkerRemoteCompletionHttpsError('response_invalid', {
|
||||
cause: error,
|
||||
});
|
||||
} finally {
|
||||
erase(serialized);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class WorkerRemoteExecutionHttpsCompletionClient
|
||||
implements WorkerRemoteExecutionCompletionClient {
|
||||
private readonly client: Pick<WorkerIngressHttpsClient, 'postJson'>;
|
||||
|
||||
constructor(options: WorkerRemoteExecutionHttpsCompletionClientOptions) {
|
||||
if (!options || typeof options.client?.postJson !== 'function') {
|
||||
throw new WorkerRemoteCompletionHttpsError('invalid_configuration');
|
||||
}
|
||||
this.client = options.client;
|
||||
}
|
||||
|
||||
async complete(
|
||||
command: WorkerRemoteExecutionCompletionCommand,
|
||||
): Promise<Readonly<WorkerRemoteExecutionCompletionResult>> {
|
||||
let body;
|
||||
try {
|
||||
if (command.executorType !== 'remote_worker') {
|
||||
throw new TypeError('completion executor type is invalid');
|
||||
}
|
||||
const wire: RemoteWorkerCompletionCommand = {
|
||||
workerId: command.workerId,
|
||||
workerSessionId: command.workerSessionId,
|
||||
workerGeneration: command.workerGeneration,
|
||||
projectId: command.projectId,
|
||||
runId: command.runId,
|
||||
attemptId: command.attemptId,
|
||||
offerId: command.offerId,
|
||||
leaseGeneration: command.leaseGeneration,
|
||||
leaseToken: command.leaseToken,
|
||||
expectedLeaseVersion: command.expectedLeaseVersion,
|
||||
callbackSequence: command.callbackSequence,
|
||||
callbackTokenDigest: command.callbackTokenDigest,
|
||||
result: command.result,
|
||||
artifact: command.artifact,
|
||||
};
|
||||
body = createRemoteWorkerCompletionRequestBody(wire);
|
||||
} catch (error) {
|
||||
throw new WorkerRemoteCompletionHttpsError('request_invalid', {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
let serialized: Uint8Array | undefined;
|
||||
try {
|
||||
serialized = await this.client.postJson({
|
||||
path: path(command, 'completion'),
|
||||
body,
|
||||
maximumRequestBytes: MAX_REMOTE_WORKER_COMPLETION_REQUEST_BYTES,
|
||||
maximumResponseBytes: MAX_REMOTE_WORKER_COMPLETION_RESPONSE_BYTES,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new WorkerRemoteCompletionHttpsError('transport_unavailable', {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
try {
|
||||
const completed = parseRemoteWorkerCompletionResponse(serialized);
|
||||
if (
|
||||
completed.runId !== command.runId ||
|
||||
completed.attemptId !== command.attemptId ||
|
||||
completed.callbackSequence !== command.callbackSequence
|
||||
) {
|
||||
throw new TypeError('completion response authority does not match');
|
||||
}
|
||||
return completed;
|
||||
} catch (error) {
|
||||
throw new WorkerRemoteCompletionHttpsError('response_invalid', {
|
||||
cause: error,
|
||||
});
|
||||
} finally {
|
||||
erase(serialized);
|
||||
}
|
||||
}
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
// Remote Execution transport owns fenced lease-control exchange.
|
||||
import {
|
||||
MAX_REMOTE_WORKER_LEASE_CONTROL_REQUEST_BYTES,
|
||||
MAX_REMOTE_WORKER_LEASE_CONTROL_RESPONSE_BYTES,
|
||||
createRemoteWorkerLeaseControlRequestBody,
|
||||
parseRemoteWorkerLeaseControlResponse,
|
||||
type RemoteWorkerLeaseControlCommand,
|
||||
type RemoteWorkerLeaseControlResult,
|
||||
} from '@qinglong/runtime-core/remote-worker-lease-control';
|
||||
import {
|
||||
WorkerIngressHttpsClient,
|
||||
WorkerIngressHttpsClientError,
|
||||
} from './workerIngressHttpsClient';
|
||||
|
||||
export interface WorkerRemoteLeaseControlClient {
|
||||
control(
|
||||
command: RemoteWorkerLeaseControlCommand,
|
||||
): Promise<Readonly<RemoteWorkerLeaseControlResult>>;
|
||||
}
|
||||
|
||||
export interface WorkerRemoteLeaseControlHttpsClientOptions {
|
||||
readonly client: WorkerIngressHttpsClient;
|
||||
}
|
||||
|
||||
export class WorkerRemoteLeaseControlHttpsError extends Error {
|
||||
constructor(
|
||||
readonly reason:
|
||||
| 'invalid_configuration'
|
||||
| 'request_invalid'
|
||||
| 'transport_unavailable'
|
||||
| 'response_invalid',
|
||||
options?: ErrorOptions,
|
||||
) {
|
||||
super(`Worker remote lease control failed: ${reason}`, options);
|
||||
this.name = 'WorkerRemoteLeaseControlHttpsError';
|
||||
}
|
||||
}
|
||||
|
||||
function path(command: RemoteWorkerLeaseControlCommand): string {
|
||||
return '/api/v3/worker-ingress/workers/' + command.workerId +
|
||||
'/sessions/' + command.workerSessionId + '/lease-control';
|
||||
}
|
||||
|
||||
export class WorkerRemoteLeaseControlHttpsClient
|
||||
implements WorkerRemoteLeaseControlClient {
|
||||
private readonly client: WorkerIngressHttpsClient;
|
||||
|
||||
constructor(options: WorkerRemoteLeaseControlHttpsClientOptions) {
|
||||
if (!options || !(options.client instanceof WorkerIngressHttpsClient)) {
|
||||
throw new WorkerRemoteLeaseControlHttpsError('invalid_configuration');
|
||||
}
|
||||
this.client = options.client;
|
||||
}
|
||||
|
||||
async control(
|
||||
command: RemoteWorkerLeaseControlCommand,
|
||||
): Promise<Readonly<RemoteWorkerLeaseControlResult>> {
|
||||
let body;
|
||||
try {
|
||||
body = createRemoteWorkerLeaseControlRequestBody(command);
|
||||
} catch (error) {
|
||||
throw new WorkerRemoteLeaseControlHttpsError(
|
||||
'request_invalid', { cause: error },
|
||||
);
|
||||
}
|
||||
let serialized: Uint8Array;
|
||||
try {
|
||||
serialized = await this.client.postJson({
|
||||
path: path(command),
|
||||
body,
|
||||
maximumRequestBytes: MAX_REMOTE_WORKER_LEASE_CONTROL_REQUEST_BYTES,
|
||||
maximumResponseBytes: MAX_REMOTE_WORKER_LEASE_CONTROL_RESPONSE_BYTES,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof WorkerIngressHttpsClientError) {
|
||||
throw new WorkerRemoteLeaseControlHttpsError(
|
||||
'transport_unavailable', { cause: error },
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
const result = parseRemoteWorkerLeaseControlResponse(serialized);
|
||||
if (
|
||||
result.projectId !== command.projectId ||
|
||||
result.runId !== command.runId ||
|
||||
result.attemptId !== command.attemptId ||
|
||||
result.offerId !== command.offerId ||
|
||||
result.leaseGeneration !== command.leaseGeneration ||
|
||||
(result.status !== 'terminal' &&
|
||||
result.leaseVersion !== command.expectedLeaseVersion + 1)
|
||||
) throw new TypeError('lease control response authority mismatch');
|
||||
return result;
|
||||
} catch (error) {
|
||||
throw new WorkerRemoteLeaseControlHttpsError(
|
||||
'response_invalid', { cause: error },
|
||||
);
|
||||
} finally {
|
||||
Buffer.from(serialized.buffer, serialized.byteOffset, serialized.byteLength)
|
||||
.fill(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
+641
@@ -0,0 +1,641 @@
|
||||
// Remote Execution transport owns the shared bounded TLS 1.3 Worker Ingress client.
|
||||
import { Agent, request as nodeHttpsRequest } from 'node:https';
|
||||
import type { RequestOptions } from 'node:https';
|
||||
import type { ClientRequest, IncomingMessage } from 'node:http';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { once } from 'node:events';
|
||||
import { isIP } from 'node:net';
|
||||
import { REMOTE_WORKER_ARTIFACT_CONTENT_TYPE } from '@qinglong/runtime-core/remote-worker-completion';
|
||||
|
||||
const AUTHORIZATION =
|
||||
/^Worker ql3w_([A-Za-z0-9][A-Za-z0-9._:-]{0,63})_([A-Za-z0-9_-]{43})$/;
|
||||
const WORKER_INGRESS_JSON_PATH =
|
||||
/^\/api\/v3\/worker-ingress\/workers\/[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\/sessions\/[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\/(register|heartbeat|transition|offers|starting|running|start-failure|secrets|completion|lease-control)$/;
|
||||
const WORKER_INGRESS_ARTIFACT_PATH =
|
||||
/^\/api\/v3\/worker-ingress\/workers\/[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\/sessions\/[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\/artifacts$/;
|
||||
const MAX_TLS_MATERIAL_BYTES = 1024 * 1024;
|
||||
const MAX_REQUEST_BYTES = 4096;
|
||||
const HARD_MAX_REQUEST_BYTES = 64 * 1024;
|
||||
const HARD_MAX_STREAM_REQUEST_BYTES = 64 * 1024 * 1024 + 4 * 1024 + 4;
|
||||
const MAX_RESPONSE_BYTES = 128 * 1024;
|
||||
const CREDENTIAL_POOL_KEY = Symbol('qinglong.worker-ingress-credential-pool-key');
|
||||
|
||||
export const WORKER_INGRESS_ARTIFACT_CONTENT_TYPE =
|
||||
REMOTE_WORKER_ARTIFACT_CONTENT_TYPE;
|
||||
|
||||
export interface WorkerIngressHttpsCredentials {
|
||||
readonly authorization: string;
|
||||
readonly certificateChainPem: string | Buffer;
|
||||
readonly privateKeyPem: string | Buffer;
|
||||
readonly trustAnchors: readonly (string | Buffer)[];
|
||||
/** Erases provider-owned transient material after the client copies it. */
|
||||
readonly dispose?: () => void;
|
||||
}
|
||||
|
||||
export interface WorkerIngressHttpsCredentialProvider {
|
||||
load(signal?: AbortSignal): Promise<WorkerIngressHttpsCredentials>;
|
||||
}
|
||||
|
||||
export type WorkerIngressHttpsRequestFactory = (
|
||||
options: RequestOptions,
|
||||
callback: (response: IncomingMessage) => void,
|
||||
) => ClientRequest;
|
||||
|
||||
export interface WorkerIngressHttpsClientOptions {
|
||||
readonly origin: string | URL;
|
||||
readonly credentials: WorkerIngressHttpsCredentialProvider;
|
||||
readonly requestTimeoutMs?: number;
|
||||
readonly agent?: Agent;
|
||||
/** Injectable only for deterministic transport contract tests. */
|
||||
readonly requestFactory?: WorkerIngressHttpsRequestFactory;
|
||||
}
|
||||
|
||||
export interface WorkerIngressHttpsPostRequest {
|
||||
readonly path: string;
|
||||
readonly body: unknown;
|
||||
readonly maximumResponseBytes: number;
|
||||
/** Defaults to 4 KiB. Larger budgets are opt-in and capped at 64 KiB. */
|
||||
readonly maximumRequestBytes?: number;
|
||||
readonly signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface WorkerIngressHttpsStreamRequest {
|
||||
readonly path: string;
|
||||
readonly body: AsyncIterable<Uint8Array>;
|
||||
readonly byteLength: number;
|
||||
readonly maximumResponseBytes: number;
|
||||
readonly signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export class WorkerIngressHttpsClientError extends Error {
|
||||
constructor(
|
||||
readonly reason:
|
||||
| 'invalid_configuration'
|
||||
| 'credentials_unavailable'
|
||||
| 'request_rejected'
|
||||
| 'response_rejected'
|
||||
| 'response_too_large'
|
||||
| 'closed',
|
||||
readonly httpStatus?: number,
|
||||
) {
|
||||
super(`Worker ingress HTTPS client failed: ${reason}`);
|
||||
this.name = 'WorkerIngressHttpsClientError';
|
||||
}
|
||||
}
|
||||
|
||||
function boundedMaterial(value: string | Buffer): Buffer {
|
||||
const bytes = Buffer.isBuffer(value)
|
||||
? Buffer.from(value)
|
||||
: Buffer.from(value, 'utf8');
|
||||
if (bytes.byteLength < 1 || bytes.byteLength > MAX_TLS_MATERIAL_BYTES) {
|
||||
bytes.fill(0);
|
||||
throw new WorkerIngressHttpsClientError('credentials_unavailable');
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function normalizeOrigin(value: string | URL): URL {
|
||||
let origin: URL;
|
||||
try {
|
||||
origin = new URL(value);
|
||||
} catch {
|
||||
throw new WorkerIngressHttpsClientError('invalid_configuration');
|
||||
}
|
||||
if (
|
||||
origin.protocol !== 'https:' ||
|
||||
origin.username !== '' ||
|
||||
origin.password !== '' ||
|
||||
origin.pathname !== '/' ||
|
||||
origin.search !== '' ||
|
||||
origin.hash !== ''
|
||||
) {
|
||||
throw new WorkerIngressHttpsClientError('invalid_configuration');
|
||||
}
|
||||
return origin;
|
||||
}
|
||||
|
||||
interface WorkerIngressHttpsCredentialMaterial {
|
||||
readonly authorization: string;
|
||||
readonly certificate: Buffer;
|
||||
readonly privateKey: Buffer;
|
||||
readonly trustAnchors: readonly Buffer[];
|
||||
readonly poolKey: string;
|
||||
}
|
||||
|
||||
interface WorkerIngressHttpsAgentRequestOptions extends RequestOptions {
|
||||
readonly [CREDENTIAL_POOL_KEY]?: string;
|
||||
}
|
||||
|
||||
class WorkerIngressHttpsAgent extends Agent {
|
||||
override getName(options: RequestOptions = {}): string {
|
||||
const poolKey = (options as WorkerIngressHttpsAgentRequestOptions)[
|
||||
CREDENTIAL_POOL_KEY
|
||||
];
|
||||
if (poolKey === undefined) return super.getName(options);
|
||||
// Node recomputes the HTTPS pool name when a socket becomes free. The
|
||||
// request-local TLS Buffers have already been erased by then, so their
|
||||
// mutable contents cannot safely participate in that name.
|
||||
return `${super.getName({
|
||||
...options,
|
||||
ca: undefined,
|
||||
cert: undefined,
|
||||
key: undefined,
|
||||
})}:qinglong:${poolKey}`;
|
||||
}
|
||||
}
|
||||
|
||||
function credentialPoolKey(
|
||||
certificate: Buffer,
|
||||
privateKey: Buffer,
|
||||
trustAnchors: readonly Buffer[],
|
||||
): string {
|
||||
const hash = createHash('sha256');
|
||||
for (const value of [certificate, privateKey, ...trustAnchors]) {
|
||||
const length = Buffer.allocUnsafe(4);
|
||||
length.writeUInt32BE(value.byteLength);
|
||||
hash.update(length);
|
||||
hash.update(value);
|
||||
length.fill(0);
|
||||
}
|
||||
return hash.digest('base64url');
|
||||
}
|
||||
|
||||
async function loadCredentialMaterial(
|
||||
provider: WorkerIngressHttpsCredentialProvider,
|
||||
signal?: AbortSignal,
|
||||
): Promise<WorkerIngressHttpsCredentialMaterial> {
|
||||
let loaded: WorkerIngressHttpsCredentials;
|
||||
try {
|
||||
loaded = await provider.load(signal);
|
||||
} catch {
|
||||
if (signal?.aborted) {
|
||||
throw signal.reason ??
|
||||
new WorkerIngressHttpsClientError('request_rejected');
|
||||
}
|
||||
throw new WorkerIngressHttpsClientError('credentials_unavailable');
|
||||
}
|
||||
let certificate: Buffer | undefined;
|
||||
let privateKey: Buffer | undefined;
|
||||
const trustAnchors: Buffer[] = [];
|
||||
let failure: unknown;
|
||||
try {
|
||||
if (
|
||||
!loaded ||
|
||||
!AUTHORIZATION.test(loaded.authorization) ||
|
||||
!Array.isArray(loaded.trustAnchors) ||
|
||||
loaded.trustAnchors.length < 1 ||
|
||||
loaded.trustAnchors.length > 8 ||
|
||||
(loaded.dispose !== undefined && typeof loaded.dispose !== 'function')
|
||||
) {
|
||||
throw new WorkerIngressHttpsClientError('credentials_unavailable');
|
||||
}
|
||||
certificate = boundedMaterial(loaded.certificateChainPem);
|
||||
privateKey = boundedMaterial(loaded.privateKeyPem);
|
||||
for (const anchor of loaded.trustAnchors) {
|
||||
trustAnchors.push(boundedMaterial(anchor));
|
||||
}
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
}
|
||||
try {
|
||||
loaded?.dispose?.();
|
||||
} catch {
|
||||
failure ??= new WorkerIngressHttpsClientError('credentials_unavailable');
|
||||
}
|
||||
if (failure !== undefined || !certificate || !privateKey) {
|
||||
certificate?.fill(0);
|
||||
privateKey?.fill(0);
|
||||
trustAnchors.forEach((value) => value.fill(0));
|
||||
if (failure instanceof WorkerIngressHttpsClientError) throw failure;
|
||||
throw new WorkerIngressHttpsClientError('credentials_unavailable');
|
||||
}
|
||||
return {
|
||||
authorization: loaded.authorization,
|
||||
certificate,
|
||||
privateKey,
|
||||
trustAnchors: Object.freeze(trustAnchors),
|
||||
poolKey: credentialPoolKey(certificate, privateKey, trustAnchors),
|
||||
};
|
||||
}
|
||||
|
||||
function eraseCredentialMaterial(
|
||||
material: WorkerIngressHttpsCredentialMaterial,
|
||||
): void {
|
||||
material.certificate.fill(0);
|
||||
material.privateKey.fill(0);
|
||||
material.trustAnchors.forEach((value) => value.fill(0));
|
||||
}
|
||||
|
||||
export class WorkerIngressHttpsClient {
|
||||
private readonly origin: URL;
|
||||
private readonly credentials: WorkerIngressHttpsCredentialProvider;
|
||||
private readonly requestTimeoutMs: number;
|
||||
private readonly requestFactory: WorkerIngressHttpsRequestFactory;
|
||||
private readonly agent: Agent;
|
||||
private readonly ownsAgent: boolean;
|
||||
private closed = false;
|
||||
|
||||
constructor(options: WorkerIngressHttpsClientOptions) {
|
||||
if (
|
||||
!options ||
|
||||
typeof options.credentials?.load !== 'function' ||
|
||||
(options.requestFactory !== undefined &&
|
||||
typeof options.requestFactory !== 'function')
|
||||
) {
|
||||
throw new WorkerIngressHttpsClientError('invalid_configuration');
|
||||
}
|
||||
const requestTimeoutMs = options.requestTimeoutMs ?? 15_000;
|
||||
if (
|
||||
!Number.isSafeInteger(requestTimeoutMs) ||
|
||||
requestTimeoutMs < 1_000 ||
|
||||
requestTimeoutMs > 60_000
|
||||
) {
|
||||
throw new WorkerIngressHttpsClientError('invalid_configuration');
|
||||
}
|
||||
this.origin = normalizeOrigin(options.origin);
|
||||
this.credentials = options.credentials;
|
||||
this.requestTimeoutMs = requestTimeoutMs;
|
||||
this.requestFactory = options.requestFactory ?? nodeHttpsRequest;
|
||||
this.ownsAgent = options.agent === undefined;
|
||||
this.agent = options.agent ?? new WorkerIngressHttpsAgent({
|
||||
keepAlive: true,
|
||||
maxSockets: 1,
|
||||
maxFreeSockets: 1,
|
||||
timeout: requestTimeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
async postJson(request: WorkerIngressHttpsPostRequest): Promise<Uint8Array> {
|
||||
if (this.closed) throw new WorkerIngressHttpsClientError('closed');
|
||||
if (
|
||||
!request ||
|
||||
typeof request.path !== 'string' ||
|
||||
!WORKER_INGRESS_JSON_PATH.test(request.path) ||
|
||||
!Number.isSafeInteger(request.maximumResponseBytes) ||
|
||||
request.maximumResponseBytes < 2 ||
|
||||
request.maximumResponseBytes > MAX_RESPONSE_BYTES ||
|
||||
(request.maximumRequestBytes !== undefined &&
|
||||
(!Number.isSafeInteger(request.maximumRequestBytes) ||
|
||||
request.maximumRequestBytes < 2 ||
|
||||
request.maximumRequestBytes > HARD_MAX_REQUEST_BYTES))
|
||||
) {
|
||||
throw new WorkerIngressHttpsClientError('request_rejected');
|
||||
}
|
||||
if (request.signal?.aborted) {
|
||||
throw request.signal.reason ??
|
||||
new WorkerIngressHttpsClientError('request_rejected');
|
||||
}
|
||||
const material = await loadCredentialMaterial(
|
||||
this.credentials,
|
||||
request.signal,
|
||||
);
|
||||
let body: Buffer;
|
||||
try {
|
||||
body = Buffer.from(JSON.stringify(request.body), 'utf8');
|
||||
} catch {
|
||||
eraseCredentialMaterial(material);
|
||||
throw new WorkerIngressHttpsClientError('request_rejected');
|
||||
}
|
||||
const maximumRequestBytes = request.maximumRequestBytes ?? MAX_REQUEST_BYTES;
|
||||
if (body.byteLength < 2 || body.byteLength > maximumRequestBytes) {
|
||||
eraseCredentialMaterial(material);
|
||||
body.fill(0);
|
||||
throw new WorkerIngressHttpsClientError('request_rejected');
|
||||
}
|
||||
try {
|
||||
return await this.perform(
|
||||
request.path,
|
||||
body,
|
||||
request.maximumResponseBytes,
|
||||
material.authorization,
|
||||
material.certificate,
|
||||
material.privateKey,
|
||||
material.trustAnchors,
|
||||
material.poolKey,
|
||||
request.signal,
|
||||
);
|
||||
} finally {
|
||||
eraseCredentialMaterial(material);
|
||||
body.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
async postStream(
|
||||
request: WorkerIngressHttpsStreamRequest,
|
||||
): Promise<Uint8Array> {
|
||||
if (this.closed) throw new WorkerIngressHttpsClientError('closed');
|
||||
if (
|
||||
!request ||
|
||||
typeof request.path !== 'string' ||
|
||||
!WORKER_INGRESS_ARTIFACT_PATH.test(request.path) ||
|
||||
!request.body ||
|
||||
typeof request.body[Symbol.asyncIterator] !== 'function' ||
|
||||
!Number.isSafeInteger(request.byteLength) ||
|
||||
request.byteLength < 1 ||
|
||||
request.byteLength > HARD_MAX_STREAM_REQUEST_BYTES ||
|
||||
!Number.isSafeInteger(request.maximumResponseBytes) ||
|
||||
request.maximumResponseBytes < 2 ||
|
||||
request.maximumResponseBytes > MAX_RESPONSE_BYTES
|
||||
) {
|
||||
throw new WorkerIngressHttpsClientError('request_rejected');
|
||||
}
|
||||
if (request.signal?.aborted) {
|
||||
throw request.signal.reason ??
|
||||
new WorkerIngressHttpsClientError('request_rejected');
|
||||
}
|
||||
const material = await loadCredentialMaterial(
|
||||
this.credentials,
|
||||
request.signal,
|
||||
);
|
||||
try {
|
||||
return await this.performStream(
|
||||
request.path,
|
||||
request.body,
|
||||
request.byteLength,
|
||||
request.maximumResponseBytes,
|
||||
material.authorization,
|
||||
material.certificate,
|
||||
material.privateKey,
|
||||
material.trustAnchors,
|
||||
material.poolKey,
|
||||
request.signal,
|
||||
);
|
||||
} finally {
|
||||
eraseCredentialMaterial(material);
|
||||
}
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
if (this.ownsAgent) this.agent.destroy();
|
||||
}
|
||||
|
||||
private perform(
|
||||
path: string,
|
||||
body: Buffer,
|
||||
maximumResponseBytes: number,
|
||||
authorization: string,
|
||||
certificate: Buffer,
|
||||
privateKey: Buffer,
|
||||
trustAnchors: readonly Buffer[],
|
||||
poolKey: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Uint8Array> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const settle = (error?: unknown, bytes?: Buffer): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
signal?.removeEventListener('abort', abort);
|
||||
if (error) reject(error);
|
||||
else resolve(bytes!);
|
||||
};
|
||||
let clientRequest: ClientRequest;
|
||||
const abort = (): void => {
|
||||
clientRequest.destroy(
|
||||
signal?.reason instanceof Error
|
||||
? signal.reason
|
||||
: new WorkerIngressHttpsClientError('request_rejected'),
|
||||
);
|
||||
};
|
||||
try {
|
||||
clientRequest = this.requestFactory({
|
||||
protocol: 'https:',
|
||||
hostname: this.origin.hostname,
|
||||
port: this.origin.port || 443,
|
||||
...(isIP(this.origin.hostname) === 0
|
||||
? { servername: this.origin.hostname }
|
||||
: {}),
|
||||
method: 'POST',
|
||||
path,
|
||||
agent: this.agent,
|
||||
minVersion: 'TLSv1.3',
|
||||
rejectUnauthorized: true,
|
||||
cert: certificate,
|
||||
key: privateKey,
|
||||
ca: [...trustAnchors],
|
||||
[CREDENTIAL_POOL_KEY]: poolKey,
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
authorization,
|
||||
'content-type': 'application/json',
|
||||
'content-length': String(body.byteLength),
|
||||
},
|
||||
} as WorkerIngressHttpsAgentRequestOptions, (response) => {
|
||||
const contentType = response.headers['content-type'];
|
||||
const contentEncoding = response.headers['content-encoding'];
|
||||
const contentLength = response.headers['content-length'];
|
||||
if (
|
||||
response.statusCode !== 200 ||
|
||||
typeof contentType !== 'string' ||
|
||||
!/^application\/json(?:\s*;|$)/i.test(contentType) ||
|
||||
(contentEncoding !== undefined && contentEncoding !== 'identity') ||
|
||||
(contentLength !== undefined &&
|
||||
(!/^\d+$/.test(contentLength) ||
|
||||
Number(contentLength) > maximumResponseBytes))
|
||||
) {
|
||||
response.resume();
|
||||
settle(new WorkerIngressHttpsClientError(
|
||||
'response_rejected', response.statusCode,
|
||||
));
|
||||
return;
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
let total = 0;
|
||||
response.on('data', (chunk: Buffer | string) => {
|
||||
if (settled) return;
|
||||
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
total += bytes.byteLength;
|
||||
if (total > maximumResponseBytes) {
|
||||
chunks.forEach((value) => value.fill(0));
|
||||
response.destroy();
|
||||
settle(new WorkerIngressHttpsClientError('response_too_large'));
|
||||
return;
|
||||
}
|
||||
chunks.push(Buffer.from(bytes));
|
||||
});
|
||||
response.once('end', () => {
|
||||
if (settled) return;
|
||||
const result = Buffer.concat(chunks, total);
|
||||
chunks.forEach((value) => value.fill(0));
|
||||
if (result.byteLength < 2) {
|
||||
result.fill(0);
|
||||
settle(new WorkerIngressHttpsClientError('response_rejected'));
|
||||
return;
|
||||
}
|
||||
settle(undefined, result);
|
||||
});
|
||||
response.once('error', () => {
|
||||
chunks.forEach((value) => value.fill(0));
|
||||
settle(new WorkerIngressHttpsClientError(
|
||||
'response_rejected', response.statusCode,
|
||||
));
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
settle(new WorkerIngressHttpsClientError('request_rejected'));
|
||||
return;
|
||||
}
|
||||
clientRequest.once('error', (error) => {
|
||||
if (signal?.aborted) settle(signal.reason ?? error);
|
||||
else settle(new WorkerIngressHttpsClientError('request_rejected'));
|
||||
});
|
||||
clientRequest.setTimeout(this.requestTimeoutMs, () => {
|
||||
clientRequest.destroy(
|
||||
new WorkerIngressHttpsClientError('request_rejected'),
|
||||
);
|
||||
});
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
clientRequest.end(body);
|
||||
});
|
||||
}
|
||||
|
||||
private performStream(
|
||||
path: string,
|
||||
body: AsyncIterable<Uint8Array>,
|
||||
byteLength: number,
|
||||
maximumResponseBytes: number,
|
||||
authorization: string,
|
||||
certificate: Buffer,
|
||||
privateKey: Buffer,
|
||||
trustAnchors: readonly Buffer[],
|
||||
poolKey: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Uint8Array> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const settle = (error?: unknown, bytes?: Buffer): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
signal?.removeEventListener('abort', abort);
|
||||
if (error) reject(error);
|
||||
else resolve(bytes!);
|
||||
};
|
||||
let clientRequest: ClientRequest;
|
||||
const abort = (): void => {
|
||||
clientRequest.destroy(
|
||||
signal?.reason instanceof Error
|
||||
? signal.reason
|
||||
: new WorkerIngressHttpsClientError('request_rejected'),
|
||||
);
|
||||
};
|
||||
try {
|
||||
clientRequest = this.requestFactory({
|
||||
protocol: 'https:',
|
||||
hostname: this.origin.hostname,
|
||||
port: this.origin.port || 443,
|
||||
...(isIP(this.origin.hostname) === 0
|
||||
? { servername: this.origin.hostname }
|
||||
: {}),
|
||||
method: 'POST',
|
||||
path,
|
||||
agent: this.agent,
|
||||
minVersion: 'TLSv1.3',
|
||||
rejectUnauthorized: true,
|
||||
cert: certificate,
|
||||
key: privateKey,
|
||||
ca: [...trustAnchors],
|
||||
[CREDENTIAL_POOL_KEY]: poolKey,
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
authorization,
|
||||
'content-type': WORKER_INGRESS_ARTIFACT_CONTENT_TYPE,
|
||||
'content-length': String(byteLength),
|
||||
},
|
||||
} as WorkerIngressHttpsAgentRequestOptions, (response) => {
|
||||
const contentType = response.headers['content-type'];
|
||||
const contentEncoding = response.headers['content-encoding'];
|
||||
const contentLength = response.headers['content-length'];
|
||||
if (
|
||||
response.statusCode !== 200 ||
|
||||
typeof contentType !== 'string' ||
|
||||
!/^application\/json(?:\s*;|$)/i.test(contentType) ||
|
||||
(contentEncoding !== undefined && contentEncoding !== 'identity') ||
|
||||
(contentLength !== undefined &&
|
||||
(!/^\d+$/.test(contentLength) ||
|
||||
Number(contentLength) > maximumResponseBytes))
|
||||
) {
|
||||
response.resume();
|
||||
clientRequest.destroy();
|
||||
settle(new WorkerIngressHttpsClientError('response_rejected'));
|
||||
return;
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
let total = 0;
|
||||
response.on('data', (chunk: Buffer | string) => {
|
||||
if (settled) return;
|
||||
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
total += bytes.byteLength;
|
||||
if (total > maximumResponseBytes) {
|
||||
chunks.forEach((value) => value.fill(0));
|
||||
response.destroy();
|
||||
settle(new WorkerIngressHttpsClientError('response_too_large'));
|
||||
return;
|
||||
}
|
||||
chunks.push(Buffer.from(bytes));
|
||||
});
|
||||
response.once('end', () => {
|
||||
if (settled) return;
|
||||
const result = Buffer.concat(chunks, total);
|
||||
chunks.forEach((value) => value.fill(0));
|
||||
if (result.byteLength < 2) {
|
||||
result.fill(0);
|
||||
settle(new WorkerIngressHttpsClientError('response_rejected'));
|
||||
return;
|
||||
}
|
||||
settle(undefined, result);
|
||||
});
|
||||
response.once('error', () => {
|
||||
chunks.forEach((value) => value.fill(0));
|
||||
settle(new WorkerIngressHttpsClientError('response_rejected'));
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
settle(new WorkerIngressHttpsClientError('request_rejected'));
|
||||
return;
|
||||
}
|
||||
clientRequest.once('error', (error) => {
|
||||
if (signal?.aborted) settle(signal.reason ?? error);
|
||||
else settle(new WorkerIngressHttpsClientError('request_rejected'));
|
||||
});
|
||||
clientRequest.setTimeout(this.requestTimeoutMs, () => {
|
||||
clientRequest.destroy(
|
||||
new WorkerIngressHttpsClientError('request_rejected'),
|
||||
);
|
||||
});
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
void (async () => {
|
||||
let total = 0;
|
||||
for await (const chunk of body) {
|
||||
if (settled) return;
|
||||
if (signal?.aborted) throw signal.reason;
|
||||
if (!(chunk instanceof Uint8Array)) {
|
||||
throw new WorkerIngressHttpsClientError('request_rejected');
|
||||
}
|
||||
total += chunk.byteLength;
|
||||
if (total > byteLength) {
|
||||
throw new WorkerIngressHttpsClientError('request_rejected');
|
||||
}
|
||||
if (!clientRequest.write(chunk)) {
|
||||
await once(
|
||||
clientRequest,
|
||||
'drain',
|
||||
signal ? { signal } : undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (total !== byteLength) {
|
||||
throw new WorkerIngressHttpsClientError('request_rejected');
|
||||
}
|
||||
clientRequest.end();
|
||||
})().catch((error: unknown) => {
|
||||
clientRequest.destroy(
|
||||
error instanceof Error
|
||||
? error
|
||||
: new WorkerIngressHttpsClientError('request_rejected'),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user