blob: 4b7226b3402fda00ac31f6a2c2d2ea6686ca5127 [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 { createHash, randomUUID } from 'node:crypto';
import { mkdir, open, readFile, rename, unlink } from 'node:fs/promises';
import { join } from 'node:path';
import { stableJsonStringify } from '@maka/core/canonical-json';
import type { ExperimentSpec } from './experiment.js';
import {
createFleetState,
transitionFleet,
type FleetArtifact,
type FleetCommand,
type FleetPolicy,
type FleetReply,
type FleetState,
type FleetWorkerCommand,
} from './fleet.js';
/** Exactly one coordinator owns a persistence instance. save must be atomic and durable. */
export interface FleetPersistence {
load(): Promise<FleetState | null>;
save(state: FleetState): Promise<void>;
putArtifact(bytes: Uint8Array): Promise<FleetArtifact>;
getArtifact(ref: FleetArtifact): Promise<Uint8Array>;
}
export interface FleetTransport {
command(command: FleetWorkerCommand): Promise<FleetReply>;
putArtifact(bytes: Uint8Array): Promise<FleetArtifact>;
}
export class FleetCoordinator implements FleetTransport {
static #owners = new WeakSet<FleetPersistence>();
#tail: Promise<unknown> = Promise.resolve();
#closed = false;
private constructor(
readonly persistence: FleetPersistence,
private readonly now: () => number,
) {}
static async open(input: {
persistence: FleetPersistence;
spec: ExperimentSpec;
policy: FleetPolicy;
now: () => number;
}): Promise<FleetCoordinator> {
if (FleetCoordinator.#owners.has(input.persistence))
throw new Error('fleet persistence already has a coordinator');
FleetCoordinator.#owners.add(input.persistence);
try {
const initial = createFleetState(input.spec, input.policy);
const existing = await input.persistence.load();
if (
existing &&
(existing.version !== initial.version ||
stableJsonStringify(existing.spec) !== stableJsonStringify(initial.spec) ||
stableJsonStringify(existing.policy) !== stableJsonStringify(initial.policy))
) {
throw new Error('fleet run identity differs');
}
if (!existing) await input.persistence.save(initial);
const coordinator = new FleetCoordinator(input.persistence, input.now);
await coordinator.command({ kind: 'recover' });
return coordinator;
} catch (error) {
FleetCoordinator.#owners.delete(input.persistence);
throw error;
}
}
command(command: FleetCommand): Promise<FleetReply> {
if (this.#closed) return Promise.reject(new Error('coordinator closed'));
const captured = structuredClone(command);
const operation = this.#tail.then(async () => {
const state = await this.persistence.load();
if (!state) throw new Error('fleet state missing');
if (captured.kind === 'report') {
for (const artifact of captured.report.artifacts)
await this.persistence.getArtifact(artifact);
}
const next = transitionFleet(state, captured, this.now());
await this.persistence.save(next.state);
return next.reply;
});
this.#tail = operation.catch(() => undefined);
return operation;
}
async snapshot(): Promise<FleetState> {
await this.#tail;
const state = await this.persistence.load();
if (!state) throw new Error('fleet state missing');
return state;
}
putArtifact(bytes: Uint8Array): Promise<FleetArtifact> {
if (this.#closed) return Promise.reject(new Error('coordinator closed'));
return this.persistence.putArtifact(bytes);
}
/** Stop accepting commands and drain commits before closing persistence or replacing this owner. */
async close() {
if (this.#closed) {
await this.#tail;
return;
}
this.#closed = true;
await this.#tail;
FleetCoordinator.#owners.delete(this.persistence);
}
}
export class MemoryFleetPersistence implements FleetPersistence {
#state: FleetState | null = null;
#artifacts = new Map<string, Uint8Array>();
async load() {
return structuredClone(this.#state);
}
async save(state: FleetState) {
this.#state = structuredClone(state);
}
async putArtifact(bytes: Uint8Array) {
const ref = reference(bytes);
this.#artifacts.set(ref.sha256, Uint8Array.from(bytes));
return ref;
}
async getArtifact(ref: FleetArtifact) {
validateReference(ref);
const bytes = this.#artifacts.get(ref.sha256);
if (!bytes) throw new Error('artifact missing');
verifyArtifact(ref, bytes);
return Uint8Array.from(bytes);
}
}
/** Local coordinator storage. A stale writer lock requires explicit operator recovery. */
export class FileFleetPersistence implements FleetPersistence {
#tail: Promise<unknown> = Promise.resolve();
#closing: Promise<void> | undefined;
private constructor(
readonly root: string,
private readonly release: () => Promise<void>,
) {}
static async open(root: string): Promise<FileFleetPersistence> {
await mkdir(root, { recursive: true });
const lockPath = join(root, '.writer.lock');
const lock = await open(lockPath, 'wx', 0o600);
let closed = false;
return new FileFleetPersistence(root, async () => {
if (closed) return;
closed = true;
await lock.close();
await unlink(lockPath);
});
}
async close() {
this.#closing ??= this.#tail.then(() => this.release());
await this.#closing;
}
#operation<T>(operation: () => Promise<T>): Promise<T> {
if (this.#closing) return Promise.reject(new Error('fleet persistence closed'));
const running = this.#tail.then(operation);
this.#tail = running.catch(() => undefined);
return running;
}
load(): Promise<FleetState | null> {
return this.#operation(() => this.#load());
}
async #load(): Promise<FleetState | null> {
let raw: string;
try {
raw = await readFile(join(this.root, 'state.json'), 'utf8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
throw error;
}
const envelope = JSON.parse(raw) as { sha256: string; state: FleetState };
if (digest(Buffer.from(stableJsonStringify(envelope.state))) !== envelope.sha256) {
throw new Error('fleet state checksum mismatch');
}
if (envelope.state.version !== 'maka.eval.fleet.v1') throw new Error('unsupported fleet state');
return envelope.state;
}
async save(state: FleetState) {
const canonical = stableJsonStringify(state);
const bytes = Buffer.from(JSON.stringify({ sha256: digest(Buffer.from(canonical)), state }));
await this.#operation(() => atomicWrite(this.root, 'state.json', bytes));
}
async putArtifact(bytes: Uint8Array): Promise<FleetArtifact> {
const captured = Uint8Array.from(bytes);
return this.#operation(async () => {
const ref = reference(captured);
const directory = join(this.root, 'artifacts');
await mkdir(directory, { recursive: true });
await atomicWrite(directory, ref.sha256, captured);
return ref;
});
}
async getArtifact(ref: FleetArtifact): Promise<Uint8Array> {
validateReference(ref);
const captured = { ...ref };
return this.#operation(async () => {
const bytes = await readFile(join(this.root, 'artifacts', captured.sha256));
verifyArtifact(captured, bytes);
return bytes;
});
}
}
async function atomicWrite(directory: string, name: string, bytes: Uint8Array) {
const temporary = join(directory, `.${name}.${randomUUID()}.tmp`);
const file = await open(temporary, 'wx', 0o600);
try {
await file.writeFile(bytes);
await file.sync();
await file.close();
await rename(temporary, join(directory, name));
if (process.platform !== 'win32') {
const parent = await open(directory, 'r');
try {
await parent.sync();
} finally {
await parent.close();
}
}
} finally {
await file.close().catch(() => undefined);
await unlink(temporary).catch((error: NodeJS.ErrnoException) => {
if (error.code !== 'ENOENT') throw error;
});
}
}
function digest(bytes: Uint8Array) {
return createHash('sha256').update(bytes).digest('hex');
}
function reference(bytes: Uint8Array): FleetArtifact {
return { sha256: digest(bytes), bytes: bytes.byteLength };
}
function validateReference(ref: FleetArtifact) {
if (!/^[a-f0-9]{64}$/.test(ref.sha256) || !Number.isSafeInteger(ref.bytes) || ref.bytes < 0) {
throw new Error('invalid artifact reference');
}
}
function verifyArtifact(ref: FleetArtifact, bytes: Uint8Array) {
if (bytes.byteLength !== ref.bytes || digest(bytes) !== ref.sha256)
throw new Error('artifact checksum mismatch');
}