Files

204 lines
7.9 KiB
TypeScript

import fs from "node:fs/promises";
import path from "node:path";
import type { NativeScannerRunTiming, NativeScannerRunTimingPatch } from "../../src/types/global.js";
export const NATIVE_SCANNER_RUN_TIMING_FILE = "run-timing.json";
export const NATIVE_SCANNER_RUN_TIMING_VERSION = "native-scanner-run-timing-v1" as const;
const timingWriteQueues = new Map<string, Promise<void>>();
const timestampFields = [
"requestStartedAt",
"scannerStartedAt",
"captureStartedAt",
"captureCompletedAt",
"processingStartedAt",
"processingCompletedAt",
"resultsDurableAt",
"resultsReconciledAt",
] as const satisfies ReadonlyArray<keyof NativeScannerRunTimingPatch>;
type TimingTimestampField = typeof timestampFields[number];
export function nativeScannerRunTimingPath(runDir: string) {
return path.join(path.resolve(runDir), NATIVE_SCANNER_RUN_TIMING_FILE);
}
export async function readNativeScannerRunTiming(runDir: string): Promise<NativeScannerRunTiming | null> {
const timingPath = nativeScannerRunTimingPath(runDir);
try {
const raw = JSON.parse(await fs.readFile(timingPath, "utf8")) as unknown;
return normalizeTiming(raw);
} catch (error) {
if (isMissingFileError(error)) return null;
throw error;
}
}
/**
* Adds first-observed lifecycle markers to one run's durable timing record.
* A marker is deliberately immutable after the first valid observation so
* polling or a later renderer retry cannot rewrite the measured interval.
*/
export async function recordNativeScannerRunTiming(
runDir: string,
patch: NativeScannerRunTimingPatch,
): Promise<NativeScannerRunTiming> {
const resolvedRunDir = path.resolve(runDir);
const previous = timingWriteQueues.get(resolvedRunDir) ?? Promise.resolve();
const task = previous
.catch(() => undefined)
.then(async () => {
const current = await readNativeScannerRunTiming(resolvedRunDir);
const next = mergeTiming(current, patch);
if (!sameTiming(current, next)) {
await writeJsonAtomic(nativeScannerRunTimingPath(resolvedRunDir), next);
}
return next;
});
const settled = task.then(() => undefined, () => undefined);
timingWriteQueues.set(resolvedRunDir, settled);
void settled.finally(() => {
if (timingWriteQueues.get(resolvedRunDir) === settled) timingWriteQueues.delete(resolvedRunDir);
});
return task;
}
export function validateNativeScannerRunTiming(timing: NativeScannerRunTiming): string[] {
const issues: string[] = [];
if (timing.version !== NATIVE_SCANNER_RUN_TIMING_VERSION) {
issues.push(`unsupported timing version ${String(timing.version)}`);
}
for (const field of timestampFields) {
const value = timing[field];
if (value !== undefined && !isValidTimestamp(value)) {
issues.push(`${field} is not a valid ISO timestamp`);
}
}
validateOrder(issues, timing, "requestStartedAt", "scannerStartedAt");
validateOrder(issues, timing, "requestStartedAt", "captureStartedAt");
validateOrder(issues, timing, "requestStartedAt", "processingStartedAt");
validateOrder(issues, timing, "captureStartedAt", "captureCompletedAt");
validateOrder(issues, timing, "captureCompletedAt", "processingCompletedAt");
validateOrder(issues, timing, "processingStartedAt", "processingCompletedAt");
validateOrder(issues, timing, "processingCompletedAt", "resultsDurableAt");
validateOrder(issues, timing, "resultsDurableAt", "resultsReconciledAt");
validateDuration(issues, timing, "requestToResultsDurableMs", "requestStartedAt", "resultsDurableAt");
validateDuration(issues, timing, "requestToResultsReconciledMs", "requestStartedAt", "resultsReconciledAt");
return issues;
}
export function hasCompleteNativeScannerRunTiming(timing: NativeScannerRunTiming | null | undefined) {
return Boolean(
timing?.requestStartedAt
&& timing.scannerStartedAt
&& timing.captureCompletedAt
&& timing.processingStartedAt
&& timing.processingCompletedAt
&& timing.resultsDurableAt
&& timing.resultsReconciledAt,
);
}
function mergeTiming(current: NativeScannerRunTiming | null, patch: NativeScannerRunTimingPatch): NativeScannerRunTiming {
const next: NativeScannerRunTiming = { version: NATIVE_SCANNER_RUN_TIMING_VERSION };
for (const field of timestampFields) {
const value = firstValidTimestamp(current?.[field], patch[field]);
if (value) next[field] = value;
}
const durableMs = durationMs(next.requestStartedAt, next.resultsDurableAt);
if (durableMs !== null) next.requestToResultsDurableMs = durableMs;
const reconciledMs = durationMs(next.requestStartedAt, next.resultsReconciledAt);
if (reconciledMs !== null) next.requestToResultsReconciledMs = reconciledMs;
return next;
}
function normalizeTiming(value: unknown): NativeScannerRunTiming {
if (!value || typeof value !== "object") throw new Error("Native scanner timing file is not an object.");
const candidate = value as Partial<NativeScannerRunTiming>;
const raw: NativeScannerRunTiming = {
version: candidate.version as NativeScannerRunTiming["version"],
};
for (const field of timestampFields) {
if (candidate[field] !== undefined) raw[field] = candidate[field] as string;
}
if (candidate.requestToResultsDurableMs !== undefined) {
raw.requestToResultsDurableMs = candidate.requestToResultsDurableMs as number;
}
if (candidate.requestToResultsReconciledMs !== undefined) {
raw.requestToResultsReconciledMs = candidate.requestToResultsReconciledMs as number;
}
const issues = validateNativeScannerRunTiming(raw);
if (issues.length > 0) throw new Error(`Invalid native scanner timing file: ${issues.join("; ")}`);
return mergeTiming(null, raw);
}
function validateOrder(
issues: string[],
timing: NativeScannerRunTiming,
earlier: TimingTimestampField,
later: TimingTimestampField,
) {
const earlierMs = timestampMs(timing[earlier]);
const laterMs = timestampMs(timing[later]);
if (earlierMs !== null && laterMs !== null && laterMs < earlierMs) {
issues.push(`${later} is before ${earlier}`);
}
}
function validateDuration(
issues: string[],
timing: NativeScannerRunTiming,
field: "requestToResultsDurableMs" | "requestToResultsReconciledMs",
startedField: TimingTimestampField,
completedField: TimingTimestampField,
) {
const declared = timing[field];
const expected = durationMs(timing[startedField], timing[completedField]);
if (declared !== undefined && (!Number.isInteger(declared) || declared < 0)) {
issues.push(`${field} is not a non-negative integer`);
}
if (expected !== null && declared !== expected) {
issues.push(`${field} does not match ${startedField} to ${completedField}`);
}
if (expected === null && declared !== undefined) {
issues.push(`${field} exists without both endpoint timestamps`);
}
}
function firstValidTimestamp(...values: Array<string | null | undefined>) {
return values.find((value): value is string => isValidTimestamp(value));
}
function isValidTimestamp(value: unknown): value is string {
return typeof value === "string" && value.length > 0 && Number.isFinite(Date.parse(value));
}
function timestampMs(value: unknown) {
return isValidTimestamp(value) ? Date.parse(value) : null;
}
function durationMs(startedAt: string | undefined, completedAt: string | undefined) {
const started = timestampMs(startedAt);
const completed = timestampMs(completedAt);
if (started === null || completed === null || completed < started) return null;
return Math.round(completed - started);
}
function sameTiming(left: NativeScannerRunTiming | null, right: NativeScannerRunTiming) {
return JSON.stringify(left) === JSON.stringify(right);
}
async function writeJsonAtomic(filePath: string, payload: unknown) {
const temporaryPath = `${filePath}.tmp-${process.pid}`;
await fs.writeFile(temporaryPath, JSON.stringify(payload, null, 2), "utf8");
await fs.rename(temporaryPath, filePath);
}
function isMissingFileError(error: unknown) {
return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT");
}