blob: 22845f3dc39b8d2cff7a6f2692cad138041841a0 [file]
import { Buffer } from 'node:buffer';
import {
appendFile,
lstat,
mkdir,
open,
readFile,
readdir,
type FileHandle,
} from 'node:fs/promises';
import { join } from 'node:path';
import type { ExecutionLogCursor } from '@maka/core/execution-evidence';
import {
assertStorageRootLease,
runWithStorageRootLease,
type StorageRootLease,
} from '@maka/storage/root-authority';
import { chainWrite } from '@maka/storage/write-queue';
import { unlock, waitForLock } from 'fs-native-extensions';
import { publishFileExclusively } from './immutable-file.js';
import type { TaskEvent } from './task-contracts.js';
import { isTaskRunLocator, taskRunLocator } from './task-run-identity.js';
import { projectTaskRun, type TaskRunProjection } from './task-run-projection.js';
export interface TaskRunReader {
listTaskRunIds(): Promise<string[]>;
readEventRecords(taskRunId: string): Promise<TaskEventLedgerEntry[]>;
readEvents(taskRunId: string): Promise<TaskEvent[]>;
project(taskRunId: string): Promise<TaskRunProjection>;
}
export interface TaskRunWriter extends TaskRunReader {
appendEvent(taskRunId: string, event: TaskEvent): Promise<void>;
}
export interface TaskEventLedgerEntry {
event: TaskEvent;
cursor: ExecutionLogCursor;
}
export function createInMemoryTaskRunStore(
initialEvents: readonly TaskEvent[] = [],
): TaskRunWriter {
return new InMemoryTaskRunStore(initialEvents);
}
export async function openHeadlessTaskRunReader(
lease: StorageRootLease<'headless', 'read'>,
): Promise<TaskRunReader> {
await assertStorageRootLease(lease, 'headless', 'read');
const store = new FileTaskRunStore(lease.canonicalPath);
return taskRunReaderFacade(store, (operation) =>
runWithStorageRootLease(lease, 'headless', 'read', operation),
);
}
export async function openHeadlessTaskRunWriter(
lease: StorageRootLease<'headless', 'write'>,
): Promise<TaskRunWriter> {
await assertStorageRootLease(lease, 'headless', 'write');
const store = new FileTaskRunStore(lease.canonicalPath);
return taskRunWriterFacade(store, (operation) =>
runWithStorageRootLease(lease, 'headless', 'write', operation),
);
}
type RunTaskRunOperation = <T>(operation: () => Promise<T>) => Promise<T>;
function taskRunReaderFacade(store: FileTaskRunStore, run: RunTaskRunOperation): TaskRunReader {
return Object.freeze(taskRunReaderMethods(store, run));
}
function taskRunReaderMethods(store: FileTaskRunStore, run: RunTaskRunOperation): TaskRunReader {
return {
listTaskRunIds: () => run(() => store.listTaskRunIds()),
readEventRecords: (taskRunId) => run(() => store.readEventRecords(taskRunId)),
readEvents: (taskRunId) => run(() => store.readEvents(taskRunId)),
project: (taskRunId) => run(() => store.project(taskRunId)),
};
}
function taskRunWriterFacade(store: FileTaskRunStore, run: RunTaskRunOperation): TaskRunWriter {
const writer: TaskRunWriter = {
...taskRunReaderMethods(store, run),
appendEvent: (taskRunId, event) => run(() => store.appendEvent(taskRunId, event)),
};
return Object.freeze(writer);
}
class InMemoryTaskRunStore implements TaskRunWriter {
private readonly events = new Map<string, TaskEvent[]>();
private readonly queues = new Map<string, Promise<void>>();
constructor(initialEvents: readonly TaskEvent[]) {
for (const event of initialEvents) {
const events = this.events.get(event.taskRunId) ?? [];
events.push(event);
this.events.set(event.taskRunId, events);
}
}
async appendEvent(taskRunId: string, event: TaskEvent): Promise<void> {
if (event.taskRunId !== taskRunId) {
throw new Error(`taskRunId mismatch: append target ${taskRunId}, event ${event.taskRunId}`);
}
await chainWrite(this.queues, taskRunId, async () => {
const events = this.events.get(taskRunId) ?? [];
events.push(event);
this.events.set(taskRunId, events);
});
}
async listTaskRunIds(): Promise<string[]> {
return [...this.events.keys()].sort();
}
async readEvents(taskRunId: string): Promise<TaskEvent[]> {
return (await this.readEventRecords(taskRunId)).map((record) => record.event);
}
async readEventRecords(taskRunId: string): Promise<TaskEventLedgerEntry[]> {
return (this.events.get(taskRunId) ?? []).map((event, sequence) => ({
event,
cursor: taskEventCursor(taskRunId, sequence, event.id),
}));
}
async project(taskRunId: string): Promise<TaskRunProjection> {
return projectTaskRun(await this.readEvents(taskRunId), taskRunId);
}
}
class FileTaskRunStore implements TaskRunWriter {
private readonly queues = new Map<string, Promise<void>>();
constructor(private readonly storageRoot: string) {}
async appendEvent(taskRunId: string, event: TaskEvent): Promise<void> {
if (event.taskRunId !== taskRunId) {
throw new Error(`taskRunId mismatch: append target ${taskRunId}, event ${event.taskRunId}`);
}
const locator = taskRunLocator(taskRunId);
await chainWrite(this.queues, locator, () =>
this.appendTaskRunEvent(locator, taskRunId, event),
);
}
async listTaskRunIds(): Promise<string[]> {
let entries: string[];
try {
entries = (await readdir(this.taskRunDir())).filter((name) => name.endsWith('.jsonl')).sort();
} catch (error) {
if (isNotFound(error)) return [];
throw error;
}
const identities: string[] = [];
for (const entry of entries) {
const locator = taskRunLocatorFromFilename(entry);
const header = await readTaskRunLedgerHeader(join(this.taskRunDir(), entry));
assertTaskRunLedgerIdentity(header, locator, entry);
identities.push(header.taskRunId);
}
return identities.sort();
}
async readEvents(taskRunId: string): Promise<TaskEvent[]> {
return (await this.readEventRecords(taskRunId)).map((record) => record.event);
}
async readEventRecords(taskRunId: string): Promise<TaskEventLedgerEntry[]> {
const path = this.taskRunPath(taskRunId);
let content: string;
try {
content = await readFile(path, 'utf8');
} catch (error) {
if (isNotFound(error)) return [];
throw error;
}
const lines = durableJsonlLines(content);
const header = parseTaskRunLedgerHeader(lines.shift(), path);
assertTaskRunLedgerIdentity(header, taskRunLocator(taskRunId), path);
if (header.taskRunId !== taskRunId) {
throw new Error(`TaskRun ledger identity does not match requested taskRunId ${taskRunId}`);
}
const records: TaskEventLedgerEntry[] = [];
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i];
if (!line) continue;
let event: TaskEvent;
try {
const parsed = JSON.parse(line) as unknown;
if (
typeof parsed !== 'object' ||
parsed === null ||
typeof (parsed as { taskRunId?: unknown }).taskRunId !== 'string'
) {
throw new Error('record is not a TaskEvent');
}
event = parsed as TaskEvent;
} catch (error) {
event = {
type: 'event_corrupt',
id: `corrupt-${i + 1}`,
taskRunId,
ts: 0,
raw: line,
error: errorMessage(error),
};
}
if (event.taskRunId !== taskRunId) {
throw new Error(`TaskRun ledger ${path} contains event for ${String(event.taskRunId)}`);
}
records.push({
event,
cursor: taskEventCursor(taskRunId, records.length, event.id),
});
}
return records;
}
async project(taskRunId: string): Promise<TaskRunProjection> {
return projectTaskRun(await this.readEvents(taskRunId), taskRunId);
}
private taskRunDir(): string {
return join(this.storageRoot, 'task-runs');
}
private taskRunPath(taskRunId: string): string {
return join(this.taskRunDir(), `${taskRunLocator(taskRunId)}.jsonl`);
}
private async appendTaskRunEvent(
locator: string,
taskRunId: string,
event: TaskEvent,
): Promise<void> {
const eventLine = `${JSON.stringify(event)}\n`;
await mkdir(this.taskRunDir(), { recursive: true });
const path = join(this.taskRunDir(), `${locator}.jsonl`);
await withTaskRunLedgerLock(`${path}.lock`, async () => {
let header: TaskRunLedgerHeader;
try {
header = await readTaskRunLedgerHeader(path);
} catch (error) {
if (!isNotFound(error)) throw error;
const initialContent = `${JSON.stringify(taskRunLedgerHeader(taskRunId))}\n${eventLine}`;
if (await publishFileExclusively(path, initialContent)) return;
header = await readTaskRunLedgerHeader(path);
}
assertTaskRunLedgerIdentity(header, locator, path);
if (header.taskRunId !== taskRunId) {
throw new Error(`TaskRun identity collision between ${header.taskRunId} and ${taskRunId}`);
}
await truncatePartialTaskRunTail(path);
await appendFile(path, eventLine, 'utf8');
});
}
}
const TASK_RUN_LEDGER_SCHEMA_VERSION = 1 as const;
const TASK_RUN_LEDGER_TYPE = 'task_run_ledger' as const;
interface TaskRunLedgerHeader {
schemaVersion: typeof TASK_RUN_LEDGER_SCHEMA_VERSION;
type: typeof TASK_RUN_LEDGER_TYPE;
taskRunId: string;
}
function taskRunLocatorFromFilename(filename: string): string {
const locator = filename.slice(0, -'.jsonl'.length);
if (!isTaskRunLocator(locator)) {
throw new Error(`Invalid TaskRun ledger filename ${filename}`);
}
return locator;
}
function taskRunLedgerHeader(taskRunId: string): TaskRunLedgerHeader {
taskRunLocator(taskRunId);
return {
schemaVersion: TASK_RUN_LEDGER_SCHEMA_VERSION,
type: TASK_RUN_LEDGER_TYPE,
taskRunId,
};
}
function parseTaskRunLedgerHeader(line: string | undefined, source: string): TaskRunLedgerHeader {
if (!line) throw new Error(`TaskRun ledger ${source} has no durable header`);
let record: unknown;
try {
record = JSON.parse(line);
} catch (error) {
throw new Error(`TaskRun ledger ${source} has an invalid header: ${errorMessage(error)}`);
}
if (
typeof record !== 'object' ||
record === null ||
(record as { schemaVersion?: unknown }).schemaVersion !== TASK_RUN_LEDGER_SCHEMA_VERSION ||
(record as { type?: unknown }).type !== TASK_RUN_LEDGER_TYPE ||
typeof (record as { taskRunId?: unknown }).taskRunId !== 'string'
) {
throw new Error(`TaskRun ledger ${source} has an invalid header`);
}
const header = record as TaskRunLedgerHeader;
taskRunLocator(header.taskRunId);
return header;
}
function assertTaskRunLedgerIdentity(
header: TaskRunLedgerHeader,
expectedLocator: string,
source: string,
): void {
if (taskRunLocator(header.taskRunId) !== expectedLocator) {
throw new Error(`TaskRun ledger ${source} does not match its identity locator`);
}
}
async function withTaskRunLedgerLock<T>(lockPath: string, operation: () => Promise<T>): Promise<T> {
const handle = await open(lockPath, 'a+', 0o600);
try {
await assertStableLockArtifact(handle, lockPath);
await handle.chmod(0o600);
await waitForLock(handle.fd);
try {
await assertStableLockArtifact(handle, lockPath);
return await operation();
} finally {
releaseLock(handle);
}
} finally {
await handle.close();
}
}
async function assertStableLockArtifact(handle: FileHandle, path: string): Promise<void> {
const [handleStats, pathStats] = await Promise.all([
handle.stat({ bigint: true }),
lstat(path, { bigint: true }),
]);
if (
!handleStats.isFile() ||
!pathStats.isFile() ||
handleStats.dev !== pathStats.dev ||
handleStats.ino !== pathStats.ino
) {
throw new Error(`TaskRun lock path is not one stable regular file: ${path}`);
}
}
function releaseLock(handle: FileHandle): void {
try {
unlock(handle.fd);
} catch {
// Closing the file handle is the authoritative release path.
}
}
async function readTaskRunLedgerHeader(path: string): Promise<TaskRunLedgerHeader> {
return parseTaskRunLedgerHeader(await readFirstDurableLine(path), path);
}
async function truncatePartialTaskRunTail(path: string): Promise<void> {
const handle = await open(path, 'r+');
try {
const { size } = await handle.stat();
let position = size;
while (position > 0) {
const length = Math.min(position, 4096);
position -= length;
const buffer = Buffer.allocUnsafe(length);
let bytesRead = 0;
while (bytesRead < length) {
const read = await handle.read(buffer, bytesRead, length - bytesRead, position + bytesRead);
if (read.bytesRead === 0) {
throw new Error(`TaskRun ledger ${path} changed while repairing its tail`);
}
bytesRead += read.bytesRead;
}
const newline = buffer.lastIndexOf(0x0a);
if (newline === -1) continue;
const durableSize = position + newline + 1;
if (durableSize < size) {
await handle.truncate(durableSize);
await handle.sync();
}
return;
}
throw new Error(`TaskRun ledger ${path} has no durable record`);
} finally {
await handle.close();
}
}
async function readFirstDurableLine(path: string): Promise<string | undefined> {
const handle = await open(path, 'r');
const chunks: Buffer[] = [];
let position = 0;
try {
while (true) {
const buffer = Buffer.allocUnsafe(4096);
const { bytesRead } = await handle.read(buffer, 0, buffer.length, position);
if (bytesRead === 0) return undefined;
const bytes = buffer.subarray(0, bytesRead);
const newline = bytes.indexOf(0x0a);
chunks.push(newline === -1 ? bytes : bytes.subarray(0, newline));
if (newline !== -1) return Buffer.concat(chunks).toString('utf8');
position += bytesRead;
}
} finally {
await handle.close();
}
}
function durableJsonlLines(content: string): string[] {
return content.endsWith('\n') ? content.split('\n') : content.split('\n').slice(0, -1);
}
function taskEventCursor(taskRunId: string, sequence: number, eventId: string): ExecutionLogCursor {
return { ledger: 'task_event', streamId: taskRunId, sequence, eventId };
}
function isNotFound(error: unknown): boolean {
return (
typeof error === 'object' && error !== null && (error as { code?: string }).code === 'ENOENT'
);
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}