blob: 6bce1eca6a6e1b67165fa33c22fcb910ec63a6b9 [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 { RuntimeMessageAuthorityInvariantError } from '@maka/runtime/message-authority';
import type { HostOperationErrorCode, OperationSpec } from '../protocol/operation-spec.js';
import type { SessionAdmissionGate } from './session-admission-gate.js';
export type QueuedMutationKind =
| 'retract'
| 'retract_entry'
| 'promote'
| 'update_entry'
| 'reorder';
type MutationInput = {
readonly originHostEpoch: string;
readonly sessionId: string;
};
type QueuedMutationErrorCode =
| 'host_draining'
| 'operation_unavailable'
| 'not_found'
| 'session_archived'
| 'session_busy'
| 'operation_conflict'
| 'outcome_unknown';
type MutationOutcome<T> =
| { readonly ok: true; readonly result: T }
| {
readonly ok: false;
readonly error: { readonly code: QueuedMutationErrorCode; readonly message: string };
};
export interface CompletedQueuedMutation {
readonly payloadIdentity: object;
readonly result: object;
}
export interface QueuedMutationRequest<I extends MutationInput, R> {
readonly spec: OperationSpec<I, R, HostOperationErrorCode>;
readonly kind: QueuedMutationKind;
readonly id: string;
readonly verb: string;
readonly input: I;
readonly payloadIdentity: object;
readonly execute: () => Promise<MutationOutcome<R>>;
}
interface PendingMutation {
readonly payload: MutationInput;
readonly result: Promise<MutationOutcome<unknown>>;
}
interface QueuedMutationExecutorOptions {
readonly hostEpoch: string;
readonly admissions: SessionAdmissionGate;
readonly isFailStopped: () => boolean;
readonly readCompleted: (
kind: QueuedMutationKind,
sessionId: string,
operationId: string,
) => CompletedQueuedMutation | undefined;
}
/** Epoch-local idempotency and Session admission for all queue mutations. */
export class QueuedMutationExecutor {
readonly #hostEpoch: string;
readonly #admissions: SessionAdmissionGate;
readonly #isFailStopped: () => boolean;
readonly #readCompleted: QueuedMutationExecutorOptions['readCompleted'];
readonly #pending = new Map<string, PendingMutation>();
constructor(options: QueuedMutationExecutorOptions) {
this.#hostEpoch = options.hostEpoch;
this.#admissions = options.admissions;
this.#isFailStopped = options.isFailStopped;
this.#readCompleted = options.readCompleted;
}
run<I extends MutationInput, R>(
request: QueuedMutationRequest<I, R>,
): Promise<MutationOutcome<R>> {
const currentEpoch = request.input.originHostEpoch === this.#hostEpoch;
const key = mutationKey(request.kind, request.input.sessionId, request.id);
if (currentEpoch) {
const pending = this.#pending.get(key);
if (pending) {
return isDeepStrictEqual(pending.payload, request.input)
? (pending.result as Promise<MutationOutcome<R>>)
: Promise.resolve(conflict(`${request.verb} identity has a different payload`));
}
}
if (this.#isFailStopped()) return Promise.resolve(draining());
if (!currentEpoch) {
return Promise.resolve(
failed('outcome_unknown', `${request.verb} outcome is not durable across Host Epochs`),
);
}
const result = this.#admit(request);
this.#pending.set(key, { payload: request.input, result });
void result.then(
() => this.#settle(key, result),
() => this.#settle(key, result),
);
return result;
}
pendingResults(sessionId: string): readonly Promise<MutationOutcome<unknown>>[] {
return [...this.#pending.values()]
.filter(({ payload }) => payload.sessionId === sessionId)
.map(({ result }) => result);
}
#admit<I extends MutationInput, R>(
request: QueuedMutationRequest<I, R>,
): Promise<MutationOutcome<R>> {
return this.#admissions.run(request.input.sessionId, async () => {
if (this.#isFailStopped()) return draining();
const completed = this.#readCompleted(request.kind, request.input.sessionId, request.id);
if (!completed) return request.execute();
if (!isDeepStrictEqual(completed.payloadIdentity, request.payloadIdentity)) {
return conflict(`${request.verb} identity has a different payload`);
}
try {
return { ok: true, result: request.spec.decodeOutput(completed.result) };
} catch (error) {
throw new RuntimeMessageAuthorityInvariantError(
`Invalid queued mutation replay outcome: ${
error instanceof Error ? error.message : 'malformed'
}`,
);
}
});
}
#settle(key: string, result: Promise<MutationOutcome<unknown>>): void {
if (this.#pending.get(key)?.result === result) this.#pending.delete(key);
}
}
function mutationKey(kind: QueuedMutationKind, sessionId: string, operationId: string): string {
return `${kind}\0${sessionId}\0${operationId}`;
}
function draining(): MutationOutcome<never> {
return failed('host_draining', 'Runtime Host message authority has failed');
}
function conflict(message: string): MutationOutcome<never> {
return failed('operation_conflict', message);
}
function failed(code: QueuedMutationErrorCode, message: string): MutationOutcome<never> {
return { ok: false, error: { code, message } };
}