366 lines · 12.7 KB · TypeScript
RawDownload
/** * Durable per-file scan cache. * * Transcripts are append-only and a file that has not changed can never yield * different usage, so parsed records are keyed by `(size, mtime)` and reused. * Without this every server restart re-parses the whole window: roughly 3.5s * for a 30-day scan here, against ~11ms to reload this cache. * * Caching *per file* rather than per day is deliberate. It is timezone * independent, so changing the reporting zone does not invalidate anything, and * it keeps cross-file de-duplication exact: cached entries are de-duplicated * within their own file only, and the aggregator still applies the global * dedupe pass over the small surviving key set. * * @module usageScanCache */// @effect-diagnostics nodeBuiltinImport:offimport * as NodePath from "node:path"; import type { UsageProviderKind } from "@t3tools/contracts"; import { GUARD_LENGTH, type TranscriptParsePosition } from "./usageTranscriptReader.ts";import type { CodexScanState, UsageRecord } from "./usageTranscripts.ts"; // v2: Codex fork-copy suppression changed what a file parses to, so v1// entries would keep serving double-counted records forever.// v3: entries carry the parse position and reducer state so a grown file// re-parses only its appended bytes instead of starting over.export const USAGE_SCAN_CACHE_VERSION = 3 as const; export interface CachedFile {  readonly size: number;  readonly mtimeMs: number;  readonly provider: UsageProviderKind;  /** Records from newline-terminated lines, up to `position.resumeOffset`. */  readonly records: readonly UsageRecord[];  /**   * Records from a trailing segment the writer had not newline-terminated at   * parse time. Kept apart from `records` because an incremental parse   * re-reads that segment and would otherwise double count it.   */  readonly tailRecords: readonly UsageRecord[];  readonly position: TranscriptParsePosition;} export type ScanCache = Map<string, CachedFile>; /** * Row layout for the serialised form. Positional and interned rather than * object-per-record: on a 30-day window that is the difference between a file * measured in tens of megabytes and one under six. */type SerializedRecord = readonly [  timestampMs: number,  modelIndex: number,  sessionIndex: number,  uncachedInputTokens: number,  cachedInputTokens: number,  cacheCreationTokens: number,  outputTokens: number,  reasoningTokens: number,  dedupeKey: string | null,  reportedCostUsd: number | null,]; interface SerializedFile {  readonly s: number;  readonly m: number;  readonly p: UsageProviderKind;  readonly r: readonly SerializedRecord[];  /** Tail records; see `CachedFile.tailRecords`. */  readonly t: readonly SerializedRecord[];  /** Parse position: resume offset, guard length, guard hash. */  readonly o: number;  readonly gl: number;  readonly gh: number;  /** Codex reducer state at `o`; `null` for stateless providers. */  readonly cs: CodexScanState | null;} interface SerializedCache {  readonly version: number;  readonly models: readonly string[];  readonly sessions: readonly string[];  readonly files: Readonly<Record<string, SerializedFile>>;} /** Serialises the cache, interning the repeated model and session strings. */export function encodeScanCache(cache: ScanCache): SerializedCache {  const models: string[] = [];  const sessions: string[] = [];  const modelIndex = new Map<string, number>();  const sessionIndex = new Map<string, number>();   const intern = (table: string[], index: Map<string, number>, value: string): number => {    const existing = index.get(value);    if (existing !== undefined) return existing;    const next = table.length;    table.push(value);    index.set(value, next);    return next;  };   const serializeRecord = (record: UsageRecord): SerializedRecord => [    record.timestampMs,    intern(models, modelIndex, record.model),    intern(sessions, sessionIndex, record.sessionId),    record.totals.uncachedInputTokens,    record.totals.cachedInputTokens,    record.totals.cacheCreationTokens,    record.totals.outputTokens,    record.totals.reasoningTokens,    record.dedupeKey,    record.reportedCostUsd,  ];   const files: Record<string, SerializedFile> = {};  for (const [path, entry] of cache) {    files[path] = {      s: entry.size,      m: entry.mtimeMs,      p: entry.provider,      r: entry.records.map(serializeRecord),      t: entry.tailRecords.map(serializeRecord),      o: entry.position.resumeOffset,      gl: entry.position.guardLength,      gh: entry.position.guardHash,      cs: entry.position.codexState,    };  }   return { version: USAGE_SCAN_CACHE_VERSION, models, sessions, files };} function isRecordArray(value: unknown): value is readonly unknown[] {  return Array.isArray(value);} /** * Rebuilds the cache from a parsed document. * * Anything malformed yields an empty cache rather than an error: a corrupt * cache should cost one cold scan, never a broken page. */export function decodeScanCache(document: unknown): ScanCache {  const cache: ScanCache = new Map();  if (typeof document !== "object" || document === null) return cache;   const root = document as Partial<SerializedCache>;  if (root.version !== USAGE_SCAN_CACHE_VERSION) return cache;  if (!isRecordArray(root.models) || !isRecordArray(root.sessions)) return cache;  if (typeof root.files !== "object" || root.files === null) return cache;   // The intern tables must be all strings: a numeric entry would pass the  // undefined guard below, land in a record's model, and crash the aggregate  // at lookupRate. A corrupt table rejects the whole cache.  if (!root.models.every((value) => typeof value === "string")) return cache;  if (!root.sessions.every((value) => typeof value === "string")) return cache;  const models = root.models as readonly string[];  const sessions = root.sessions as readonly string[];   // Any corrupt row disqualifies the whole entry. Keeping the survivors  // under the original (size, mtime) would read as a valid warm hit and the  // file would never be re-parsed, silently losing the dropped rows' usage.  const decodeRecords = (    rows: readonly unknown[],    provider: UsageProviderKind,  ): UsageRecord[] | null => {    const records: UsageRecord[] = [];    for (const row of rows) {      if (!isRecordArray(row) || row.length < 10) return null;      const [        timestampMs,        modelIndex,        sessionIndex,        uncached,        cached,        cacheCreation,        output,        reasoning,        dedupeKey,        reportedCostUsd,      ] = row as SerializedRecord;       const model = typeof modelIndex === "number" ? models[modelIndex] : undefined;      if (        typeof timestampMs !== "number" ||        !Number.isFinite(timestampMs) ||        model === undefined ||        !Number.isFinite(uncached) ||        !Number.isFinite(cached) ||        !Number.isFinite(cacheCreation) ||        !Number.isFinite(output) ||        !Number.isFinite(reasoning)      ) {        return null;      }       records.push({        provider,        timestampMs,        model,        sessionId: (typeof sessionIndex === "number" ? sessions[sessionIndex] : undefined) ?? "",        totals: {          uncachedInputTokens: uncached,          cachedInputTokens: cached,          cacheCreationTokens: cacheCreation,          outputTokens: output,          reasoningTokens: reasoning,        },        reportedCostUsd: typeof reportedCostUsd === "number" ? reportedCostUsd : null,        dedupeKey: typeof dedupeKey === "string" ? dedupeKey : null,      });    }    return records;  };   for (const [path, raw] of Object.entries(root.files)) {    if (typeof raw !== "object" || raw === null) continue;    const entry = raw as Partial<SerializedFile>;    if (typeof entry.s !== "number" || typeof entry.m !== "number") continue;    if (entry.p !== "claude" && entry.p !== "codex" && entry.p !== "grok") continue;    if (!isRecordArray(entry.r) || !isRecordArray(entry.t)) continue;    // Position fields feed byte offsets and a Buffer allocation in the reader,    // so anything outside their real ranges must reject the entry: a bogus    // guard length would otherwise fail every parse of the file, silently    // dropping its usage instead of costing the documented cold re-parse.    if (      typeof entry.o !== "number" ||      !Number.isSafeInteger(entry.o) ||      entry.o < 0 ||      typeof entry.gl !== "number" ||      !Number.isSafeInteger(entry.gl) ||      entry.gl < 0 ||      entry.gl > GUARD_LENGTH ||      entry.gl > entry.o ||      typeof entry.gh !== "number" ||      !Number.isFinite(entry.gh)    ) {      continue;    }    const codexState = decodeCodexState(entry.cs);    if (codexState === undefined) continue;     const provider: UsageProviderKind = entry.p;    const records = decodeRecords(entry.r, provider);    const tailRecords = decodeRecords(entry.t, provider);    if (records === null || tailRecords === null) continue;     cache.set(path, {      size: entry.s,      mtimeMs: entry.m,      provider,      records,      tailRecords,      position: {        resumeOffset: entry.o,        guardLength: entry.gl,        guardHash: entry.gh,        codexState,      },    });  }   return cache;} /** * Validates a persisted Codex reducer state. Returns `undefined` for a corrupt * value, which disqualifies the entry: resuming with a bad state would attach * appended usage to the wrong model or replay fork-copied history. */function decodeCodexState(value: unknown): CodexScanState | null | undefined {  if (value === null) return null;  if (typeof value !== "object") return undefined;  const state = value as Partial<CodexScanState>;  if (    typeof state.model !== "string" ||    typeof state.sessionId !== "string" ||    (state.lastUsageSignature !== null && typeof state.lastUsageSignature !== "string") ||    typeof state.sawSessionMeta !== "boolean" ||    typeof state.suppressingForkCopies !== "boolean" ||    typeof state.forkCopyAnchorMs !== "number" ||    !Number.isFinite(state.forkCopyAnchorMs)  ) {    return undefined;  }  return {    model: state.model,    sessionId: state.sessionId,    lastUsageSignature: state.lastUsageSignature ?? null,    sawSessionMeta: state.sawSessionMeta,    suppressingForkCopies: state.suppressingForkCopies,    forkCopyAnchorMs: state.forkCopyAnchorMs,  };} export interface PruneOptions {  /** Files the walk just saw. Only meaningful inside the walked window. */  readonly livePaths: ReadonlySet<string>;  /**   * Roots the walk actually completed. Absence from `livePaths` only proves a   * file is gone when its root was walked: a provider whose directory failed to   * resolve this pass must not have its warm entries purged.   */  readonly walkedRoots: readonly string[];  /** Start of the walked window; entries older than this were not looked for. */  readonly windowStartMs: number;  /** Entries older than this are dropped regardless. */  readonly retentionCutoffMs: number;} /** * Drops aged-out entries, and entries for files that have disappeared. * * The walk only covers the requested window, so absence from `livePaths` only * proves deletion for entries *inside* that window. Pruning everything the walk * missed would evict the 30-day entries every time someone looked at 7 days. * * Replaces an earlier record cap that cleared the whole cache once exceeded, * which meant a large enough window never warmed up at all. */export function pruneScanCache(cache: ScanCache, options: PruneOptions): number {  let removed = 0;  for (const [path, entry] of cache) {    const agedOut = entry.mtimeMs < options.retentionCutoffMs;    const underWalkedRoot = options.walkedRoots.some((root) => {      const relative = NodePath.relative(root, path);      return (        relative === "" ||        (relative !== ".." &&          !relative.startsWith(`..${NodePath.sep}`) &&          !NodePath.isAbsolute(relative))      );    });    const deleted =      underWalkedRoot && entry.mtimeMs >= options.windowStartMs && !options.livePaths.has(path);    if (agedOut || deleted) {      cache.delete(path);      removed += 1;    }  }  return removed;} /** * Within-file de-duplication, applied before an entry is cached. * * Callers stitching an incremental parse together pass one `seen` set across * the line and tail record batches so the whole file stays deduplicated as a * unit; the set is mutated in place. */export function dedupeWithinFile(  records: readonly UsageRecord[],  seen: Set<string> = new Set(),): readonly UsageRecord[] {  const kept: UsageRecord[] = [];  for (const record of records) {    if (record.dedupeKey !== null) {      if (seen.has(record.dedupeKey)) continue;      seen.add(record.dedupeKey);    }    kept.push(record);  }  return kept;}