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
4 *4 *
5 * Isolated here so the rest of the usage code stays on Effect's `FileSystem`.5 * Isolated here so the rest of the usage code stays on Effect's `FileSystem`.
6 * The direct `node:fs` streaming is deliberate: a cold 30-day window is ~1.4 GB6 * 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 of7 * across ~1,500 files, and buffer-level streaming is roughly an order of
8 * magnitude cheaper than materialising each file. The equivalent Effect stream8 * magnitude cheaper than materialising each file. The equivalent Effect stream
9 * pipeline is idiomatic but not fast enough to sit behind a page load.9 * pipeline is idiomatic but not fast enough to sit behind a page load.
10 *10 *
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 *
11 * @module usageTranscriptReader16 * @module usageTranscriptReader
12 */17 */
13import * as NodeFS from "node:fs";
14import * as NodeFSP from "node:fs/promises";18import * as NodeFSP from "node:fs/promises";
15import * as NodePath from "node:path";19import * as NodePath from "node:path";
16import * as NodeReadline from "node:readline";
17 20
18import type { UsageProviderKind } from "@t3tools/contracts";21import type { UsageProviderKind } from "@t3tools/contracts";
19 22
@@ -23,6 +26,7 @@ import { · 3 unmodified lines
23 parseClaudeLine,26 parseClaudeLine,
24 parseCodexLine,27 parseCodexLine,
25 parseGrokLine,28 parseGrokLine,
29 type CodexScanState,
26 type UsageRecord,30 type UsageRecord,
27} from "./usageTranscripts.ts";31} from "./usageTranscripts.ts";
28 32
@@ -32,6 +36,56 @@ export interface TranscriptFile { · 3 unmodified lines
32 readonly mtimeMs: number;36 readonly mtimeMs: number;
33}37}
34 38
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 */
50export 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
61export 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. */
76export const GUARD_LENGTH = 64;
77const NEWLINE = 0x0a;
78const CARRIAGE_RETURN = 0x0d;
79
80function 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
35/**89/**
36 * Lists `.jsonl` transcripts under `root` last modified at or after `sinceMs`.90 * Lists `.jsonl` transcripts under `root` last modified at or after `sinceMs`.
37 *91 *
@@ -100,6 +154,25 @@ export async function readDirectoryVolumeId(path: string): Promise<string> { · 62 unmodified lines
100 }154 }
101}155}
102 156
157async 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
103/**176/**
104 * Streams one transcript and returns the usage records it contains, or `null`177 * Streams one transcript and returns the usage records it contains, or `null`
105 * when the file could not be read.178 * when the file could not be read.
@@ -109,6 +182,10 @@ export async function readDirectoryVolumeId(path: string): Promise<string> { · 3 unmodified lines
109 * under the same `(size, mtime)` key would silently drop that file's usage182 * under the same `(size, mtime)` key would silently drop that file's usage
110 * until the file next changes.183 * until the file next changes.
111 *184 *
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 *
112 * Codex carries the active model on `turn_context` lines that hold no usage of189 * Codex carries the active model on `turn_context` lines that hold no usage of
113 * their own, so those still have to pass through the reducer to keep model190 * their own, so those still have to pass through the reducer to keep model
114 * attribution correct.191 * attribution correct.
@@ -116,43 +193,121 @@ export async function readDirectoryVolumeId(path: string): Promise<string> { · 1 unmodified line
116export async function readTranscriptRecords(193export async function readTranscriptRecords(
117 filePath: string,194 filePath: string,
118 provider: UsageProviderKind,195 provider: UsageProviderKind,
119): Promise<readonly UsageRecord[] | null> {196 resumeFrom?: TranscriptParsePosition,
120 const records: UsageRecord[] = [];197): Promise<TranscriptParseResult | null> {
121 const codexState = initialCodexScanState();198 let handle: NodeFSP.FileHandle;
199 try {
200 handle = await NodeFSP.open(filePath, "r");
201 } catch {
202 return null;
203 }
122 204
123 try {205 try {
124 const lines = NodeReadline.createInterface({206 let codexState = initialCodexScanState();
125 input: NodeFS.createReadStream(filePath, { encoding: "utf8" }),207 let resumed = false;
126 crlfDelay: Infinity,208 let start = 0;
127 });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 }
128 219
129 for await (const line of lines) {220 const parseLine = (line: string, state: CodexScanState, out: UsageRecord[]): void => {
130 if (provider === "codex") {221 if (provider === "codex") {
131 if (222 if (
132 !mightCarryUsage(line, provider) &&223 !mightCarryUsage(line, provider) &&
133 !line.includes('"turn_context"') &&224 !line.includes('"turn_context"') &&
134 !line.includes('"session_meta"')225 !line.includes('"session_meta"')
135 ) {226 ) {
136 continue;227 return;
137 }228 }
138 const record = parseCodexLine(line, codexState);229 const record = parseCodexLine(line, state);
139 if (record !== null) records.push(record);230 if (record !== null) out.push(record);
140 continue;231 return;
141 }232 }
142 233 if (!mightCarryUsage(line, provider)) return;
143 if (provider === "grok") {234 if (provider === "grok") {
144 if (!mightCarryUsage(line, provider)) continue;235 for (const grokRecord of parseGrokLine(line)) out.push(grokRecord);
145 for (const grokRecord of parseGrokLine(line)) records.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);
146 continue;264 continue;
147 }265 }
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 }
148 279
149 if (!mightCarryUsage(line, provider)) continue;280 // A trailing segment without its newline is parsed for this result but not
150 const record = parseClaudeLine(line);281 // consumed: a writer may still be appending to it, and counting a half
151 if (record !== null) records.push(record);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);
152 }287 }
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 };
153 } catch {308 } catch {
154 return null;309 return null;
310 } finally {
311 await handle.close().catch(() => undefined);
155 }312 }
156
157 return records;
158}313}