Showing the changes to apps/server/src/usage/usageTranscriptReader.ts (1 of 6 files). Show all
Unified · Split
| @@ -4,16 +4,19 @@ · 3 unmodified lines | ||
| 4 | 4 | * |
| 5 | 5 | * Isolated here so the rest of the usage code stays on Effect's `FileSystem`. |
| 6 | 6 | * 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 | |
| 8 | 8 | * magnitude cheaper than materialising each file. The equivalent Effect stream |
| 9 | 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 | 16 | * @module usageTranscriptReader |
| 12 | 17 | */ |
| 13 | −import * as NodeFS from "node:fs"; | |
| 14 | 18 | import * as NodeFSP from "node:fs/promises"; |
| 15 | 19 | import * as NodePath from "node:path"; |
| 16 | −import * as NodeReadline from "node:readline"; | |
| 17 | 20 | |
| 18 | 21 | import type { UsageProviderKind } from "@t3tools/contracts"; |
| 19 | 22 | |
| @@ -23,6 +26,7 @@ import { · 3 unmodified lines | ||
| 23 | 26 | parseClaudeLine, |
| 24 | 27 | parseCodexLine, |
| 25 | 28 | parseGrokLine, |
| 29 | + type CodexScanState, | |
| 26 | 30 | type UsageRecord, |
| 27 | 31 | } from "./usageTranscripts.ts"; |
| 28 | 32 | |
| @@ -32,6 +36,56 @@ export interface TranscriptFile { · 3 unmodified lines | ||
| 32 | 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 | + */ | |
| 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 | + | |
| 35 | 89 | /** |
| 36 | 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 | |
| 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 | + | |
| 103 | 176 | /** |
| 104 | 177 | * Streams one transcript and returns the usage records it contains, or `null` |
| 105 | 178 | * when the file could not be read. |
| @@ -109,6 +182,10 @@ export async function readDirectoryVolumeId(path: string): Promise<string> { · 3 unmodified lines | ||
| 109 | 182 | * under the same `(size, mtime)` key would silently drop that file's usage |
| 110 | 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 | 189 | * Codex carries the active model on `turn_context` lines that hold no usage of |
| 113 | 190 | * their own, so those still have to pass through the reducer to keep model |
| 114 | 191 | * attribution correct. |
| @@ -116,43 +193,121 @@ export async function readDirectoryVolumeId(path: string): Promise<string> { · 1 unmodified line | ||
| 116 | 193 | export async function readTranscriptRecords( |
| 117 | 194 | filePath: string, |
| 118 | 195 | 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 | + } | |
| 122 | 204 | |
| 123 | 205 | 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 | + } | |
| 128 | 219 | |
| 129 | − for await (const line of lines) { | |
| 220 | + const parseLine = (line: string, state: CodexScanState, out: UsageRecord[]): void => { | |
| 130 | 221 | if (provider === "codex") { |
| 131 | 222 | if ( |
| 132 | 223 | !mightCarryUsage(line, provider) && |
| 133 | 224 | !line.includes('"turn_context"') && |
| 134 | 225 | !line.includes('"session_meta"') |
| 135 | 226 | ) { |
| 136 | − continue; | |
| 227 | + return; | |
| 137 | 228 | } |
| 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; | |
| 141 | 232 | } |
| 142 | − | |
| 233 | + if (!mightCarryUsage(line, provider)) return; | |
| 143 | 234 | 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); | |
| 146 | 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; | |
| 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); | |
| 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 | 308 | } catch { |
| 154 | 309 | return null; |
| 310 | + } finally { | |
| 311 | + await handle.close().catch(() => undefined); | |
| 155 | 312 | } |
| 156 | − | |
| 157 | − return records; | |
| 158 | 313 | } |