codeburn/tests/bounded-load-parse.test.ts
Resham Joshi 22ebfd463e
perf: load only the session-cache members a date range can use (#1542)
* Write session shards one member per line

A shard is still one JSON object, so older builds read it unchanged, but it
is written through a stream and never exists as one string. Past V8's string
limit the save used to throw and the cache silently never persisted; reads of
such a shard now go member by member.

* Hold cached files a ranged query cannot report on as stubs

A ranged load streams its in-scope shards and keeps in full only the files
with a call in the range, spawn anchors, failure markers, turn-less files and
WSL paths. Every other file becomes a stub: its fingerprint, bucket, PR flag,
dedup keys per turn and where its member line lives. A stub stands in for its
entry everywhere but the summaries it cannot contribute to: reconcile, the
seenMsgIds and parser dedup pre-seeds, the query-time key replay in serve
order, orphan surfacing, eviction and the emptiness checks. A stub whose file
changed loads its member first, so the append or re-parse runs as before, and
a wider request in the same process loads the stubs it now needs.

A bucket holding stubs is rewritten as a full load would write it, copying
the stubs' lines from the shard they came from; unchanged, the published
shard is kept. A single-line shard from an older build is rewritten on the
next save. Providers whose parse reads cached entries no report shows
(hermes, quickdesk, antigravity, devin, gemini) still load in full.

* Discover codex projects from a small index

Discovery only needs each rollout's project, but it loaded the whole codex
results file (hundreds of MB on a large corpus) on every run to get it.
flushCodexCache now also writes codex-projects.v15.json, keyed by the same
fingerprint, and discovery reads that. A missing index is built once from the
results file.

* Load the session cache once for status today and month

status asks for today, then the month. A command can now declare the union of
its ranges, so the first load already holds the month in full and the second
request finds it there; each request keeps its own month scope and summaries.

* Release the pre-lock cache view before reloading

Once the refresh lock is acquired or another process completed the refresh,
the snapshot loaded before the wait is never used again, but it stayed
referenced while the reload read a second view.

* Test the first-paint snapshot serves stubs in load order

* Parse a shard whole when the range covers all its months

A range that spans a shard's months needs nearly every member, and streaming
it one member at a time only added allocation churn (about 200MB more RSS on
report -p all over a 1GB cache). The streaming reader also reuses one read
buffer instead of allocating a fresh one per chunk.

* Parse a shard whole once the range reaches its oldest month

The previous rule also required the range to reach past the shard's newest
month, which a range ending now never does, so report -p all still streamed
every shard.

* Carry a shard line that spans reads as decoded text

Members of a megabyte and more span the 1MB reads, and joining their pieces
as Buffers allocated off-heap memory that only a GC returns: report -p all
over a 1GB cache peaked about 170MB above main in RSS. Decoding the pieces
with a StringDecoder keeps them on the heap and brings every streamed range
below main, so the whole-file parse for wide ranges goes again.

* Changelog: bounded session-cache load
2026-09-24 19:52:42 +02:00

140 lines
7.4 KiB
TypeScript

// End to end through parseProviderSources: a ranged parse holds out-of-range
// rollouts as stubs, and a stub must still do everything its entry would
// outside the summaries (claim its dedup keys in serve order, be evicted from
// disk when its source is deleted). Codex captures CODEX_HOME when its module
// is first imported, hence the env before the dynamic imports.
import { afterAll, beforeEach, describe, expect, it } from 'vitest'
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs'
import { readFile } from 'fs/promises'
import { join } from 'path'
import { tmpdir } from 'os'
import type { DateRange } from '../src/types.js'
const tmpDir = mkdtempSync(join(tmpdir(), 'bounded-load-'))
const savedCodexHome = process.env['CODEX_HOME']
process.env['CODEX_HOME'] = join(tmpDir, 'codex')
const { cacheStubs, clearLoadCacheMemo, loadCache, markCacheDirty, monthScopeForRange, saveCache, sessionCacheDir } = await import('../src/session-cache.js')
const { clearSessionCache, parseAllSessions, withColdFirstPaintFloor } = await import('../src/parser.js')
const { clearCodexMemCaches } = await import('../src/codex-cache.js')
const ENV_KEYS = ['CODEBURN_CACHE_DIR', 'CLAUDE_CONFIG_DIR', 'CLAUDE_CONFIG_DIRS', 'CODEBURN_DESKTOP_SESSIONS_DIR', 'CODEBURN_CACHE_SCOPE'] as const
const saved = Object.fromEntries(ENV_KEYS.map(k => [k, process.env[k]]))
let run = 0
function rollout(day: string, sid: string, startIso: string, n: number): string {
const dir = join(tmpDir, 'codex', 'sessions', '2099', day.slice(0, 2), day.slice(3))
mkdirSync(dir, { recursive: true })
const path = join(dir, `rollout-2099-${day}T10-00-00-${sid}.jsonl`)
const lines = [
{ type: 'session_meta', timestamp: startIso, payload: { id: sid, session_id: sid, cwd: `/work/${sid}`, originator: 'codex_cli_rs', model: 'gpt-5.3-codex', cli_version: '0.50.0' } },
{ type: 'turn_context', timestamp: startIso, payload: { cwd: `/work/${sid}`, model: 'gpt-5.3-codex' } },
]
const total = { input_tokens: 0, cached_input_tokens: 0, output_tokens: 0, reasoning_output_tokens: 0, total_tokens: 0 }
for (let i = 0; i < n; i++) {
const ts = new Date(Date.parse(startIso) + i * 60_000).toISOString()
const last = { input_tokens: 1000 + i, cached_input_tokens: 100, output_tokens: 50 + i, reasoning_output_tokens: 0, total_tokens: 1050 + 2 * i }
for (const k of Object.keys(total) as Array<keyof typeof total>) total[k] += last[k]
lines.push({ type: 'response_item', timestamp: ts, payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: `step ${i}` }] } } as never)
lines.push({ type: 'event_msg', timestamp: ts, payload: { type: 'token_count', info: { last_token_usage: last, total_token_usage: { ...total } } } } as never)
}
writeFileSync(path, lines.map(l => JSON.stringify(l)).join('\n') + '\n')
return path
}
const may1: DateRange = { start: new Date('2099-05-01T00:00:00.000Z'), end: new Date('2099-05-01T23:59:59.999Z') }
const calls = (projects: Awaited<ReturnType<typeof parseAllSessions>>): number => projects.reduce((n, p) => n + p.totalApiCalls, 0)
const cost = (projects: Awaited<ReturnType<typeof parseAllSessions>>): number => projects.reduce((n, p) => n + p.totalCostUSD, 0)
beforeEach(() => {
rmSync(join(tmpDir, 'codex'), { recursive: true, force: true })
process.env['CODEBURN_CACHE_DIR'] = join(tmpDir, `cache-${run++}`)
process.env['CLAUDE_CONFIG_DIR'] = join(tmpDir, 'no-claude')
process.env['CODEBURN_DESKTOP_SESSIONS_DIR'] = join(tmpDir, 'no-desktop')
delete process.env['CLAUDE_CONFIG_DIRS']
delete process.env['CODEBURN_CACHE_SCOPE']
clearSessionCache()
clearLoadCacheMemo()
clearCodexMemCaches()
})
afterAll(() => {
for (const k of ENV_KEYS) { if (saved[k] === undefined) delete process.env[k]; else process.env[k] = saved[k] }
if (savedCodexHome === undefined) delete process.env['CODEX_HOME']
else process.env['CODEX_HOME'] = savedCodexHome
rmSync(tmpDir, { recursive: true, force: true })
})
async function reparse(range: DateRange, scope?: 'all'): Promise<Awaited<ReturnType<typeof parseAllSessions>>> {
clearSessionCache()
clearLoadCacheMemo()
clearCodexMemCaches()
if (scope) process.env['CODEBURN_CACHE_SCOPE'] = scope
else delete process.env['CODEBURN_CACHE_SCOPE']
return parseAllSessions(range, 'codex')
}
describe('bounded load through the provider parse', () => {
it('replays a stub\'s dedup keys in serve order, exactly as its full entry would', async () => {
const old = rollout('04-10', 'aaold', '2099-04-10T10:00:00.000Z', 3)
const today = rollout('05-01', 'bbtoday', '2099-05-01T10:00:00.000Z', 2)
await parseAllSessions(undefined, 'codex')
// Give the in-range file a turn that carries one of the old file's keys, as
// a file parsed before the other was cached can.
clearLoadCacheMemo()
const full = await loadCache()
const section = full.providers['codex']!
const oldKey = section.files[old]!.turns[0]!.calls[0]!.deduplicationKey
section.files[today]!.turns[1]!.calls[0]!.deduplicationKey = oldKey
markCacheDirty(full, 'codex', today)
expect(await saveCache(full)).toBe(true)
clearLoadCacheMemo()
const scoped = await loadCache(monthScopeForRange(may1.start, may1.end))
expect([...cacheStubs(scoped, 'codex')!.keys()]).toEqual([old])
const bounded = await reparse(may1)
const reference = await reparse(may1, 'all')
expect(calls(bounded)).toBe(1)
expect(calls(bounded)).toBe(calls(reference))
expect(cost(bounded)).toBe(cost(reference))
// The first-paint snapshot serves every cached entry as an orphan, in load
// order: the stub has to take its full entry's place in that order too.
const snapshot = async (scope?: 'all') => {
clearSessionCache(); clearLoadCacheMemo(); clearCodexMemCaches()
if (scope) process.env['CODEBURN_CACHE_SCOPE'] = scope
else delete process.env['CODEBURN_CACHE_SCOPE']
return (await withColdFirstPaintFloor(may1.start, () => parseAllSessions(may1, 'codex'), true, true)).result
}
const boundedSnapshot = await snapshot()
expect(calls(boundedSnapshot)).toBe(1)
expect(JSON.stringify(boundedSnapshot)).toBe(JSON.stringify(await snapshot('all')))
})
it('evicts a deleted transcript that was only a stub from its shard on disk', async () => {
const doomed = rollout('04-10', 'aadoomed', '2099-04-10T10:00:00.000Z', 2)
const kept = rollout('04-12', 'cckept', '2099-04-12T10:00:00.000Z', 2)
rollout('05-01', 'bbtoday', '2099-05-01T10:00:00.000Z', 2)
await parseAllSessions(undefined, 'codex')
const aprilShard = async (): Promise<string> => {
const envelope = JSON.parse(await readFile(join(sessionCacheDir(), 'envelope.json'), 'utf-8'))
return readFile(join(sessionCacheDir(), envelope.providers.codex.shards['2099-04'].name), 'utf-8')
}
const before = await aprilShard()
const line = (text: string, path: string) => text.split('\n').find(l => l.startsWith(JSON.stringify(path) + ':'))?.replace(/,$/, '')
expect(line(before, doomed)).toBeDefined()
clearLoadCacheMemo()
const scoped = await loadCache(monthScopeForRange(may1.start, may1.end))
expect([...cacheStubs(scoped, 'codex')!.keys()].sort()).toEqual([doomed, kept].sort())
rmSync(doomed)
await reparse(may1)
const after = await aprilShard()
expect(after).not.toContain(doomed)
expect(line(after, kept)).toBe(line(before, kept))
expect(Object.keys(JSON.parse(after))).toEqual([kept])
})
})