204 lines
7.9 KiB
TypeScript
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");
|
|
}
|