History

perf(server): scan only appended transcript bytes for usage summaries (#9024)

Wout Stiens committed 2 months ago

Commit 8f1ef8b1 parent 0bfb6df6 files · +963 −90Browse files

Showing the changes to apps/server/src/usage/usageTranscriptReader.ts (1 of 6 files). Show all

Unified · Split

apps/server/src/usage/usageTranscriptReader.ts+178−23
@@ -4,16 +4,19 @@ · 3 unmodified lines
44 *
55 * Isolated here so the rest of the usage code stays on Effect's `FileSystem`.
66 * The direct `node:fs` streaming is deliberate: a cold 30-day window is ~1.4 GB
7− * across ~1,500 files, and `readline` over a read stream is roughly an order of
7+ * across ~1,500 files, and buffer-level streaming is roughly an order of
88 * magnitude cheaper than materialising each file. The equivalent Effect stream
99 * pipeline is idiomatic but not fast enough to sit behind a page load.
1010 *
11+ * Transcripts are append-only, so a parse also reports the byte position it
12+ * stopped at. A later scan of the same file resumes from that position and
13+ * parses only the appended bytes, which is what keeps a warm scan cheap while a
14+ * session is actively writing a multi-hundred-megabyte rollout.
15+ *
1116 * @module usageTranscriptReader
1217 */
13−import * as NodeFS from "node:fs";
1418 import * as NodeFSP from "node:fs/promises";
1519 import * as NodePath from "node:path";
16−import * as NodeReadline from "node:readline";
1720
1821 import type { UsageProviderKind } from "@t3tools/contracts";
1922
@@ -23,6 +26,7 @@ import { · 3 unmodified lines
2326 parseClaudeLine,
2427 parseCodexLine,
2528 parseGrokLine,
29+ type CodexScanState,
2630 type UsageRecord,
2731 } from "./usageTranscripts.ts";
2832
@@ -32,6 +36,56 @@ export interface TranscriptFile { · 3 unmodified lines
3236 readonly mtimeMs: number;
3337 }
3438
39+/**
40+ * Where a parse stopped, with enough state to continue from there.
41+ *
42+ * The guard hash fingerprints the bytes immediately before `resumeOffset`. A
43+ * resume only proceeds when those bytes still match: transcripts are
44+ * append-only by design, but a rotated or rewritten file silently mis-parsed
45+ * from the middle would corrupt usage totals. The window is a cheap tripwire
46+ * for those realistic failure shapes, all of which disturb the file's tail at
47+ * that exact offset; it deliberately does not hash the whole prefix, which
48+ * would cost the full re-read the resume exists to avoid.
49+ */
50+export interface TranscriptParsePosition {
51+ /** Byte offset just past the last newline-terminated line consumed. */
52+ readonly resumeOffset: number;
53+ /** Length of the fingerprinted window ending at `resumeOffset`. */
54+ readonly guardLength: number;
55+ /** FNV-1a hash of that window. */
56+ readonly guardHash: number;
57+ /** Codex reducer state as of `resumeOffset`; `null` for stateless providers. */
58+ readonly codexState: CodexScanState | null;
59+}
60+
61+export interface TranscriptParseResult {
62+ /** Records from newline-terminated lines at or after the parse start. */
63+ readonly records: readonly UsageRecord[];
64+ /**
65+ * Records from a trailing segment the writer has not newline-terminated yet.
66+ * Kept out of `records` because `position` deliberately excludes that
67+ * segment: the next scan re-reads it once the writer finishes the line.
68+ */
69+ readonly tailRecords: readonly UsageRecord[];
70+ readonly position: TranscriptParsePosition;
71+ /** Whether the parse continued from `resumeFrom` rather than byte 0. */
72+ readonly resumed: boolean;
73+}
74+
75+/** 64 bytes of JSONL tail is ample to distinguish a replaced file. */
76+export const GUARD_LENGTH = 64;
77+const NEWLINE = 0x0a;
78+const CARRIAGE_RETURN = 0x0d;
79+
80+function fnv1a(buffer: Buffer): number {
81+ let hash = 0x811c9dc5;
82+ for (let index = 0; index < buffer.length; index += 1) {
83+ hash ^= buffer[index]!;
84+ hash = Math.imul(hash, 0x01000193);
85+ }
86+ return hash >>> 0;
87+}
88+
3589 /**
3690 * Lists `.jsonl` transcripts under `root` last modified at or after `sinceMs`.
3791 *
@@ -100,6 +154,25 @@ export async function readDirectoryVolumeId(path: string): Promise<string> { · 62 unmodified lines
100154 }
101155 }
102156
157+async function guardMatches(
158+ handle: NodeFSP.FileHandle,
159+ position: TranscriptParsePosition,
160+): Promise<boolean> {
161+ if (position.guardLength <= 0 || position.guardLength > GUARD_LENGTH) return false;
162+ try {
163+ const window = Buffer.alloc(position.guardLength);
164+ const { bytesRead } = await handle.read(
165+ window,
166+ 0,
167+ position.guardLength,
168+ position.resumeOffset - position.guardLength,
169+ );
170+ return bytesRead === position.guardLength && fnv1a(window) === position.guardHash;
171+ } catch {
172+ return false;
173+ }
174+}
175+
103176 /**
104177 * Streams one transcript and returns the usage records it contains, or `null`
105178 * when the file could not be read.
@@ -109,6 +182,10 @@ export async function readDirectoryVolumeId(path: string): Promise<string> { · 3 unmodified lines
109182 * under the same `(size, mtime)` key would silently drop that file's usage
110183 * until the file next changes.
111184 *
185+ * With `resumeFrom`, parsing continues from that position when its guard bytes
186+ * still match, so only appended lines are read; otherwise the whole file is
187+ * re-parsed from the start and `resumed` reports `false`.
188+ *
112189 * Codex carries the active model on `turn_context` lines that hold no usage of
113190 * their own, so those still have to pass through the reducer to keep model
114191 * attribution correct.
@@ -116,43 +193,121 @@ export async function readDirectoryVolumeId(path: string): Promise<string> { · 1 unmodified line
116193 export async function readTranscriptRecords(
117194 filePath: string,
118195 provider: UsageProviderKind,
119−): Promise<readonly UsageRecord[] | null> {
120− const records: UsageRecord[] = [];
121− const codexState = initialCodexScanState();
196+ resumeFrom?: TranscriptParsePosition,
197+): Promise<TranscriptParseResult | null> {
198+ let handle: NodeFSP.FileHandle;
199+ try {
200+ handle = await NodeFSP.open(filePath, "r");
201+ } catch {
202+ return null;
203+ }
122204
123205 try {
124− const lines = NodeReadline.createInterface({
125− input: NodeFS.createReadStream(filePath, { encoding: "utf8" }),
126− crlfDelay: Infinity,
127− });
206+ let codexState = initialCodexScanState();
207+ let resumed = false;
208+ let start = 0;
209+ if (
210+ resumeFrom !== undefined &&
211+ resumeFrom.resumeOffset > 0 &&
212+ (provider !== "codex" || resumeFrom.codexState !== null) &&
213+ (await guardMatches(handle, resumeFrom))
214+ ) {
215+ if (resumeFrom.codexState !== null) codexState = { ...resumeFrom.codexState };
216+ start = resumeFrom.resumeOffset;
217+ resumed = true;
218+ }
128219
129− for await (const line of lines) {
220+ const parseLine = (line: string, state: CodexScanState, out: UsageRecord[]): void => {
130221 if (provider === "codex") {
131222 if (
132223 !mightCarryUsage(line, provider) &&
133224 !line.includes('"turn_context"') &&
134225 !line.includes('"session_meta"')
135226 ) {
136− continue;
227+ return;
137228 }
138− const record = parseCodexLine(line, codexState);
139− if (record !== null) records.push(record);
140− continue;
229+ const record = parseCodexLine(line, state);
230+ if (record !== null) out.push(record);
231+ return;
141232 }
142−
233+ if (!mightCarryUsage(line, provider)) return;
143234 if (provider === "grok") {
144− if (!mightCarryUsage(line, provider)) continue;
145− for (const grokRecord of parseGrokLine(line)) records.push(grokRecord);
235+ for (const grokRecord of parseGrokLine(line)) out.push(grokRecord);
236+ return;
237+ }
238+ const record = parseClaudeLine(line);
239+ if (record !== null) out.push(record);
240+ };
241+
242+ const toLineString = (lineBuffer: Buffer): string => {
243+ const content =
244+ lineBuffer.length > 0 && lineBuffer[lineBuffer.length - 1] === CARRIAGE_RETURN
245+ ? lineBuffer.subarray(0, -1)
246+ : lineBuffer;
247+ return content.toString("utf8");
248+ };
249+
250+ const records: UsageRecord[] = [];
251+ // Buffer-level line splitting rather than `readline`, because resuming
252+ // needs byte-exact offsets and decoded strings cannot provide them.
253+ // Newline-free chunks are collected rather than concatenated as they
254+ // arrive, so a single huge line costs one copy instead of one per chunk.
255+ let resumeOffset = start;
256+ let pendingChunks: Buffer[] = [];
257+ const stream = handle.createReadStream({
258+ start,
259+ autoClose: false,
260+ }) as AsyncIterable<Buffer>;
261+ for await (const chunk of stream) {
262+ if (!chunk.includes(NEWLINE)) {
263+ pendingChunks.push(chunk);
146264 continue;
147265 }
266+ const buffer: Buffer =
267+ pendingChunks.length === 0 ? chunk : Buffer.concat([...pendingChunks, chunk]);
268+ pendingChunks = [];
269+ let lineStart = 0;
270+ for (;;) {
271+ const newlineIndex = buffer.indexOf(NEWLINE, lineStart);
272+ if (newlineIndex === -1) break;
273+ parseLine(toLineString(buffer.subarray(lineStart, newlineIndex)), codexState, records);
274+ lineStart = newlineIndex + 1;
275+ }
276+ resumeOffset += lineStart;
277+ if (lineStart < buffer.length) pendingChunks.push(buffer.subarray(lineStart));
278+ }
148279
149− if (!mightCarryUsage(line, provider)) continue;
150− const record = parseClaudeLine(line);
151− if (record !== null) records.push(record);
280+ // A trailing segment without its newline is parsed for this result but not
281+ // consumed: a writer may still be appending to it, and counting a half
282+ // record now and its full form later would double count.
283+ const tailRecords: UsageRecord[] = [];
284+ if (pendingChunks.length > 0) {
285+ const pending = pendingChunks.length === 1 ? pendingChunks[0]! : Buffer.concat(pendingChunks);
286+ if (pending.length > 0) parseLine(toLineString(pending), { ...codexState }, tailRecords);
152287 }
288+
289+ const guardLength = Math.min(GUARD_LENGTH, resumeOffset);
290+ let guardHash = 0;
291+ if (guardLength > 0) {
292+ const window = Buffer.alloc(guardLength);
293+ await handle.read(window, 0, guardLength, resumeOffset - guardLength);
294+ guardHash = fnv1a(window);
295+ }
296+
297+ return {
298+ records,
299+ tailRecords,
300+ position: {
301+ resumeOffset,
302+ guardLength,
303+ guardHash,
304+ codexState: provider === "codex" ? codexState : null,
305+ },
306+ resumed,
307+ };
153308 } catch {
154309 return null;
310+ } finally {
311+ await handle.close().catch(() => undefined);
155312 }
156−
157− return records;
158313 }