mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-22 10:32:40 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
import type {
|
||||
LocalCompletionReceiptJournal,
|
||||
LocalCompletionReceiptJournalCursor,
|
||||
} from '@qinglong/runtime-core/local-completion-receipt-journal';
|
||||
import {
|
||||
MAX_LOCAL_COMPLETION_RECEIPT_JOURNAL_PAGE,
|
||||
assertLocalCompletionReceiptJournalLimit,
|
||||
} from '@qinglong/runtime-core/local-completion-receipt-journal';
|
||||
import type { CompletionReceiptStore } from './completionReceiptFileStore';
|
||||
|
||||
const TERMINAL_ATTEMPT_STATUSES = new Set([
|
||||
'succeeded',
|
||||
'failed',
|
||||
'cancelled',
|
||||
'timed_out',
|
||||
'lost',
|
||||
]);
|
||||
const MAX_RETENTION_MS = 24 * 60 * 60_000;
|
||||
|
||||
interface MaintainedCompletionReceiptStore extends CompletionReceiptStore {
|
||||
quarantine?(attemptId: string): Promise<string | undefined>;
|
||||
purgeQuarantine?(attemptId: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface LocalCompletionReceiptCleanupSummary {
|
||||
readonly scanned: number;
|
||||
readonly removed: number;
|
||||
readonly expiredMissing: number;
|
||||
readonly purgedQuarantines: number;
|
||||
readonly remaining: number;
|
||||
readonly failed: number;
|
||||
readonly truncated: boolean;
|
||||
readonly nextCursor?: LocalCompletionReceiptJournalCursor;
|
||||
}
|
||||
|
||||
export interface LocalCompletionReceiptCleanupOptions {
|
||||
readonly terminalMissingRetentionMs?: number;
|
||||
readonly clock?: { now(): number };
|
||||
}
|
||||
|
||||
export class LocalCompletionReceiptCleanupScanner {
|
||||
private readonly terminalMissingRetentionMs: number;
|
||||
private readonly clock: { now(): number };
|
||||
|
||||
constructor(
|
||||
private readonly journal: LocalCompletionReceiptJournal,
|
||||
private readonly receipts: MaintainedCompletionReceiptStore,
|
||||
options: LocalCompletionReceiptCleanupOptions = {},
|
||||
) {
|
||||
this.terminalMissingRetentionMs =
|
||||
options.terminalMissingRetentionMs ?? 60_000;
|
||||
if (
|
||||
!Number.isSafeInteger(this.terminalMissingRetentionMs) ||
|
||||
this.terminalMissingRetentionMs < 0 ||
|
||||
this.terminalMissingRetentionMs > MAX_RETENTION_MS
|
||||
) {
|
||||
throw new RangeError(
|
||||
'terminalMissingRetentionMs must be between 0 and 24 hours',
|
||||
);
|
||||
}
|
||||
this.clock = options.clock ?? { now: Date.now };
|
||||
}
|
||||
|
||||
async scan(
|
||||
options: {
|
||||
readonly cursor?: LocalCompletionReceiptJournalCursor;
|
||||
readonly limit?: number;
|
||||
} = {},
|
||||
): Promise<LocalCompletionReceiptCleanupSummary> {
|
||||
const observedAtMs = this.clock.now();
|
||||
if (!Number.isSafeInteger(observedAtMs) || observedAtMs < 0) {
|
||||
throw new RangeError('Completion receipt cleanup clock is invalid');
|
||||
}
|
||||
const limit = options.limit ?? 32;
|
||||
assertLocalCompletionReceiptJournalLimit(limit);
|
||||
const page = await this.journal.listCandidates({
|
||||
observedAtMs,
|
||||
limit,
|
||||
...(options.cursor === undefined ? {} : { cursor: options.cursor }),
|
||||
});
|
||||
if (
|
||||
!page ||
|
||||
!Array.isArray(page.candidates) ||
|
||||
page.candidates.length > MAX_LOCAL_COMPLETION_RECEIPT_JOURNAL_PAGE ||
|
||||
typeof page.truncated !== 'boolean'
|
||||
) {
|
||||
throw new TypeError('Completion receipt cleanup page is invalid');
|
||||
}
|
||||
let removed = 0;
|
||||
let expiredMissing = 0;
|
||||
let purgedQuarantines = 0;
|
||||
let remaining = 0;
|
||||
let failed = 0;
|
||||
for (const candidate of page.candidates) {
|
||||
try {
|
||||
if (candidate.executorType !== 'local_process') {
|
||||
remaining += 1;
|
||||
continue;
|
||||
}
|
||||
if (candidate.state === 'quarantined') {
|
||||
if (!this.receipts.quarantine || !this.receipts.purgeQuarantine) {
|
||||
remaining += 1;
|
||||
continue;
|
||||
}
|
||||
await this.receipts.quarantine(candidate.attemptId);
|
||||
await this.receipts.purgeQuarantine(candidate.attemptId);
|
||||
await this.journal.resolve(candidate.attemptId);
|
||||
purgedQuarantines += 1;
|
||||
continue;
|
||||
}
|
||||
if (!TERMINAL_ATTEMPT_STATUSES.has(candidate.attemptStatus)) {
|
||||
remaining += 1;
|
||||
continue;
|
||||
}
|
||||
const receiptRemoved = await this.receipts.remove(candidate.attemptId);
|
||||
if (receiptRemoved) {
|
||||
await this.journal.resolve(candidate.attemptId);
|
||||
removed += 1;
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
candidate.finishedAtMs !== undefined &&
|
||||
candidate.finishedAtMs + this.terminalMissingRetentionMs <=
|
||||
observedAtMs
|
||||
) {
|
||||
await this.journal.resolve(candidate.attemptId);
|
||||
expiredMissing += 1;
|
||||
} else {
|
||||
remaining += 1;
|
||||
}
|
||||
} catch {
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
return Object.freeze({
|
||||
scanned: page.candidates.length,
|
||||
removed,
|
||||
expiredMissing,
|
||||
purgedQuarantines,
|
||||
remaining,
|
||||
failed,
|
||||
truncated: page.truncated,
|
||||
...(page.nextCursor === undefined ? {} : { nextCursor: page.nextCursor }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export interface LocalCompletionReceiptCleanupLifecycleOptions {
|
||||
readonly intervalMs: number;
|
||||
readonly pageSize: number;
|
||||
readonly stopTimeoutMs?: number;
|
||||
readonly onDiagnostic?: (
|
||||
error: unknown,
|
||||
summary?: LocalCompletionReceiptCleanupSummary,
|
||||
) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export class LocalCompletionReceiptCleanupLifecycle {
|
||||
private readonly stopTimeoutMs: number;
|
||||
private timer: NodeJS.Timeout | undefined;
|
||||
private inFlight: Promise<void> | undefined;
|
||||
private running = false;
|
||||
private cursor: LocalCompletionReceiptJournalCursor | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly scanner: LocalCompletionReceiptCleanupScanner,
|
||||
private readonly options: LocalCompletionReceiptCleanupLifecycleOptions,
|
||||
) {
|
||||
if (
|
||||
!Number.isSafeInteger(options.intervalMs) ||
|
||||
options.intervalMs < 1_000 ||
|
||||
options.intervalMs > 24 * 60 * 60_000
|
||||
) {
|
||||
throw new RangeError(
|
||||
'cleanup interval must be between 1 second and 24 hours',
|
||||
);
|
||||
}
|
||||
assertLocalCompletionReceiptJournalLimit(options.pageSize);
|
||||
this.stopTimeoutMs = options.stopTimeoutMs ?? 5_000;
|
||||
if (
|
||||
!Number.isSafeInteger(this.stopTimeoutMs) ||
|
||||
this.stopTimeoutMs < 100 ||
|
||||
this.stopTimeoutMs > 30_000
|
||||
) {
|
||||
throw new RangeError(
|
||||
'cleanup stop timeout must be between 100 and 30000 ms',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.running) return;
|
||||
this.running = true;
|
||||
this.schedule();
|
||||
}
|
||||
|
||||
async runOnce(): Promise<LocalCompletionReceiptCleanupSummary> {
|
||||
const summary = await this.scanner.scan({
|
||||
limit: this.options.pageSize,
|
||||
...(this.cursor === undefined ? {} : { cursor: this.cursor }),
|
||||
});
|
||||
this.cursor = summary.truncated ? summary.nextCursor : undefined;
|
||||
return summary;
|
||||
}
|
||||
|
||||
async stop(): Promise<'stopped' | 'timed_out'> {
|
||||
this.running = false;
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.timer = undefined;
|
||||
const inFlight = this.inFlight;
|
||||
if (!inFlight) return 'stopped';
|
||||
return Promise.race([
|
||||
inFlight.then(() => 'stopped' as const),
|
||||
new Promise<'timed_out'>((resolve) => {
|
||||
setTimeout(() => resolve('timed_out'), this.stopTimeoutMs).unref?.();
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
private schedule(): void {
|
||||
if (!this.running) return;
|
||||
this.timer = setTimeout(() => {
|
||||
if (!this.running || this.inFlight) {
|
||||
this.schedule();
|
||||
return;
|
||||
}
|
||||
this.inFlight = this.tick().finally(() => {
|
||||
this.inFlight = undefined;
|
||||
this.schedule();
|
||||
});
|
||||
}, this.options.intervalMs);
|
||||
this.timer.unref?.();
|
||||
}
|
||||
|
||||
private async tick(): Promise<void> {
|
||||
try {
|
||||
const summary = await this.scanner.scan({
|
||||
limit: this.options.pageSize,
|
||||
...(this.cursor === undefined ? {} : { cursor: this.cursor }),
|
||||
});
|
||||
this.cursor = summary.truncated ? summary.nextCursor : undefined;
|
||||
await this.options.onDiagnostic?.(undefined, summary);
|
||||
} catch (error) {
|
||||
await this.options.onDiagnostic?.(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
export const COMPLETION_RECEIPT_SCHEMA_VERSION = 1;
|
||||
export const MAX_COMPLETION_RECEIPT_BYTES = 4 * 1024;
|
||||
|
||||
const RECEIPT_KEYS = [
|
||||
'schemaVersion',
|
||||
'runId',
|
||||
'attemptId',
|
||||
'callbackSequence',
|
||||
'token',
|
||||
'startedAtMs',
|
||||
'finishedAtMs',
|
||||
'exitCode',
|
||||
] as const;
|
||||
const RECEIPT_KEY_SET = new Set<string>(RECEIPT_KEYS);
|
||||
const PORTABLE_LOCAL_EXECUTION_ID =
|
||||
/^[A-Za-z0-9][A-Za-z0-9._:-]{0,35}$/;
|
||||
const TOKEN_PATTERN = /^[A-Za-z0-9_-]{32,128}$/;
|
||||
|
||||
export interface CompletionReceipt {
|
||||
schemaVersion: typeof COMPLETION_RECEIPT_SCHEMA_VERSION;
|
||||
runId: string;
|
||||
attemptId: string;
|
||||
callbackSequence: number;
|
||||
token: string;
|
||||
startedAtMs: number;
|
||||
finishedAtMs: number;
|
||||
exitCode: number;
|
||||
}
|
||||
|
||||
export class InvalidCompletionReceiptError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'InvalidCompletionReceiptError';
|
||||
}
|
||||
}
|
||||
|
||||
function invalid(message: string): never {
|
||||
throw new InvalidCompletionReceiptError(message);
|
||||
}
|
||||
|
||||
function skipWhitespace(value: string, from: number): number {
|
||||
let index = from;
|
||||
while (/\s/.test(value[index] ?? '')) index += 1;
|
||||
return index;
|
||||
}
|
||||
|
||||
function stringEnd(value: string, from: number): number {
|
||||
if (value[from] !== '"') invalid('Completion receipt key is not a string');
|
||||
for (let index = from + 1; index < value.length; index += 1) {
|
||||
if (value[index] === '\\') {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (value[index] === '"') return index + 1;
|
||||
}
|
||||
return invalid('Completion receipt contains an unterminated string');
|
||||
}
|
||||
|
||||
/** JSON.parse silently accepts duplicate keys, so reject them before parsing. */
|
||||
function assertUniqueFlatObjectKeys(value: string): void {
|
||||
let index = skipWhitespace(value, 0);
|
||||
if (value[index] !== '{') invalid('Completion receipt must be a JSON object');
|
||||
index = skipWhitespace(value, index + 1);
|
||||
const keys = new Set<string>();
|
||||
if (value[index] === '}') return;
|
||||
|
||||
while (index < value.length) {
|
||||
const keyStart = index;
|
||||
const keyEnd = stringEnd(value, keyStart);
|
||||
let key: string;
|
||||
try {
|
||||
key = JSON.parse(value.slice(keyStart, keyEnd));
|
||||
} catch {
|
||||
return invalid('Completion receipt contains an invalid key');
|
||||
}
|
||||
if (keys.has(key)) invalid('Completion receipt contains a duplicate key');
|
||||
keys.add(key);
|
||||
|
||||
index = skipWhitespace(value, keyEnd);
|
||||
if (value[index] !== ':') invalid('Completion receipt key has no value');
|
||||
index = skipWhitespace(value, index + 1);
|
||||
if (value[index] === '"') {
|
||||
index = stringEnd(value, index);
|
||||
} else {
|
||||
if (value[index] === '{' || value[index] === '[') {
|
||||
invalid('Completion receipt values must be scalar');
|
||||
}
|
||||
const valueStart = index;
|
||||
while (
|
||||
index < value.length &&
|
||||
value[index] !== ',' &&
|
||||
value[index] !== '}'
|
||||
) {
|
||||
index += 1;
|
||||
}
|
||||
if (value.slice(valueStart, index).trim().length === 0) {
|
||||
invalid('Completion receipt contains an empty value');
|
||||
}
|
||||
}
|
||||
|
||||
index = skipWhitespace(value, index);
|
||||
if (value[index] === '}') return;
|
||||
if (value[index] !== ',')
|
||||
invalid('Completion receipt is not a flat object');
|
||||
index = skipWhitespace(value, index + 1);
|
||||
}
|
||||
invalid('Completion receipt object is incomplete');
|
||||
}
|
||||
|
||||
export function assertCompletionReceiptId(
|
||||
value: string,
|
||||
field: 'runId' | 'attemptId',
|
||||
): void {
|
||||
if (!PORTABLE_LOCAL_EXECUTION_ID.test(value)) {
|
||||
invalid(`${field} must be a bounded portable execution ID`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertSafeTimestamp(
|
||||
value: unknown,
|
||||
field: string,
|
||||
): asserts value is number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
||||
invalid(`${field} must be a non-negative safe integer`);
|
||||
}
|
||||
}
|
||||
|
||||
export function validateCompletionReceipt(
|
||||
candidate: unknown,
|
||||
): CompletionReceipt {
|
||||
if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) {
|
||||
invalid('Completion receipt must be an object');
|
||||
}
|
||||
const record = candidate as Record<string, unknown>;
|
||||
const keys = Object.keys(record);
|
||||
if (
|
||||
keys.length !== RECEIPT_KEYS.length ||
|
||||
keys.some((key) => !RECEIPT_KEY_SET.has(key)) ||
|
||||
RECEIPT_KEYS.some((key) => !Object.hasOwn(record, key))
|
||||
) {
|
||||
invalid('Completion receipt fields do not match schema version 1');
|
||||
}
|
||||
if (record.schemaVersion !== COMPLETION_RECEIPT_SCHEMA_VERSION) {
|
||||
invalid('Completion receipt schema version is unsupported');
|
||||
}
|
||||
if (typeof record.runId !== 'string') invalid('runId must be a string');
|
||||
if (typeof record.attemptId !== 'string') {
|
||||
invalid('attemptId must be a string');
|
||||
}
|
||||
assertCompletionReceiptId(record.runId, 'runId');
|
||||
assertCompletionReceiptId(record.attemptId, 'attemptId');
|
||||
if (
|
||||
!Number.isSafeInteger(record.callbackSequence) ||
|
||||
(record.callbackSequence as number) < 1
|
||||
) {
|
||||
invalid('callbackSequence must be a positive safe integer');
|
||||
}
|
||||
if (typeof record.token !== 'string' || !TOKEN_PATTERN.test(record.token)) {
|
||||
invalid('token must be a bounded base64url value');
|
||||
}
|
||||
assertSafeTimestamp(record.startedAtMs, 'startedAtMs');
|
||||
assertSafeTimestamp(record.finishedAtMs, 'finishedAtMs');
|
||||
if (record.finishedAtMs < record.startedAtMs) {
|
||||
invalid('finishedAtMs must not be before startedAtMs');
|
||||
}
|
||||
if (
|
||||
!Number.isInteger(record.exitCode) ||
|
||||
(record.exitCode as number) < 0 ||
|
||||
(record.exitCode as number) > 255
|
||||
) {
|
||||
invalid('exitCode must be an integer between 0 and 255');
|
||||
}
|
||||
return {
|
||||
schemaVersion: COMPLETION_RECEIPT_SCHEMA_VERSION,
|
||||
runId: record.runId,
|
||||
attemptId: record.attemptId,
|
||||
callbackSequence: record.callbackSequence as number,
|
||||
token: record.token,
|
||||
startedAtMs: record.startedAtMs,
|
||||
finishedAtMs: record.finishedAtMs,
|
||||
exitCode: record.exitCode as number,
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeCompletionReceipt(receipt: CompletionReceipt): string {
|
||||
const value = validateCompletionReceipt(receipt);
|
||||
const serialized = JSON.stringify({
|
||||
schemaVersion: value.schemaVersion,
|
||||
runId: value.runId,
|
||||
attemptId: value.attemptId,
|
||||
callbackSequence: value.callbackSequence,
|
||||
token: value.token,
|
||||
startedAtMs: value.startedAtMs,
|
||||
finishedAtMs: value.finishedAtMs,
|
||||
exitCode: value.exitCode,
|
||||
});
|
||||
if (Buffer.byteLength(serialized, 'utf8') > MAX_COMPLETION_RECEIPT_BYTES) {
|
||||
invalid('Completion receipt exceeds the byte limit');
|
||||
}
|
||||
return serialized;
|
||||
}
|
||||
|
||||
export function parseCompletionReceipt(
|
||||
input: string | Uint8Array,
|
||||
): CompletionReceipt {
|
||||
const bytes =
|
||||
typeof input === 'string' ? Buffer.from(input) : Buffer.from(input);
|
||||
if (bytes.length === 0 || bytes.length > MAX_COMPLETION_RECEIPT_BYTES) {
|
||||
invalid('Completion receipt size is outside the allowed range');
|
||||
}
|
||||
const value = bytes.toString('utf8');
|
||||
if (!Buffer.from(value, 'utf8').equals(bytes)) {
|
||||
invalid('Completion receipt must be valid UTF-8');
|
||||
}
|
||||
assertUniqueFlatObjectKeys(value);
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(value);
|
||||
} catch {
|
||||
return invalid('Completion receipt is not valid JSON');
|
||||
}
|
||||
return validateCompletionReceipt(parsed);
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
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 './completionReceipt';
|
||||
|
||||
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 interface CompletionReceiptStore {
|
||||
publish(receipt: CompletionReceipt): Promise<void>;
|
||||
read(attemptId: string): Promise<CompletionReceipt | undefined>;
|
||||
remove(attemptId: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
export class CompletionReceiptFileStore implements CompletionReceiptStore {
|
||||
constructor(private readonly root: string) {
|
||||
if (
|
||||
!path.isAbsolute(root) ||
|
||||
path.parse(root).root === root ||
|
||||
root.includes('\0') ||
|
||||
Buffer.byteLength(root, 'utf8') > 4096
|
||||
) {
|
||||
throw new RangeError(
|
||||
'Completion receipt root must be a bounded non-root absolute path',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user