blob: e8307b08130196e778fb6867dc2d3c03719c1c9c [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { isDeepStrictEqual } from 'node:util';
import { preservesHostedExecutionEnvironment } from '../protocol/index.js';
import type {
HostedExecutionProjection,
HostedExecutionReferenceInput,
HostedExecutionStartInput,
OperationOutcome,
} from '../protocol/index.js';
import type { HostedExecutionOperationHandlerMap } from './operation-dispatcher.js';
export class HostHostedExecutionCoordinator {
readonly handlers: HostedExecutionOperationHandlerMap = {
'hosted.execution.start': (input) => this.#start(input),
'hosted.execution.cancel': (input) => this.#cancel(input),
};
readonly #executions = new Map<
string,
{
readonly input: HostedExecutionStartInput;
readonly abort: AbortController;
readonly task: Promise<HostedExecutionProjection>;
}
>();
readonly #cancelled = new Set<string>();
#accepting = true;
constructor(
private readonly run: (
input: HostedExecutionStartInput,
signal: AbortSignal,
) => Promise<HostedExecutionProjection>,
private readonly requestDrain: () => void,
) {}
beginDrain(): void {
this.#accepting = false;
for (const execution of this.#executions.values()) execution.abort.abort();
}
async close(): Promise<void> {
this.beginDrain();
await Promise.all([...this.#executions.values()].map(({ task }) => task));
}
async #start(
input: HostedExecutionStartInput,
): Promise<OperationOutcome<'hosted.execution.start'>> {
if (this.#cancelled.has(input.executionId)) {
this.requestDrain();
return {
ok: true,
result: indeterminate(input.executionId, 'Hosted execution was cancelled before admission'),
};
}
const existing = this.#executions.get(input.executionId);
if (existing) {
if (!isDeepStrictEqual(existing.input, input)) return conflict();
return { ok: true, result: structuredClone(await existing.task) };
}
if (!this.#accepting) {
return { ok: false, error: { code: 'host_draining', message: 'Runtime Host is draining' } };
}
const abort = new AbortController();
const task = this.run(input, abort.signal)
.catch(() => indeterminate(input.executionId, 'Runtime Host could not settle execution'))
.then((result) => {
if (!preservesHostedExecutionEnvironment(result)) this.requestDrain();
return result;
})
.finally(() => {
this.#executions.delete(input.executionId);
});
this.#executions.set(input.executionId, { input: structuredClone(input), abort, task });
return { ok: true, result: structuredClone(await task) };
}
async #cancel(
input: HostedExecutionReferenceInput,
): Promise<OperationOutcome<'hosted.execution.cancel'>> {
this.#cancelled.add(input.executionId);
const execution = this.#executions.get(input.executionId);
if (!execution) {
this.requestDrain();
return {
ok: true,
result: indeterminate(input.executionId, 'Hosted execution is not active'),
};
}
execution.abort.abort();
return { ok: true, result: structuredClone(await execution.task) };
}
}
function conflict(): OperationOutcome<'hosted.execution.start'> {
return {
ok: false,
error: { code: 'operation_conflict', message: 'Hosted execution identity is already in use' },
};
}
function indeterminate(executionId: string, failureReason: string): HostedExecutionProjection {
return { executionId, kind: 'indeterminate', failureReason };
}