255 lines · 8.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 */import type { UsageProviderKind } from "@t3tools/contracts"; import type { 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.export const USAGE_SCAN_CACHE_VERSION = 2 as const; export interface CachedFile {  readonly size: number;  readonly mtimeMs: number;  readonly provider: UsageProviderKind;  readonly records: readonly UsageRecord[];} 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[];} 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 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((record) => [        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,      ]),    };  }   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 normalizeModelName. 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[];   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") continue;    if (!isRecordArray(entry.r)) continue;     const provider: UsageProviderKind = entry.p;    const records: UsageRecord[] = [];    // 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.    let corrupt = false;    for (const row of entry.r) {      if (!isRecordArray(row) || row.length < 10) {        corrupt = true;        break;      }      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)      ) {        corrupt = true;        break;      }       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,      });    }     if (corrupt) continue;    cache.set(path, { size: entry.s, mtimeMs: entry.m, provider, records });  }   return cache;} 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) => path.startsWith(root));    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. */export function dedupeWithinFile(records: readonly UsageRecord[]): readonly UsageRecord[] {  const seen = new Set<string>();  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;}