mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 18:08:20 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
import { constants } from 'fs';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { randomBytes } from 'crypto';
|
||||
import {
|
||||
assertCompletionReceiptId,
|
||||
InvalidCompletionReceiptError,
|
||||
MAX_COMPLETION_RECEIPT_BYTES,
|
||||
parseCompletionReceipt,
|
||||
serializeCompletionReceipt,
|
||||
type CompletionReceipt,
|
||||
} from '../../domain/completionReceipt';
|
||||
import type { CompletionReceiptStore } from '../../ports/completionReceiptStore';
|
||||
|
||||
export class CompletionReceiptAlreadyExistsError extends Error {
|
||||
constructor(readonly attemptId: string) {
|
||||
super(`Completion receipt already exists for Attempt ${attemptId}`);
|
||||
this.name = 'CompletionReceiptAlreadyExistsError';
|
||||
}
|
||||
}
|
||||
|
||||
function isCode(error: unknown, code: string): boolean {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'code' in error &&
|
||||
(error as { code?: string }).code === code
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A bounded local journal. It does not scan directories or own a lifecycle;
|
||||
* callers must discover active Attempts from the database.
|
||||
*/
|
||||
export class CompletionReceiptFileStore implements CompletionReceiptStore {
|
||||
constructor(private readonly root: string) {
|
||||
if (!path.isAbsolute(root)) {
|
||||
throw new RangeError('Completion receipt root must be absolute');
|
||||
}
|
||||
}
|
||||
|
||||
async publish(receipt: CompletionReceipt): Promise<void> {
|
||||
const serialized = serializeCompletionReceipt(receipt);
|
||||
const directory = this.directory(receipt.attemptId);
|
||||
const target = this.target(receipt.attemptId);
|
||||
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
||||
const temporary = path.join(
|
||||
directory,
|
||||
`.${receipt.attemptId}.${randomBytes(16).toString('hex')}.tmp`,
|
||||
);
|
||||
let handle: fs.FileHandle | undefined;
|
||||
try {
|
||||
handle = await fs.open(temporary, 'wx', 0o600);
|
||||
await handle.writeFile(serialized, 'utf8');
|
||||
await handle.sync();
|
||||
await handle.close();
|
||||
handle = undefined;
|
||||
|
||||
// A hard link publishes the fully written inode atomically and, unlike
|
||||
// plain rename(), never replaces an existing completion fact.
|
||||
try {
|
||||
await fs.link(temporary, target);
|
||||
} catch (error) {
|
||||
if (isCode(error, 'EEXIST')) {
|
||||
throw new CompletionReceiptAlreadyExistsError(receipt.attemptId);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
await this.bestEffortUnlink(temporary);
|
||||
await this.bestEffortSyncDirectory(directory);
|
||||
} catch (error) {
|
||||
await handle?.close().catch(() => undefined);
|
||||
await this.bestEffortUnlink(temporary);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async read(attemptId: string): Promise<CompletionReceipt | undefined> {
|
||||
const target = this.target(attemptId);
|
||||
let handle: fs.FileHandle;
|
||||
try {
|
||||
handle = await fs.open(
|
||||
target,
|
||||
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
} catch (error) {
|
||||
if (isCode(error, 'ENOENT')) return undefined;
|
||||
if (isCode(error, 'ELOOP')) {
|
||||
throw new InvalidCompletionReceiptError(
|
||||
'Completion receipt must not be a symbolic link',
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
const stat = await handle.stat();
|
||||
if (!stat.isFile()) {
|
||||
throw new InvalidCompletionReceiptError(
|
||||
'Completion receipt must be a regular file',
|
||||
);
|
||||
}
|
||||
const bytes = Buffer.allocUnsafe(MAX_COMPLETION_RECEIPT_BYTES + 1);
|
||||
const { bytesRead } = await handle.read(bytes, 0, bytes.length, 0);
|
||||
if (bytesRead > MAX_COMPLETION_RECEIPT_BYTES) {
|
||||
throw new InvalidCompletionReceiptError(
|
||||
'Completion receipt exceeds the byte limit',
|
||||
);
|
||||
}
|
||||
const receipt = parseCompletionReceipt(bytes.subarray(0, bytesRead));
|
||||
if (receipt.attemptId !== attemptId) {
|
||||
throw new InvalidCompletionReceiptError(
|
||||
'Completion receipt path and Attempt do not match',
|
||||
);
|
||||
}
|
||||
return receipt;
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
async remove(attemptId: string): Promise<boolean> {
|
||||
const target = this.target(attemptId);
|
||||
try {
|
||||
await fs.unlink(target);
|
||||
await this.bestEffortSyncDirectory(path.dirname(target));
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isCode(error, 'ENOENT')) return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async quarantine(attemptId: string): Promise<string | undefined> {
|
||||
const target = this.target(attemptId);
|
||||
const reference = this.quarantineReference(attemptId);
|
||||
const relativeDirectory = path.dirname(reference);
|
||||
const directory = path.join(this.root, relativeDirectory);
|
||||
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
||||
await fs.chmod(directory, 0o700);
|
||||
const quarantined = path.join(this.root, reference);
|
||||
try {
|
||||
await fs.link(target, quarantined);
|
||||
} catch (error) {
|
||||
if (isCode(error, 'ENOENT')) {
|
||||
return (await this.pathExists(quarantined)) ? reference : undefined;
|
||||
}
|
||||
if (!isCode(error, 'EEXIST')) throw error;
|
||||
}
|
||||
await this.unlinkIfPresent(target);
|
||||
await this.bestEffortSyncDirectory(path.dirname(target));
|
||||
await this.bestEffortSyncDirectory(directory);
|
||||
return reference;
|
||||
}
|
||||
|
||||
quarantineReference(attemptId: string): string {
|
||||
assertCompletionReceiptId(attemptId, 'attemptId');
|
||||
return path.posix.join(
|
||||
'.quarantine',
|
||||
attemptId.slice(0, 2),
|
||||
`${attemptId}.json`,
|
||||
);
|
||||
}
|
||||
|
||||
async purgeQuarantine(attemptId: string): Promise<boolean> {
|
||||
const target = path.join(this.root, this.quarantineReference(attemptId));
|
||||
try {
|
||||
await fs.unlink(target);
|
||||
await this.bestEffortSyncDirectory(path.dirname(target));
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isCode(error, 'ENOENT')) return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private directory(attemptId: string): string {
|
||||
assertCompletionReceiptId(attemptId, 'attemptId');
|
||||
return path.join(this.root, attemptId.slice(0, 2));
|
||||
}
|
||||
|
||||
private target(attemptId: string): string {
|
||||
return path.join(this.directory(attemptId), `${attemptId}.json`);
|
||||
}
|
||||
|
||||
private async bestEffortUnlink(value: string): Promise<void> {
|
||||
try {
|
||||
await fs.unlink(value);
|
||||
} catch (error) {
|
||||
if (!isCode(error, 'ENOENT')) {
|
||||
// Temp cleanup is diagnostic-only; the immutable final fact wins.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async unlinkIfPresent(value: string): Promise<void> {
|
||||
try {
|
||||
await fs.unlink(value);
|
||||
} catch (error) {
|
||||
if (!isCode(error, 'ENOENT')) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async pathExists(value: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.lstat(value);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isCode(error, 'ENOENT')) return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async bestEffortSyncDirectory(directory: string): Promise<void> {
|
||||
try {
|
||||
const handle = await fs.open(directory, constants.O_RDONLY);
|
||||
try {
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
} catch {
|
||||
// Some supported filesystems cannot fsync directories. The receipt
|
||||
// remains restart-safe, while power-loss durability is best effort.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
import { createHash } from 'crypto';
|
||||
import { constants } from 'fs';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { assertCompletionReceiptId } from '../../domain/completionReceipt';
|
||||
import type {
|
||||
CompletionReceiptDirectoryEntry,
|
||||
CompletionReceiptDirectoryEntryKind,
|
||||
CompletionReceiptOrphanDirectory,
|
||||
CompletionReceiptOrphanQuarantineResult,
|
||||
CompletionReceiptShardSnapshot,
|
||||
} from '../../ports/completionReceiptOrphanMaintenance';
|
||||
|
||||
const SHARD_PATTERN = /^[0-9a-f]{2}$/;
|
||||
const TEMPORARY_PATTERN = /^\.([0-9a-f-]{36})\.[0-9a-f]{32}\.tmp$/;
|
||||
|
||||
function isCode(error: unknown, code: string): boolean {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'code' in error &&
|
||||
(error as { code?: string }).code === code
|
||||
);
|
||||
}
|
||||
|
||||
function receiptAttemptId(name: string): string | undefined {
|
||||
if (!name.endsWith('.json')) return undefined;
|
||||
const attemptId = name.slice(0, -'.json'.length);
|
||||
try {
|
||||
assertCompletionReceiptId(attemptId, 'attemptId');
|
||||
return attemptId;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function temporaryAttemptId(name: string): string | undefined {
|
||||
const match = TEMPORARY_PATTERN.exec(name);
|
||||
if (!match) return undefined;
|
||||
try {
|
||||
assertCompletionReceiptId(match[1], 'attemptId');
|
||||
return match[1];
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function entryKind(
|
||||
shard: string,
|
||||
name: string,
|
||||
regularFile: boolean,
|
||||
): { kind: CompletionReceiptDirectoryEntryKind; attemptId?: string } {
|
||||
if (!regularFile) return { kind: 'unsafe' };
|
||||
const finalAttemptId = receiptAttemptId(name);
|
||||
if (finalAttemptId && finalAttemptId.startsWith(shard)) {
|
||||
return { kind: 'receipt', attemptId: finalAttemptId };
|
||||
}
|
||||
const tempAttemptId = temporaryAttemptId(name);
|
||||
if (tempAttemptId && tempAttemptId.startsWith(shard)) {
|
||||
return { kind: 'temporary', attemptId: tempAttemptId };
|
||||
}
|
||||
return { kind: 'unknown' };
|
||||
}
|
||||
|
||||
function filesystemIdentity(
|
||||
stat: Awaited<ReturnType<typeof fs.lstat>>,
|
||||
): string {
|
||||
return [stat.dev, stat.ino, stat.size, stat.mtimeMs].join(':');
|
||||
}
|
||||
|
||||
export class CompletionReceiptOrphanFileDirectory
|
||||
implements CompletionReceiptOrphanDirectory
|
||||
{
|
||||
constructor(private readonly root: string) {
|
||||
if (!path.isAbsolute(root) || root.includes('\0')) {
|
||||
throw new RangeError(
|
||||
'Completion receipt orphan root must be an absolute path containing no NUL',
|
||||
);
|
||||
}
|
||||
if (path.resolve(root) === path.parse(path.resolve(root)).root) {
|
||||
throw new RangeError(
|
||||
'Completion receipt orphan root must not be a filesystem root',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async inspectShard(
|
||||
shard: string,
|
||||
maxEntries: number,
|
||||
): Promise<CompletionReceiptShardSnapshot> {
|
||||
if (!SHARD_PATTERN.test(shard)) {
|
||||
throw new RangeError(
|
||||
'Completion receipt shard must be two lowercase hex digits',
|
||||
);
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(maxEntries) ||
|
||||
maxEntries < 1 ||
|
||||
maxEntries > 64
|
||||
) {
|
||||
throw new RangeError('maxEntries must be between 1 and 64');
|
||||
}
|
||||
const directoryPath = await this.resolveShardDirectory(shard);
|
||||
if (!directoryPath) return { shard, entries: [], overflow: false };
|
||||
const directory = await fs.opendir(directoryPath, { bufferSize: 1 });
|
||||
|
||||
const entries: CompletionReceiptDirectoryEntry[] = [];
|
||||
let overflow = false;
|
||||
try {
|
||||
for await (const dirent of directory) {
|
||||
if (entries.length === maxEntries) {
|
||||
overflow = true;
|
||||
break;
|
||||
}
|
||||
const entryPath = path.join(directoryPath, dirent.name);
|
||||
let stat: Awaited<ReturnType<typeof fs.lstat>>;
|
||||
try {
|
||||
stat = await fs.lstat(entryPath);
|
||||
} catch (error) {
|
||||
if (isCode(error, 'ENOENT')) continue;
|
||||
throw error;
|
||||
}
|
||||
const classified = entryKind(shard, dirent.name, stat.isFile());
|
||||
entries.push({
|
||||
shard,
|
||||
name: dirent.name,
|
||||
kind: classified.kind,
|
||||
modifiedAtMs: Math.max(0, Math.trunc(stat.mtimeMs)),
|
||||
sizeBytes: stat.size,
|
||||
filesystemIdentity: filesystemIdentity(stat),
|
||||
...(classified.attemptId ? { attemptId: classified.attemptId } : {}),
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await directory.close().catch((error) => {
|
||||
if (!isCode(error, 'ERR_DIR_CLOSED')) throw error;
|
||||
});
|
||||
}
|
||||
return { shard, entries, overflow };
|
||||
}
|
||||
|
||||
async quarantine(
|
||||
entry: CompletionReceiptDirectoryEntry,
|
||||
): Promise<CompletionReceiptOrphanQuarantineResult> {
|
||||
if (
|
||||
!SHARD_PATTERN.test(entry.shard) ||
|
||||
path.basename(entry.name) !== entry.name
|
||||
) {
|
||||
throw new RangeError('Completion receipt orphan entry path is invalid');
|
||||
}
|
||||
if (entry.kind === 'unsafe') return { status: 'changed' };
|
||||
const shardDirectory = await this.resolveShardDirectory(entry.shard);
|
||||
if (!shardDirectory) return { status: 'changed' };
|
||||
const source = path.join(shardDirectory, entry.name);
|
||||
let current: Awaited<ReturnType<typeof fs.lstat>>;
|
||||
try {
|
||||
current = await fs.lstat(source);
|
||||
} catch (error) {
|
||||
if (isCode(error, 'ENOENT')) return { status: 'changed' };
|
||||
throw error;
|
||||
}
|
||||
if (
|
||||
!current.isFile() ||
|
||||
filesystemIdentity(current) !== entry.filesystemIdentity
|
||||
) {
|
||||
return { status: 'changed' };
|
||||
}
|
||||
|
||||
const digest = createHash('sha256')
|
||||
.update(`${entry.shard}/${entry.name}\0${entry.filesystemIdentity}`)
|
||||
.digest('hex');
|
||||
const reference = path.posix.join(
|
||||
'.orphan-quarantine',
|
||||
entry.shard,
|
||||
`${digest}.entry`,
|
||||
);
|
||||
const canonicalRoot = path.dirname(shardDirectory);
|
||||
const directory = await this.ensureQuarantineDirectory(
|
||||
canonicalRoot,
|
||||
entry.shard,
|
||||
);
|
||||
const target = path.join(directory, `${digest}.entry`);
|
||||
let linkedByThisCall = false;
|
||||
try {
|
||||
await fs.link(source, target);
|
||||
linkedByThisCall = true;
|
||||
} catch (error) {
|
||||
if (!isCode(error, 'EEXIST')) {
|
||||
if (isCode(error, 'ENOENT')) return { status: 'changed' };
|
||||
throw error;
|
||||
}
|
||||
const targetStat = await fs.lstat(target);
|
||||
const sourceStat = await fs.lstat(source).catch(() => undefined);
|
||||
if (
|
||||
!sourceStat ||
|
||||
targetStat.dev !== sourceStat.dev ||
|
||||
targetStat.ino !== sourceStat.ino
|
||||
) {
|
||||
return { status: 'changed' };
|
||||
}
|
||||
}
|
||||
let verifiedShardDirectory: string | undefined;
|
||||
try {
|
||||
verifiedShardDirectory = await this.resolveShardDirectory(entry.shard);
|
||||
} catch (error) {
|
||||
if (linkedByThisCall) await fs.unlink(target).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
const verifiedSource = await fs.lstat(source).catch(() => undefined);
|
||||
if (
|
||||
verifiedShardDirectory !== shardDirectory ||
|
||||
!verifiedSource ||
|
||||
filesystemIdentity(verifiedSource) !== entry.filesystemIdentity
|
||||
) {
|
||||
if (linkedByThisCall) await fs.unlink(target).catch(() => undefined);
|
||||
return { status: 'changed' };
|
||||
}
|
||||
await fs.unlink(source);
|
||||
await this.bestEffortSync(path.dirname(source));
|
||||
await this.bestEffortSync(directory);
|
||||
return { status: 'quarantined', reference };
|
||||
}
|
||||
|
||||
private async resolveShardDirectory(
|
||||
shard: string,
|
||||
): Promise<string | undefined> {
|
||||
let canonicalRoot: string;
|
||||
try {
|
||||
canonicalRoot = await fs.realpath(this.root);
|
||||
} catch (error) {
|
||||
if (isCode(error, 'ENOENT')) return undefined;
|
||||
throw error;
|
||||
}
|
||||
const candidate = path.join(canonicalRoot, shard);
|
||||
let stat: Awaited<ReturnType<typeof fs.lstat>>;
|
||||
try {
|
||||
stat = await fs.lstat(candidate);
|
||||
} catch (error) {
|
||||
if (isCode(error, 'ENOENT')) return undefined;
|
||||
throw error;
|
||||
}
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
||||
throw new Error(
|
||||
`Completion receipt shard ${shard} is not a safe directory`,
|
||||
);
|
||||
}
|
||||
const canonicalDirectory = await fs.realpath(candidate);
|
||||
if (
|
||||
path.dirname(canonicalDirectory) !== canonicalRoot ||
|
||||
path.basename(canonicalDirectory) !== shard
|
||||
) {
|
||||
throw new Error(`Completion receipt shard ${shard} escapes its root`);
|
||||
}
|
||||
return canonicalDirectory;
|
||||
}
|
||||
|
||||
private async ensureQuarantineDirectory(
|
||||
canonicalRoot: string,
|
||||
shard: string,
|
||||
): Promise<string> {
|
||||
const quarantineRoot = path.join(canonicalRoot, '.orphan-quarantine');
|
||||
await this.ensurePrivateDirectory(quarantineRoot, canonicalRoot);
|
||||
const directory = path.join(quarantineRoot, shard);
|
||||
await this.ensurePrivateDirectory(directory, quarantineRoot);
|
||||
return directory;
|
||||
}
|
||||
|
||||
private async ensurePrivateDirectory(
|
||||
directory: string,
|
||||
expectedParent: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await fs.mkdir(directory, { mode: 0o700 });
|
||||
} catch (error) {
|
||||
if (!isCode(error, 'EEXIST')) throw error;
|
||||
}
|
||||
const stat = await fs.lstat(directory);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
||||
throw new Error(
|
||||
'Completion receipt quarantine path is not a safe directory',
|
||||
);
|
||||
}
|
||||
const canonicalDirectory = await fs.realpath(directory);
|
||||
if (path.dirname(canonicalDirectory) !== expectedParent) {
|
||||
throw new Error('Completion receipt quarantine path escapes its root');
|
||||
}
|
||||
await fs.chmod(directory, 0o700);
|
||||
}
|
||||
|
||||
private async bestEffortSync(directory: string): Promise<void> {
|
||||
try {
|
||||
const handle = await fs.open(directory, constants.O_RDONLY);
|
||||
try {
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
} catch {
|
||||
// Directory fsync is unavailable on some supported filesystems.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { constants } from 'fs';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { normalizeLocalArtifactReadRange } from '../../domain/artifactRead';
|
||||
import { assertLocalExecutionArtifactId } from '../../domain/localExecutionArtifact';
|
||||
import type {
|
||||
AvailableLocalArtifactByteRange,
|
||||
LocalArtifactByteRangeReadResult,
|
||||
LocalArtifactByteRangeReader as LocalArtifactByteRangeReaderPort,
|
||||
} from '../../ports/localArtifactByteRangeReader';
|
||||
|
||||
export class UnsafeLocalArtifactReadTargetError extends Error {
|
||||
constructor() {
|
||||
super('Local Artifact read target is unsafe');
|
||||
this.name = 'UnsafeLocalArtifactReadTargetError';
|
||||
}
|
||||
}
|
||||
|
||||
function isCode(error: unknown, code: string): boolean {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'code' in error &&
|
||||
(error as { code?: string }).code === code
|
||||
);
|
||||
}
|
||||
|
||||
export class LocalArtifactByteRangeReader
|
||||
implements LocalArtifactByteRangeReaderPort
|
||||
{
|
||||
private readonly root: string;
|
||||
|
||||
constructor(root: string) {
|
||||
if (!path.isAbsolute(root) || root.includes('\0')) {
|
||||
throw new TypeError('Local Artifact read root must be absolute');
|
||||
}
|
||||
this.root = path.resolve(root);
|
||||
}
|
||||
|
||||
async read(
|
||||
logArtifactId: string,
|
||||
requestedRange: Parameters<LocalArtifactByteRangeReaderPort['read']>[1],
|
||||
): Promise<LocalArtifactByteRangeReadResult> {
|
||||
assertLocalExecutionArtifactId(logArtifactId);
|
||||
const range = normalizeLocalArtifactReadRange(requestedRange);
|
||||
const directory = path.join(this.root, logArtifactId.slice(6, 8));
|
||||
await this.assertDirectory(this.root);
|
||||
if (!(await this.optionalDirectory(directory)))
|
||||
return { status: 'missing' };
|
||||
const target = path.join(directory, `${logArtifactId}.log`);
|
||||
let handle;
|
||||
try {
|
||||
handle = await fs.open(
|
||||
target,
|
||||
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
} catch (error) {
|
||||
if (isCode(error, 'ENOENT')) return { status: 'missing' };
|
||||
throw new UnsafeLocalArtifactReadTargetError();
|
||||
}
|
||||
try {
|
||||
const stat = await handle.stat();
|
||||
if (!stat.isFile() || !Number.isSafeInteger(stat.size) || stat.size < 0) {
|
||||
throw new UnsafeLocalArtifactReadTargetError();
|
||||
}
|
||||
const start = Math.min(range.offset, stat.size);
|
||||
const expected = Math.min(range.length, stat.size - start);
|
||||
const content = Buffer.allocUnsafe(expected);
|
||||
let read = 0;
|
||||
while (read < expected) {
|
||||
const result = await handle.read(
|
||||
content,
|
||||
read,
|
||||
expected - read,
|
||||
start + read,
|
||||
);
|
||||
if (result.bytesRead < 1) {
|
||||
throw new UnsafeLocalArtifactReadTargetError();
|
||||
}
|
||||
read += result.bytesRead;
|
||||
}
|
||||
const endExclusive = start + expected;
|
||||
const result: AvailableLocalArtifactByteRange = {
|
||||
status: 'available',
|
||||
content,
|
||||
start,
|
||||
endExclusive,
|
||||
totalBytes: stat.size,
|
||||
...(endExclusive < stat.size ? { nextOffset: endExclusive } : {}),
|
||||
};
|
||||
return Object.freeze(result);
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
private async assertDirectory(value: string): Promise<void> {
|
||||
try {
|
||||
const stat = await fs.lstat(value);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
||||
throw new UnsafeLocalArtifactReadTargetError();
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof UnsafeLocalArtifactReadTargetError) throw error;
|
||||
throw new UnsafeLocalArtifactReadTargetError();
|
||||
}
|
||||
}
|
||||
|
||||
private async optionalDirectory(value: string): Promise<boolean> {
|
||||
try {
|
||||
const stat = await fs.lstat(value);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
||||
throw new UnsafeLocalArtifactReadTargetError();
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isCode(error, 'ENOENT')) return false;
|
||||
if (error instanceof UnsafeLocalArtifactReadTargetError) throw error;
|
||||
throw new UnsafeLocalArtifactReadTargetError();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { constants, type Stats } from 'fs';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { MAX_LOCAL_ARTIFACT_TRUNCATION_FACT_BYTES } from '../../domain/localArtifactTruncation';
|
||||
import { assertLocalExecutionArtifactId } from '../../domain/localExecutionArtifact';
|
||||
import type {
|
||||
LocalArtifactFileRetirementResult,
|
||||
LocalArtifactFileRetirementStore as LocalArtifactFileRetirementStorePort,
|
||||
} from '../../ports/localArtifactFileRetirementStore';
|
||||
import { localArtifactTruncationFactFileName } from './localArtifactTruncationFactStore';
|
||||
|
||||
export class UnsafeLocalArtifactRetirementError extends Error {
|
||||
constructor() {
|
||||
super('Local Artifact retirement target is unsafe');
|
||||
this.name = 'UnsafeLocalArtifactRetirementError';
|
||||
}
|
||||
}
|
||||
|
||||
function isCode(error: unknown, code: string): boolean {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'code' in error &&
|
||||
(error as { code?: string }).code === code
|
||||
);
|
||||
}
|
||||
|
||||
export class LocalArtifactFileRetirementStore
|
||||
implements LocalArtifactFileRetirementStorePort
|
||||
{
|
||||
private readonly root: string;
|
||||
|
||||
constructor(root: string) {
|
||||
if (!path.isAbsolute(root) || root.includes('\0')) {
|
||||
throw new TypeError('Local Artifact retirement root must be absolute');
|
||||
}
|
||||
this.root = path.resolve(root);
|
||||
}
|
||||
|
||||
async retire(
|
||||
logArtifactId: string,
|
||||
): Promise<LocalArtifactFileRetirementResult> {
|
||||
assertLocalExecutionArtifactId(logArtifactId);
|
||||
const directory = path.join(this.root, logArtifactId.slice(6, 8));
|
||||
await this.assertDirectory(this.root);
|
||||
if (!(await this.optionalDirectory(directory))) {
|
||||
return Object.freeze({
|
||||
disposition: 'already_absent',
|
||||
bytesReclaimed: 0,
|
||||
});
|
||||
}
|
||||
const target = path.join(directory, `${logArtifactId}.log`);
|
||||
const fifo = path.join(directory, `.${logArtifactId}.log.fifo`);
|
||||
const truncation = path.join(
|
||||
directory,
|
||||
localArtifactTruncationFactFileName(logArtifactId),
|
||||
);
|
||||
const truncationTemporary = path.join(
|
||||
directory,
|
||||
`.${logArtifactId}.log.truncated.tmp`,
|
||||
);
|
||||
const [targetStat, fifoStat, truncationStat, truncationTemporaryStat] =
|
||||
await Promise.all([
|
||||
this.lstat(target),
|
||||
this.lstat(fifo),
|
||||
this.lstat(truncation),
|
||||
this.lstat(truncationTemporary),
|
||||
]);
|
||||
if (targetStat && (!targetStat.isFile() || targetStat.isSymbolicLink())) {
|
||||
throw new UnsafeLocalArtifactRetirementError();
|
||||
}
|
||||
if (
|
||||
targetStat &&
|
||||
(!Number.isSafeInteger(targetStat.size) || targetStat.size < 0)
|
||||
) {
|
||||
throw new UnsafeLocalArtifactRetirementError();
|
||||
}
|
||||
if (fifoStat && (!fifoStat.isFIFO() || fifoStat.isSymbolicLink())) {
|
||||
throw new UnsafeLocalArtifactRetirementError();
|
||||
}
|
||||
for (const stat of [truncationStat, truncationTemporaryStat]) {
|
||||
if (
|
||||
stat &&
|
||||
(!stat.isFile() ||
|
||||
stat.isSymbolicLink() ||
|
||||
!Number.isSafeInteger(stat.size) ||
|
||||
stat.size < 0 ||
|
||||
stat.size > MAX_LOCAL_ARTIFACT_TRUNCATION_FACT_BYTES)
|
||||
) {
|
||||
throw new UnsafeLocalArtifactRetirementError();
|
||||
}
|
||||
}
|
||||
let targetDeleted = false;
|
||||
let bytesReclaimed = 0;
|
||||
if (targetStat) {
|
||||
try {
|
||||
await fs.unlink(target);
|
||||
targetDeleted = true;
|
||||
bytesReclaimed = targetStat.size;
|
||||
} catch (error) {
|
||||
if (!isCode(error, 'ENOENT')) throw error;
|
||||
}
|
||||
}
|
||||
let auxiliaryRemoved = false;
|
||||
for (const [auxiliary, stat] of [
|
||||
[fifo, fifoStat],
|
||||
[truncation, truncationStat],
|
||||
[truncationTemporary, truncationTemporaryStat],
|
||||
] as const) {
|
||||
if (!stat) continue;
|
||||
try {
|
||||
await fs.unlink(auxiliary);
|
||||
auxiliaryRemoved = true;
|
||||
} catch (error) {
|
||||
if (!isCode(error, 'ENOENT')) throw error;
|
||||
}
|
||||
}
|
||||
if (targetDeleted || auxiliaryRemoved) {
|
||||
await this.syncDirectory(directory);
|
||||
}
|
||||
if (!targetDeleted) {
|
||||
return Object.freeze({
|
||||
disposition: 'already_absent',
|
||||
bytesReclaimed: 0,
|
||||
});
|
||||
}
|
||||
return Object.freeze({
|
||||
disposition: 'deleted',
|
||||
bytesReclaimed,
|
||||
});
|
||||
}
|
||||
|
||||
private async assertDirectory(value: string): Promise<void> {
|
||||
try {
|
||||
const stat = await fs.lstat(value);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
||||
throw new UnsafeLocalArtifactRetirementError();
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof UnsafeLocalArtifactRetirementError) throw error;
|
||||
throw new UnsafeLocalArtifactRetirementError();
|
||||
}
|
||||
}
|
||||
|
||||
private async optionalDirectory(value: string): Promise<boolean> {
|
||||
try {
|
||||
const stat = await fs.lstat(value);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
||||
throw new UnsafeLocalArtifactRetirementError();
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isCode(error, 'ENOENT')) return false;
|
||||
if (error instanceof UnsafeLocalArtifactRetirementError) throw error;
|
||||
throw new UnsafeLocalArtifactRetirementError();
|
||||
}
|
||||
}
|
||||
|
||||
private async lstat(value: string): Promise<Stats | null> {
|
||||
try {
|
||||
return await fs.lstat(value);
|
||||
} catch (error) {
|
||||
if (isCode(error, 'ENOENT')) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async syncDirectory(directory: string): Promise<void> {
|
||||
const handle = await fs.open(
|
||||
directory,
|
||||
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
try {
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { constants } from 'fs';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import {
|
||||
decodeLocalArtifactTruncationFact,
|
||||
MAX_LOCAL_ARTIFACT_TRUNCATION_FACT_BYTES,
|
||||
type LocalArtifactTruncationFact,
|
||||
} from '../../domain/localArtifactTruncation';
|
||||
import { assertLocalExecutionArtifactId } from '../../domain/localExecutionArtifact';
|
||||
import type { LocalArtifactTruncationFactStore as LocalArtifactTruncationFactStorePort } from '../../ports/localArtifactTruncationFactStore';
|
||||
|
||||
export function localArtifactTruncationFactFileName(
|
||||
logArtifactId: string,
|
||||
): string {
|
||||
assertLocalExecutionArtifactId(logArtifactId);
|
||||
return `.${logArtifactId}.log.truncated.json`;
|
||||
}
|
||||
|
||||
export class UnsafeLocalArtifactTruncationFactError extends Error {
|
||||
constructor() {
|
||||
super('Local Artifact truncation fact target is unsafe');
|
||||
this.name = 'UnsafeLocalArtifactTruncationFactError';
|
||||
}
|
||||
}
|
||||
|
||||
function isCode(error: unknown, code: string): boolean {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'code' in error &&
|
||||
(error as { code?: string }).code === code
|
||||
);
|
||||
}
|
||||
|
||||
export class LocalArtifactTruncationFactStore
|
||||
implements LocalArtifactTruncationFactStorePort
|
||||
{
|
||||
private readonly root: string;
|
||||
|
||||
constructor(root: string) {
|
||||
if (!path.isAbsolute(root) || root.includes('\0')) {
|
||||
throw new TypeError('Local Artifact truncation root must be absolute');
|
||||
}
|
||||
this.root = path.resolve(root);
|
||||
}
|
||||
|
||||
async read(
|
||||
logArtifactId: string,
|
||||
): Promise<Readonly<LocalArtifactTruncationFact> | null> {
|
||||
assertLocalExecutionArtifactId(logArtifactId);
|
||||
const directory = path.join(this.root, logArtifactId.slice(6, 8));
|
||||
await this.assertDirectory(this.root);
|
||||
if (!(await this.optionalDirectory(directory))) return null;
|
||||
const target = path.join(
|
||||
directory,
|
||||
localArtifactTruncationFactFileName(logArtifactId),
|
||||
);
|
||||
let handle;
|
||||
try {
|
||||
handle = await fs.open(
|
||||
target,
|
||||
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
} catch (error) {
|
||||
if (isCode(error, 'ENOENT')) return null;
|
||||
throw new UnsafeLocalArtifactTruncationFactError();
|
||||
}
|
||||
try {
|
||||
const stat = await handle.stat();
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
!Number.isSafeInteger(stat.size) ||
|
||||
stat.size < 1 ||
|
||||
stat.size > MAX_LOCAL_ARTIFACT_TRUNCATION_FACT_BYTES
|
||||
) {
|
||||
throw new UnsafeLocalArtifactTruncationFactError();
|
||||
}
|
||||
const fact = decodeLocalArtifactTruncationFact(await handle.readFile());
|
||||
if (fact.logArtifactId !== logArtifactId) {
|
||||
throw new UnsafeLocalArtifactTruncationFactError();
|
||||
}
|
||||
return fact;
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
private async assertDirectory(value: string): Promise<void> {
|
||||
try {
|
||||
const stat = await fs.lstat(value);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
||||
throw new UnsafeLocalArtifactTruncationFactError();
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof UnsafeLocalArtifactTruncationFactError) throw error;
|
||||
throw new UnsafeLocalArtifactTruncationFactError();
|
||||
}
|
||||
}
|
||||
|
||||
private async optionalDirectory(value: string): Promise<boolean> {
|
||||
try {
|
||||
const stat = await fs.lstat(value);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
||||
throw new UnsafeLocalArtifactTruncationFactError();
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isCode(error, 'ENOENT')) return false;
|
||||
if (error instanceof UnsafeLocalArtifactTruncationFactError) throw error;
|
||||
throw new UnsafeLocalArtifactTruncationFactError();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { constants } from 'fs';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import type {
|
||||
ExecutionOutputChunk,
|
||||
ExecutionOutputSink,
|
||||
} from '../../domain/execution';
|
||||
import {
|
||||
LocalArtifactCapacityUnavailableError,
|
||||
LocalArtifactQuotaExceededError,
|
||||
normalizeLocalArtifactCapacityPolicy,
|
||||
type LocalArtifactCapacityPolicy,
|
||||
} from '../../domain/localArtifactCapacity';
|
||||
import {
|
||||
localExecutionArtifactId,
|
||||
assertLocalExecutionArtifactId,
|
||||
} from '../../domain/localExecutionArtifact';
|
||||
import type { RunDispatchCandidate } from '../../domain/runDispatchCandidate';
|
||||
import type {
|
||||
LocalExecutionArtifactAllocator,
|
||||
PreparedLocalExecutionArtifact,
|
||||
} from '../../ports/localExecutionArtifactAllocator';
|
||||
import type { LocalArtifactCapacityProbe } from '../../ports/localArtifactCapacityProbe';
|
||||
import { LocalFileSystemCapacityProbe } from './localFileSystemCapacityProbe';
|
||||
import { enableDurableLocalProcessOutput } from '../local-process/durableLocalProcessOutput';
|
||||
|
||||
async function privateDirectory(directory: string): Promise<void> {
|
||||
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
||||
const stat = await fs.lstat(directory);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
||||
throw new TypeError('Local execution artifact directory is unsafe');
|
||||
}
|
||||
await fs.chmod(directory, 0o700);
|
||||
}
|
||||
|
||||
class LocalFileExecutionOutput implements ExecutionOutputSink {
|
||||
private pending: Promise<unknown> = Promise.resolve();
|
||||
private closed = false;
|
||||
|
||||
private remainingBytes: number;
|
||||
|
||||
constructor(
|
||||
private readonly file: fs.FileHandle,
|
||||
maximumBytes: number,
|
||||
existingBytes: number,
|
||||
) {
|
||||
this.remainingBytes = maximumBytes - existingBytes;
|
||||
}
|
||||
|
||||
write(output: ExecutionOutputChunk): Promise<void> {
|
||||
if (this.closed) {
|
||||
return Promise.reject(new Error('Local execution artifact is closed'));
|
||||
}
|
||||
const chunk = Buffer.from(output.chunk);
|
||||
const operation = this.pending.then(async () => {
|
||||
if (this.remainingBytes <= 0) {
|
||||
throw new LocalArtifactQuotaExceededError();
|
||||
}
|
||||
const accepted = chunk.subarray(
|
||||
0,
|
||||
Math.min(chunk.length, this.remainingBytes),
|
||||
);
|
||||
let written = 0;
|
||||
while (written < accepted.length) {
|
||||
const result = await this.file.write(accepted.subarray(written));
|
||||
if (result.bytesWritten < 1) {
|
||||
throw new Error('Local execution artifact write made no progress');
|
||||
}
|
||||
written += result.bytesWritten;
|
||||
this.remainingBytes -= result.bytesWritten;
|
||||
}
|
||||
if (accepted.length !== chunk.length) {
|
||||
throw new LocalArtifactQuotaExceededError();
|
||||
}
|
||||
});
|
||||
this.pending = operation.catch(() => undefined);
|
||||
return operation;
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
await this.pending.catch(() => undefined);
|
||||
await this.file.close();
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalFileExecutionArtifactAllocator
|
||||
implements LocalExecutionArtifactAllocator
|
||||
{
|
||||
private readonly artifactRoot: string;
|
||||
private readonly completionReceiptRoot: string;
|
||||
private readonly policy: Readonly<LocalArtifactCapacityPolicy>;
|
||||
private readonly capacity: LocalArtifactCapacityProbe;
|
||||
|
||||
constructor(
|
||||
artifactRoot: string,
|
||||
completionReceiptRoot: string,
|
||||
policy: LocalArtifactCapacityPolicy,
|
||||
capacity: LocalArtifactCapacityProbe = new LocalFileSystemCapacityProbe(),
|
||||
) {
|
||||
if (
|
||||
!path.isAbsolute(artifactRoot) ||
|
||||
artifactRoot.includes('\0') ||
|
||||
!path.isAbsolute(completionReceiptRoot) ||
|
||||
completionReceiptRoot.includes('\0')
|
||||
) {
|
||||
throw new TypeError('Local execution artifact roots must be absolute');
|
||||
}
|
||||
this.artifactRoot = path.resolve(artifactRoot);
|
||||
this.completionReceiptRoot = path.resolve(completionReceiptRoot);
|
||||
this.policy = normalizeLocalArtifactCapacityPolicy(policy);
|
||||
this.capacity = capacity;
|
||||
}
|
||||
|
||||
async prepare(
|
||||
candidate: Readonly<RunDispatchCandidate>,
|
||||
): Promise<PreparedLocalExecutionArtifact> {
|
||||
const logArtifactId = localExecutionArtifactId(candidate);
|
||||
assertLocalExecutionArtifactId(logArtifactId);
|
||||
const shard = logArtifactId.slice('local-'.length, 'local-'.length + 2);
|
||||
const directory = path.join(this.artifactRoot, shard);
|
||||
await privateDirectory(this.artifactRoot);
|
||||
const capacity = await this.capacity.inspect(this.artifactRoot);
|
||||
const requiredBytes =
|
||||
BigInt(this.policy.minimumFreeBytes) +
|
||||
BigInt(this.policy.maximumAttemptBytes);
|
||||
if (capacity.availableBytes < requiredBytes) {
|
||||
throw new LocalArtifactCapacityUnavailableError();
|
||||
}
|
||||
await privateDirectory(directory);
|
||||
await privateDirectory(this.completionReceiptRoot);
|
||||
const outputFilePath = path.join(directory, `${logArtifactId}.log`);
|
||||
const file = await fs.open(
|
||||
outputFilePath,
|
||||
constants.O_WRONLY |
|
||||
constants.O_CREAT |
|
||||
constants.O_APPEND |
|
||||
(constants.O_NOFOLLOW ?? 0),
|
||||
0o600,
|
||||
);
|
||||
try {
|
||||
const stat = await file.stat();
|
||||
if (!stat.isFile()) {
|
||||
throw new TypeError('Local execution artifact target is unsafe');
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(stat.size) ||
|
||||
stat.size < 0 ||
|
||||
stat.size > this.policy.maximumAttemptBytes
|
||||
) {
|
||||
throw new LocalArtifactQuotaExceededError();
|
||||
}
|
||||
await file.chmod(0o600);
|
||||
const output = new LocalFileExecutionOutput(
|
||||
file,
|
||||
this.policy.maximumAttemptBytes,
|
||||
stat.size,
|
||||
);
|
||||
return {
|
||||
logArtifactId,
|
||||
output: enableDurableLocalProcessOutput(output, {
|
||||
outputFilePath,
|
||||
completionReceiptRoot: this.completionReceiptRoot,
|
||||
maximumBytes: this.policy.maximumAttemptBytes,
|
||||
logArtifactId,
|
||||
}),
|
||||
dispose: () => output.close(),
|
||||
};
|
||||
} catch (error) {
|
||||
await file.close().catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { LocalArtifactCapacityUnavailableError } from '../../domain/localArtifactCapacity';
|
||||
import type {
|
||||
LocalArtifactCapacityProbe,
|
||||
LocalArtifactCapacitySnapshot,
|
||||
LocalArtifactCapacitySource,
|
||||
} from '../../ports/localArtifactCapacityProbe';
|
||||
|
||||
interface BigIntStatFs {
|
||||
bavail: bigint;
|
||||
blocks: bigint;
|
||||
bsize: bigint;
|
||||
}
|
||||
|
||||
export class RootedLocalFileSystemCapacitySource
|
||||
implements LocalArtifactCapacitySource
|
||||
{
|
||||
private readonly root: string;
|
||||
|
||||
constructor(
|
||||
root: string,
|
||||
private readonly probe: LocalArtifactCapacityProbe = new LocalFileSystemCapacityProbe(),
|
||||
) {
|
||||
if (!path.isAbsolute(root) || root.includes('\0')) {
|
||||
throw new TypeError('Local Artifact capacity root must be absolute');
|
||||
}
|
||||
this.root = path.resolve(root);
|
||||
}
|
||||
|
||||
inspect(): Promise<LocalArtifactCapacitySnapshot> {
|
||||
return this.probe.inspect(this.root);
|
||||
}
|
||||
}
|
||||
|
||||
interface StatFsPromises {
|
||||
statfs(value: string, options: { bigint: true }): Promise<BigIntStatFs>;
|
||||
}
|
||||
|
||||
export class LocalFileSystemCapacityProbe
|
||||
implements LocalArtifactCapacityProbe
|
||||
{
|
||||
async inspect(root: string): Promise<LocalArtifactCapacitySnapshot> {
|
||||
if (!path.isAbsolute(root) || root.includes('\0')) {
|
||||
throw new LocalArtifactCapacityUnavailableError();
|
||||
}
|
||||
try {
|
||||
const statfs = (fs as unknown as StatFsPromises).statfs;
|
||||
if (typeof statfs !== 'function') {
|
||||
throw new LocalArtifactCapacityUnavailableError();
|
||||
}
|
||||
const stat = await statfs.call(fs, root, { bigint: true });
|
||||
const availableBytes = stat.bavail * stat.bsize;
|
||||
const totalBytes = stat.blocks * stat.bsize;
|
||||
if (
|
||||
availableBytes < BigInt(0) ||
|
||||
totalBytes < BigInt(1) ||
|
||||
availableBytes > totalBytes
|
||||
) {
|
||||
throw new LocalArtifactCapacityUnavailableError();
|
||||
}
|
||||
return Object.freeze({ availableBytes, totalBytes });
|
||||
} catch (error) {
|
||||
if (error instanceof LocalArtifactCapacityUnavailableError) throw error;
|
||||
throw new LocalArtifactCapacityUnavailableError();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { constants } from 'fs';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import {
|
||||
LocalSecretUnavailableError,
|
||||
assertLocalSecretKeyId,
|
||||
} from '../../domain/localSecret';
|
||||
import type {
|
||||
LocalSecretKeyMaterial,
|
||||
LocalSecretKeyProvider,
|
||||
} from '../../ports/localSecretKeyProvider';
|
||||
|
||||
const MAX_KEYRING_BYTES = 16 * 1024;
|
||||
const MAX_KEY_COUNT = 16;
|
||||
const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/;
|
||||
|
||||
interface LocalSecretKeyringManifest {
|
||||
version: 1;
|
||||
activeKeyId: string;
|
||||
keys: Record<string, string>;
|
||||
}
|
||||
|
||||
function exactKeys(value: object, expected: readonly string[]): boolean {
|
||||
const actual = Object.keys(value).sort();
|
||||
return (
|
||||
actual.length === expected.length &&
|
||||
actual.every((key, index) => key === expected[index])
|
||||
);
|
||||
}
|
||||
|
||||
function parseManifest(value: Buffer): LocalSecretKeyringManifest {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(value.toString('utf8'));
|
||||
} catch {
|
||||
throw new LocalSecretUnavailableError();
|
||||
}
|
||||
if (
|
||||
!parsed ||
|
||||
typeof parsed !== 'object' ||
|
||||
Array.isArray(parsed) ||
|
||||
!exactKeys(parsed, ['activeKeyId', 'keys', 'version'])
|
||||
) {
|
||||
throw new LocalSecretUnavailableError();
|
||||
}
|
||||
const record = parsed as Record<string, unknown>;
|
||||
if (
|
||||
record.version !== 1 ||
|
||||
!record.keys ||
|
||||
typeof record.keys !== 'object' ||
|
||||
Array.isArray(record.keys)
|
||||
) {
|
||||
throw new LocalSecretUnavailableError();
|
||||
}
|
||||
assertLocalSecretKeyId(record.activeKeyId as string);
|
||||
const entries = Object.entries(record.keys as Record<string, unknown>);
|
||||
if (entries.length < 1 || entries.length > MAX_KEY_COUNT) {
|
||||
throw new LocalSecretUnavailableError();
|
||||
}
|
||||
const keys: Record<string, string> = Object.create(null);
|
||||
for (const [keyId, encoded] of entries) {
|
||||
assertLocalSecretKeyId(keyId);
|
||||
const decoded =
|
||||
typeof encoded === 'string'
|
||||
? Buffer.from(encoded, 'base64url')
|
||||
: Buffer.alloc(0);
|
||||
if (
|
||||
typeof encoded !== 'string' ||
|
||||
!BASE64URL_PATTERN.test(encoded) ||
|
||||
decoded.length !== 32 ||
|
||||
decoded.toString('base64url') !== encoded
|
||||
) {
|
||||
decoded.fill(0);
|
||||
throw new LocalSecretUnavailableError();
|
||||
}
|
||||
decoded.fill(0);
|
||||
keys[keyId] = encoded;
|
||||
}
|
||||
if (!keys[record.activeKeyId as string]) {
|
||||
throw new LocalSecretUnavailableError();
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
activeKeyId: record.activeKeyId as string,
|
||||
keys,
|
||||
};
|
||||
}
|
||||
|
||||
export class LocalSecretKeyringFileProvider implements LocalSecretKeyProvider {
|
||||
private readonly filePath: string;
|
||||
|
||||
constructor(filePath: string) {
|
||||
if (!path.isAbsolute(filePath) || filePath.includes('\0')) {
|
||||
throw new TypeError('Local Secret keyring path must be absolute');
|
||||
}
|
||||
this.filePath = path.resolve(filePath);
|
||||
}
|
||||
|
||||
async active(): Promise<LocalSecretKeyMaterial> {
|
||||
const manifest = await this.read();
|
||||
return this.material(
|
||||
manifest,
|
||||
manifest.activeKeyId,
|
||||
) as LocalSecretKeyMaterial;
|
||||
}
|
||||
|
||||
async resolve(keyId: string): Promise<LocalSecretKeyMaterial | null> {
|
||||
try {
|
||||
assertLocalSecretKeyId(keyId);
|
||||
} catch {
|
||||
throw new LocalSecretUnavailableError();
|
||||
}
|
||||
const manifest = await this.read();
|
||||
return this.material(manifest, keyId);
|
||||
}
|
||||
|
||||
private material(
|
||||
manifest: LocalSecretKeyringManifest,
|
||||
keyId: string,
|
||||
): LocalSecretKeyMaterial | null {
|
||||
const encoded = manifest.keys[keyId];
|
||||
return encoded
|
||||
? Object.freeze({
|
||||
keyId,
|
||||
key: Uint8Array.from(Buffer.from(encoded, 'base64url')),
|
||||
})
|
||||
: null;
|
||||
}
|
||||
|
||||
private async read(): Promise<LocalSecretKeyringManifest> {
|
||||
let file: fs.FileHandle | undefined;
|
||||
let contents: Buffer | undefined;
|
||||
try {
|
||||
file = await fs.open(
|
||||
this.filePath,
|
||||
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
const stat = await file.stat();
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
stat.isSymbolicLink() ||
|
||||
(stat.mode & 0o077) !== 0 ||
|
||||
stat.size < 1 ||
|
||||
stat.size > MAX_KEYRING_BYTES
|
||||
) {
|
||||
throw new LocalSecretUnavailableError();
|
||||
}
|
||||
contents = await file.readFile();
|
||||
if (contents.length !== stat.size) {
|
||||
throw new LocalSecretUnavailableError();
|
||||
}
|
||||
return parseManifest(contents);
|
||||
} catch {
|
||||
throw new LocalSecretUnavailableError();
|
||||
} finally {
|
||||
contents?.fill(0);
|
||||
await file?.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { createHash } from 'crypto';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import {
|
||||
defaultOffRuntimeRolloutPolicy,
|
||||
parseRuntimeRolloutManifest,
|
||||
} from '../../domain/runtimeRolloutManifest';
|
||||
import type {
|
||||
RuntimeRolloutLoadAudit,
|
||||
RuntimeRolloutLoadResult,
|
||||
} from '../../ports/runtimeRolloutLoader';
|
||||
|
||||
export type {
|
||||
RuntimeRolloutLoadAudit,
|
||||
RuntimeRolloutLoadResult,
|
||||
RuntimeRolloutLoadStatus,
|
||||
} from '../../ports/runtimeRolloutLoader';
|
||||
|
||||
export const MAX_RUNTIME_ROLLOUT_MANIFEST_BYTES = 64 * 1024;
|
||||
|
||||
export interface RuntimeRolloutManifestLoaderOptions {
|
||||
clock?: { now(): number };
|
||||
maxBytes?: number;
|
||||
}
|
||||
|
||||
function rejected(
|
||||
audit: RuntimeRolloutLoadAudit,
|
||||
reasonCode: NonNullable<RuntimeRolloutLoadAudit['reasonCode']>,
|
||||
): RuntimeRolloutLoadResult {
|
||||
return {
|
||||
status: 'rejected',
|
||||
policy: defaultOffRuntimeRolloutPolicy(),
|
||||
audit: { ...audit, status: 'rejected', reasonCode },
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadRuntimeRolloutManifest(
|
||||
sourcePath: string,
|
||||
options: RuntimeRolloutManifestLoaderOptions = {},
|
||||
): Promise<RuntimeRolloutLoadResult> {
|
||||
if (!path.isAbsolute(sourcePath)) {
|
||||
throw new TypeError('Runtime rollout manifest path must be absolute');
|
||||
}
|
||||
const evaluatedAtMs = (options.clock ?? { now: Date.now }).now();
|
||||
if (!Number.isSafeInteger(evaluatedAtMs) || evaluatedAtMs < 0) {
|
||||
throw new TypeError('Runtime rollout clock returned an invalid timestamp');
|
||||
}
|
||||
const maxBytes = options.maxBytes ?? MAX_RUNTIME_ROLLOUT_MANIFEST_BYTES;
|
||||
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) {
|
||||
throw new TypeError('Runtime rollout maxBytes must be a positive integer');
|
||||
}
|
||||
const baseAudit: RuntimeRolloutLoadAudit = {
|
||||
event: 'runtime.rollout_config_evaluated',
|
||||
evaluatedAtMs,
|
||||
sourcePath,
|
||||
status: 'rejected',
|
||||
};
|
||||
|
||||
let bytes: Buffer;
|
||||
try {
|
||||
bytes = await fs.readFile(sourcePath);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return {
|
||||
status: 'missing',
|
||||
policy: defaultOffRuntimeRolloutPolicy(),
|
||||
audit: {
|
||||
...baseAudit,
|
||||
status: 'missing',
|
||||
reasonCode: 'FILE_MISSING',
|
||||
},
|
||||
};
|
||||
}
|
||||
return rejected(baseAudit, 'FILE_READ_FAILED');
|
||||
}
|
||||
|
||||
const sourceSha256 = createHash('sha256').update(bytes).digest('hex');
|
||||
const hashedAudit = { ...baseAudit, sourceSha256 };
|
||||
if (bytes.byteLength > maxBytes) {
|
||||
return rejected(hashedAudit, 'FILE_TOO_LARGE');
|
||||
}
|
||||
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(bytes.toString('utf8'));
|
||||
} catch {
|
||||
return rejected(hashedAudit, 'INVALID_JSON');
|
||||
}
|
||||
|
||||
try {
|
||||
const decision = parseRuntimeRolloutManifest(value, evaluatedAtMs);
|
||||
const status = decision.manifest.enabled ? 'accepted' : 'disabled';
|
||||
return {
|
||||
status,
|
||||
policy: decision.policy,
|
||||
manifest: decision.manifest,
|
||||
audit: {
|
||||
...hashedAudit,
|
||||
status,
|
||||
revision: decision.manifest.revision,
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return rejected(hashedAudit, 'INVALID_MANIFEST');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,534 @@
|
||||
import { constants } from 'fs';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { lock } from 'proper-lockfile';
|
||||
import {
|
||||
MAX_WORKER_EXECUTION_OFFER_JOURNAL_ENTRIES,
|
||||
MAX_WORKER_EXECUTION_OFFER_JOURNAL_PAGE_SIZE,
|
||||
MAX_WORKER_EXECUTION_OFFER_RECORD_BYTES,
|
||||
cloneWorkerExecutionOfferJournalRecord,
|
||||
parseWorkerExecutionOfferJournalRecord,
|
||||
serializeWorkerExecutionOfferJournalRecord,
|
||||
type WorkerExecutionOfferJournalRecord,
|
||||
} from '../../domain/workerExecutionOffer';
|
||||
import { assertRunDispatchOfferId } from '../../domain/runDispatchOffer';
|
||||
import type {
|
||||
WorkerExecutionOfferJournal,
|
||||
WorkerExecutionOfferJournalCreateResult,
|
||||
WorkerExecutionOfferJournalPage,
|
||||
} from '../../ports/workerExecutionOfferJournal';
|
||||
import type {
|
||||
WorkerExecutionOfferJournalOwnership,
|
||||
WorkerExecutionOfferJournalOwnershipState,
|
||||
} from '../../ports/workerExecutionOfferJournalOwnership';
|
||||
|
||||
const JOURNAL_FILE_PATTERN = /^([0-9a-f]{64})\.json$/;
|
||||
|
||||
export const MIN_WORKER_OFFER_JOURNAL_LOCK_STALE_MS = 5_000;
|
||||
export const MAX_WORKER_OFFER_JOURNAL_LOCK_STALE_MS = 5 * 60_000;
|
||||
|
||||
export interface WorkerExecutionOfferLockProvider {
|
||||
acquire(options: {
|
||||
root: string;
|
||||
lockfilePath: string;
|
||||
staleMs: number;
|
||||
updateMs: number;
|
||||
onCompromised(error: Error): void;
|
||||
}): Promise<() => Promise<void>>;
|
||||
}
|
||||
|
||||
const properLockProvider: WorkerExecutionOfferLockProvider = {
|
||||
acquire(options) {
|
||||
return lock(options.root, {
|
||||
stale: options.staleMs,
|
||||
update: options.updateMs,
|
||||
retries: 0,
|
||||
realpath: true,
|
||||
lockfilePath: options.lockfilePath,
|
||||
onCompromised: options.onCompromised,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export class WorkerExecutionOfferJournalCapacityError extends Error {
|
||||
constructor(readonly maximumEntries: number) {
|
||||
super(`Worker execution offer journal reached ${maximumEntries} entries`);
|
||||
this.name = 'WorkerExecutionOfferJournalCapacityError';
|
||||
}
|
||||
}
|
||||
|
||||
export class WorkerExecutionOfferJournalRevisionError extends Error {
|
||||
constructor(readonly offerId: string) {
|
||||
super(`Worker execution offer journal revision changed for ${offerId}`);
|
||||
this.name = 'WorkerExecutionOfferJournalRevisionError';
|
||||
}
|
||||
}
|
||||
|
||||
export class WorkerExecutionOfferJournalNotFoundError extends Error {
|
||||
constructor(readonly offerId: string) {
|
||||
super(`Worker execution offer journal entry ${offerId} was not found`);
|
||||
this.name = 'WorkerExecutionOfferJournalNotFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
export class WorkerExecutionOfferJournalOwnershipError extends Error {
|
||||
constructor(
|
||||
readonly reason: 'already_owned' | 'not_owned' | 'compromised',
|
||||
readonly cause?: unknown,
|
||||
) {
|
||||
super(`Worker execution offer journal ownership failed: ${reason}`);
|
||||
this.name = 'WorkerExecutionOfferJournalOwnershipError';
|
||||
}
|
||||
}
|
||||
|
||||
function isCode(error: unknown, code: string): boolean {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'code' in error &&
|
||||
(error as { code?: string }).code === code
|
||||
);
|
||||
}
|
||||
|
||||
function assertIntegerBetween(
|
||||
name: string,
|
||||
value: number,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): void {
|
||||
if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
|
||||
throw new RangeError(`${name} must be between ${minimum} and ${maximum}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Worker-local restart journal. One bounded file per offer avoids a database or
|
||||
* sidecar on edge devices. The Worker runtime must exclusively own this root.
|
||||
*/
|
||||
export class WorkerExecutionOfferFileJournal
|
||||
implements WorkerExecutionOfferJournal, WorkerExecutionOfferJournalOwnership
|
||||
{
|
||||
private readonly maximumEntries: number;
|
||||
private readonly ownershipStaleMs: number;
|
||||
private readonly lockProvider: WorkerExecutionOfferLockProvider;
|
||||
private readonly onOwnershipCompromised?: (error: Error) => void;
|
||||
private ownerState: WorkerExecutionOfferJournalOwnershipState = 'unowned';
|
||||
private releaseOwner?: () => Promise<void>;
|
||||
private ownershipError?: Error;
|
||||
private mutationTail: Promise<void> = Promise.resolve();
|
||||
|
||||
constructor(
|
||||
private readonly root: string,
|
||||
options: {
|
||||
maximumEntries?: number;
|
||||
ownershipStaleMs?: number;
|
||||
lockProvider?: WorkerExecutionOfferLockProvider;
|
||||
onOwnershipCompromised?: (error: Error) => void;
|
||||
} = {},
|
||||
) {
|
||||
if (!path.isAbsolute(root)) {
|
||||
throw new RangeError(
|
||||
'Worker execution offer journal root must be absolute',
|
||||
);
|
||||
}
|
||||
this.maximumEntries = options.maximumEntries ?? 64;
|
||||
assertIntegerBetween(
|
||||
'maximumEntries',
|
||||
this.maximumEntries,
|
||||
1,
|
||||
MAX_WORKER_EXECUTION_OFFER_JOURNAL_ENTRIES,
|
||||
);
|
||||
this.ownershipStaleMs = options.ownershipStaleMs ?? 30_000;
|
||||
assertIntegerBetween(
|
||||
'ownershipStaleMs',
|
||||
this.ownershipStaleMs,
|
||||
MIN_WORKER_OFFER_JOURNAL_LOCK_STALE_MS,
|
||||
MAX_WORKER_OFFER_JOURNAL_LOCK_STALE_MS,
|
||||
);
|
||||
this.lockProvider = options.lockProvider ?? properLockProvider;
|
||||
this.onOwnershipCompromised = options.onOwnershipCompromised;
|
||||
}
|
||||
|
||||
ownershipState(): WorkerExecutionOfferJournalOwnershipState {
|
||||
return this.ownerState;
|
||||
}
|
||||
|
||||
async acquireOwnership(): Promise<'acquired' | 'already_owned'> {
|
||||
if (this.ownerState === 'owned') return 'already_owned';
|
||||
if (this.ownerState === 'releasing') {
|
||||
throw new WorkerExecutionOfferJournalOwnershipError('not_owned');
|
||||
}
|
||||
if (this.ownerState === 'compromised') {
|
||||
throw new WorkerExecutionOfferJournalOwnershipError(
|
||||
'compromised',
|
||||
this.ownershipError,
|
||||
);
|
||||
}
|
||||
await this.ensureRoot();
|
||||
try {
|
||||
const release = await this.lockProvider.acquire({
|
||||
root: this.root,
|
||||
lockfilePath: path.join(this.root, '.owner.lock'),
|
||||
staleMs: this.ownershipStaleMs,
|
||||
updateMs: Math.max(1_000, Math.floor(this.ownershipStaleMs / 2)),
|
||||
onCompromised: (error) => this.compromiseOwnership(error),
|
||||
});
|
||||
this.releaseOwner = release;
|
||||
this.ownerState = 'owned';
|
||||
return 'acquired';
|
||||
} catch (error) {
|
||||
if (isCode(error, 'ELOCKED')) {
|
||||
throw new WorkerExecutionOfferJournalOwnershipError(
|
||||
'already_owned',
|
||||
error,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async releaseOwnership(): Promise<'released' | 'not_owned' | 'compromised'> {
|
||||
if (this.ownerState === 'unowned') return 'not_owned';
|
||||
if (this.ownerState === 'compromised') return 'compromised';
|
||||
if (this.ownerState === 'releasing') return 'not_owned';
|
||||
while (true) {
|
||||
const pending = this.mutationTail;
|
||||
await pending;
|
||||
if (pending === this.mutationTail) break;
|
||||
}
|
||||
const ownerStateAfterMutations = this.ownershipState();
|
||||
if (ownerStateAfterMutations === 'compromised') return 'compromised';
|
||||
if (ownerStateAfterMutations !== 'owned') return 'not_owned';
|
||||
const release = this.releaseOwner;
|
||||
if (!release) {
|
||||
this.compromiseOwnership(
|
||||
new Error('Worker offer journal owner release capability is missing'),
|
||||
);
|
||||
return 'compromised';
|
||||
}
|
||||
this.ownerState = 'releasing';
|
||||
try {
|
||||
await release();
|
||||
this.releaseOwner = undefined;
|
||||
this.ownerState = 'unowned';
|
||||
return 'released';
|
||||
} catch (error) {
|
||||
const compromised =
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error('Worker offer journal owner release failed');
|
||||
this.compromiseOwnership(compromised);
|
||||
throw new WorkerExecutionOfferJournalOwnershipError('compromised', error);
|
||||
}
|
||||
}
|
||||
|
||||
async create(
|
||||
record: WorkerExecutionOfferJournalRecord,
|
||||
): Promise<WorkerExecutionOfferJournalCreateResult> {
|
||||
this.assertOwned();
|
||||
const candidate = cloneWorkerExecutionOfferJournalRecord(record);
|
||||
if (candidate.revision !== 0 || candidate.state !== 'accepted') {
|
||||
throw new TypeError(
|
||||
'A new Worker offer journal entry must be accepted at revision zero',
|
||||
);
|
||||
}
|
||||
return this.serializeMutation(async () => {
|
||||
const target = this.target(candidate.offer.offerId);
|
||||
if (await this.exists(target)) return 'exists';
|
||||
const names = await this.entryNames();
|
||||
if (names.length >= this.maximumEntries) {
|
||||
throw new WorkerExecutionOfferJournalCapacityError(this.maximumEntries);
|
||||
}
|
||||
const temporary = this.temporary(candidate.offer.offerId);
|
||||
try {
|
||||
await this.writeTemporary(temporary, candidate);
|
||||
this.assertOwned();
|
||||
await fs.link(temporary, target);
|
||||
await this.bestEffortSyncDirectory();
|
||||
return 'created';
|
||||
} catch (error) {
|
||||
if (isCode(error, 'EEXIST')) return 'exists';
|
||||
throw error;
|
||||
} finally {
|
||||
await this.bestEffortUnlink(temporary);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async read(
|
||||
offerId: string,
|
||||
): Promise<WorkerExecutionOfferJournalRecord | undefined> {
|
||||
this.assertOwned();
|
||||
assertRunDispatchOfferId(offerId);
|
||||
let handle: fs.FileHandle;
|
||||
try {
|
||||
handle = await fs.open(
|
||||
this.target(offerId),
|
||||
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
} catch (error) {
|
||||
if (isCode(error, 'ENOENT')) return undefined;
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
const stat = await handle.stat();
|
||||
if (!stat.isFile()) {
|
||||
throw new TypeError(
|
||||
'Worker execution offer journal entry must be a regular file',
|
||||
);
|
||||
}
|
||||
const bytes = Buffer.allocUnsafe(
|
||||
MAX_WORKER_EXECUTION_OFFER_RECORD_BYTES + 1,
|
||||
);
|
||||
const { bytesRead } = await handle.read(bytes, 0, bytes.length, 0);
|
||||
if (bytesRead > MAX_WORKER_EXECUTION_OFFER_RECORD_BYTES) {
|
||||
throw new TypeError(
|
||||
'Worker execution offer journal entry exceeds the byte limit',
|
||||
);
|
||||
}
|
||||
const record = parseWorkerExecutionOfferJournalRecord(
|
||||
bytes.subarray(0, bytesRead),
|
||||
);
|
||||
if (record.offer.offerId !== offerId) {
|
||||
throw new TypeError(
|
||||
'Worker offer journal path and payload do not match',
|
||||
);
|
||||
}
|
||||
return record;
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
async replace(
|
||||
record: WorkerExecutionOfferJournalRecord,
|
||||
expectedRevision: number,
|
||||
): Promise<void> {
|
||||
this.assertOwned();
|
||||
const candidate = cloneWorkerExecutionOfferJournalRecord(record);
|
||||
assertIntegerBetween(
|
||||
'expectedRevision',
|
||||
expectedRevision,
|
||||
0,
|
||||
Number.MAX_SAFE_INTEGER - 1,
|
||||
);
|
||||
if (candidate.revision !== expectedRevision + 1) {
|
||||
throw new TypeError('Replacement journal revision must increment by one');
|
||||
}
|
||||
await this.serializeMutation(async () => {
|
||||
const current = await this.read(candidate.offer.offerId);
|
||||
if (!current) {
|
||||
throw new WorkerExecutionOfferJournalNotFoundError(
|
||||
candidate.offer.offerId,
|
||||
);
|
||||
}
|
||||
if (current.revision !== expectedRevision) {
|
||||
throw new WorkerExecutionOfferJournalRevisionError(
|
||||
candidate.offer.offerId,
|
||||
);
|
||||
}
|
||||
const temporary = this.temporary(candidate.offer.offerId);
|
||||
try {
|
||||
await this.writeTemporary(temporary, candidate);
|
||||
this.assertOwned();
|
||||
await fs.rename(temporary, this.target(candidate.offer.offerId));
|
||||
await this.bestEffortSyncDirectory();
|
||||
} finally {
|
||||
await this.bestEffortUnlink(temporary);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async remove(offerId: string, expectedRevision?: number): Promise<boolean> {
|
||||
this.assertOwned();
|
||||
assertRunDispatchOfferId(offerId);
|
||||
if (expectedRevision !== undefined) {
|
||||
assertIntegerBetween(
|
||||
'expectedRevision',
|
||||
expectedRevision,
|
||||
0,
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
);
|
||||
}
|
||||
return this.serializeMutation(async () => {
|
||||
if (expectedRevision !== undefined) {
|
||||
const current = await this.read(offerId);
|
||||
if (!current) return false;
|
||||
if (current.revision !== expectedRevision) {
|
||||
throw new WorkerExecutionOfferJournalRevisionError(offerId);
|
||||
}
|
||||
}
|
||||
try {
|
||||
this.assertOwned();
|
||||
await fs.unlink(this.target(offerId));
|
||||
await this.bestEffortSyncDirectory();
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isCode(error, 'ENOENT')) return false;
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async list(
|
||||
options: { afterOfferId?: string; limit?: number } = {},
|
||||
): Promise<WorkerExecutionOfferJournalPage> {
|
||||
this.assertOwned();
|
||||
if (options.afterOfferId !== undefined) {
|
||||
assertRunDispatchOfferId(options.afterOfferId);
|
||||
}
|
||||
const limit = options.limit ?? 32;
|
||||
assertIntegerBetween(
|
||||
'limit',
|
||||
limit,
|
||||
1,
|
||||
MAX_WORKER_EXECUTION_OFFER_JOURNAL_PAGE_SIZE,
|
||||
);
|
||||
const names = await this.entryNames();
|
||||
const offerIds = names
|
||||
.map((name) => JOURNAL_FILE_PATTERN.exec(name)?.[1])
|
||||
.filter((value): value is string => value !== undefined)
|
||||
.filter(
|
||||
(offerId) =>
|
||||
options.afterOfferId === undefined || offerId > options.afterOfferId,
|
||||
)
|
||||
.sort();
|
||||
const selected = offerIds.slice(0, limit + 1);
|
||||
const hasMore = selected.length > limit;
|
||||
const pageIds = selected.slice(0, limit);
|
||||
const records: WorkerExecutionOfferJournalRecord[] = [];
|
||||
for (const offerId of pageIds) {
|
||||
const record = await this.read(offerId);
|
||||
if (!record) {
|
||||
throw new WorkerExecutionOfferJournalRevisionError(offerId);
|
||||
}
|
||||
records.push(record);
|
||||
}
|
||||
return {
|
||||
records,
|
||||
...(hasMore && pageIds.length
|
||||
? { nextAfterOfferId: pageIds[pageIds.length - 1] }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
private target(offerId: string): string {
|
||||
assertRunDispatchOfferId(offerId);
|
||||
return path.join(this.root, `${offerId}.json`);
|
||||
}
|
||||
|
||||
private temporary(offerId: string): string {
|
||||
return path.join(
|
||||
this.root,
|
||||
`.${offerId}.${randomBytes(16).toString('hex')}.tmp`,
|
||||
);
|
||||
}
|
||||
|
||||
private async ensureRoot(): Promise<void> {
|
||||
await fs.mkdir(this.root, { recursive: true, mode: 0o700 });
|
||||
const stat = await fs.lstat(this.root);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
||||
throw new TypeError(
|
||||
'Worker execution offer journal root must be a real directory',
|
||||
);
|
||||
}
|
||||
await fs.chmod(this.root, 0o700);
|
||||
}
|
||||
|
||||
private assertOwned(): void {
|
||||
if (this.ownerState === 'owned') return;
|
||||
throw new WorkerExecutionOfferJournalOwnershipError(
|
||||
this.ownerState === 'compromised' ? 'compromised' : 'not_owned',
|
||||
this.ownershipError,
|
||||
);
|
||||
}
|
||||
|
||||
private compromiseOwnership(error: Error): void {
|
||||
this.ownershipError = error;
|
||||
this.releaseOwner = undefined;
|
||||
this.ownerState = 'compromised';
|
||||
try {
|
||||
this.onOwnershipCompromised?.(error);
|
||||
} catch {
|
||||
// Ownership loss must remain visible even if diagnostics fail.
|
||||
}
|
||||
}
|
||||
|
||||
private async entryNames(): Promise<string[]> {
|
||||
let names: string[];
|
||||
try {
|
||||
names = await fs.readdir(this.root);
|
||||
} catch (error) {
|
||||
if (isCode(error, 'ENOENT')) return [];
|
||||
throw error;
|
||||
}
|
||||
const entries = names.filter((name) => JOURNAL_FILE_PATTERN.test(name));
|
||||
if (entries.length > this.maximumEntries) {
|
||||
throw new WorkerExecutionOfferJournalCapacityError(this.maximumEntries);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
private async writeTemporary(
|
||||
temporary: string,
|
||||
record: WorkerExecutionOfferJournalRecord,
|
||||
): Promise<void> {
|
||||
const serialized = serializeWorkerExecutionOfferJournalRecord(record);
|
||||
let handle: fs.FileHandle | undefined;
|
||||
try {
|
||||
handle = await fs.open(temporary, 'wx', 0o600);
|
||||
await handle.writeFile(serialized, 'utf8');
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle?.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
private async exists(target: string): Promise<boolean> {
|
||||
try {
|
||||
const stat = await fs.lstat(target);
|
||||
if (!stat.isFile()) {
|
||||
throw new TypeError(
|
||||
'Worker execution offer journal target must be a regular file',
|
||||
);
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isCode(error, 'ENOENT')) return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async bestEffortUnlink(target: string): Promise<void> {
|
||||
try {
|
||||
await fs.unlink(target);
|
||||
} catch (error) {
|
||||
if (!isCode(error, 'ENOENT')) {
|
||||
// Temp cleanup is diagnostic-only; the atomically published record wins.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async bestEffortSyncDirectory(): Promise<void> {
|
||||
try {
|
||||
const handle = await fs.open(this.root, constants.O_RDONLY);
|
||||
try {
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
} catch {
|
||||
// Some supported filesystems cannot fsync directories.
|
||||
}
|
||||
}
|
||||
|
||||
private serializeMutation<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const result = this.mutationTail.then(operation, operation);
|
||||
this.mutationTail = result.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user