mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-20 11:44:39 +00:00
* Store the session cache in day pieces with a per-provider index Layout v10: a provider's files live in pieces by the UTC day of their newest call, each with a key file, and an index row per file (piece, fingerprint, call span, v9 month) read on every load. A ranged load reads the index, the key files and only the members it reports on; a save rewrites only the pieces it touched. Rows keep v9's full-load order and month scope, so orphans, the first-paint snapshot and dedup see what they saw on v9. The v9 month shards re-lay out once, streaming, and are removed after v10 publishes. * Skip cached transcripts outside a narrow query's months when unchanged v9 could not see them, so every narrow query re-parsed each older Claude transcript it discovered and republished the cache. The index knows their fingerprint; an unchanged one needs nothing, a changed one still re-parses as if uncached. * Read piece members in batches through one buffer, and full loads concurrently * Write piece key files without a sync; rebuild lost keys from the piece * Changelog: day pieces * Seed dedup with the keys of unchanged transcripts outside the query's months before a whole-file parse * Upgrade-path check: verify the v10 envelope, indexes, pieces and key files
159 lines
7 KiB
TypeScript
159 lines
7 KiB
TypeScript
// A ranged load holds the cached files its range cannot report on as stubs.
|
|
// These pin what that must never cost: a save leaves the pieces of stubs
|
|
// alone, a single-line v9 shard re-lays out into pieces, and a wider request
|
|
// in the same process loads the members it now needs.
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
import { mkdir, readFile, rm, writeFile } from 'fs/promises'
|
|
import { existsSync } from 'fs'
|
|
import { tmpdir } from 'os'
|
|
import { join } from 'path'
|
|
|
|
import {
|
|
CACHE_VERSION,
|
|
cacheStubs,
|
|
clearLoadCacheMemo,
|
|
computeEnvFingerprint,
|
|
loadCache,
|
|
markCacheDirty,
|
|
monthScopeForRange,
|
|
saveCache,
|
|
sessionCacheDir,
|
|
type CachedFile,
|
|
type SessionCache,
|
|
} from '../src/session-cache.js'
|
|
|
|
let TMP_DIR: string
|
|
|
|
beforeEach(async () => {
|
|
TMP_DIR = join(tmpdir(), `codeburn-bounded-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`)
|
|
process.env['CODEBURN_CACHE_DIR'] = TMP_DIR
|
|
await mkdir(TMP_DIR, { recursive: true })
|
|
clearLoadCacheMemo()
|
|
})
|
|
|
|
afterEach(async () => {
|
|
if (existsSync(TMP_DIR)) await rm(TMP_DIR, { recursive: true })
|
|
})
|
|
|
|
function fileAt(day: string, key: string, extraTurns = 0): CachedFile {
|
|
const turn = (i: number): CachedFile['turns'][number] => {
|
|
const timestamp = `2026-05-${day}T1${i}:00:00.000Z`
|
|
return {
|
|
timestamp,
|
|
sessionId: key,
|
|
userMessage: 'go',
|
|
calls: [{
|
|
provider: 'claude',
|
|
model: 'claude-sonnet-4-20250514',
|
|
usage: { inputTokens: 10, outputTokens: 5, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0, cacheCreationOneHourTokens: 0 },
|
|
costUSD: 0.01,
|
|
speed: 'standard',
|
|
timestamp,
|
|
tools: [],
|
|
bashCommands: [],
|
|
skills: [],
|
|
subagentTypes: [],
|
|
deduplicationKey: `${key}-${i}`,
|
|
}],
|
|
}
|
|
}
|
|
return { fingerprint: { dev: 1, ino: key.length, mtimeMs: 3, sizeBytes: 4 }, lastCompleteLineOffset: 9, mcpInventory: [], turns: Array.from({ length: 1 + extraTurns }, (_, i) => turn(i)) }
|
|
}
|
|
|
|
const day20 = monthScopeForRange(new Date('2026-05-20T00:00:00.000Z'), new Date('2026-05-20T23:59:59.999Z'))
|
|
|
|
async function publish(files: Record<string, CachedFile>): Promise<void> {
|
|
const cache: SessionCache = { version: CACHE_VERSION, complete: true, providers: { claude: { envFingerprint: computeEnvFingerprint('claude'), files } } }
|
|
markCacheDirty(cache, 'claude')
|
|
expect(await saveCache(cache)).toBe(true)
|
|
clearLoadCacheMemo()
|
|
}
|
|
|
|
async function pieceOf(day: string): Promise<{ name: string; text: string }> {
|
|
const dir = sessionCacheDir()
|
|
const envelope = JSON.parse(await readFile(join(dir, 'envelope.json'), 'utf-8'))
|
|
const index = JSON.parse(await readFile(join(dir, envelope.providers.claude.index), 'utf-8'))
|
|
const name = index.pieces[`2026-05-${day}`] as string
|
|
return { name, text: await readFile(join(dir, name), 'utf-8') }
|
|
}
|
|
|
|
describe('bounded ranged load', () => {
|
|
it('rewrites only the pieces that changed, and keeps full-load order', async () => {
|
|
await publish({ '/p/x.jsonl': fileAt('20', 'x'), '/p/y.jsonl': fileAt('02', 'y'), '/p/w.jsonl': fileAt('03', 'w') })
|
|
const y = await pieceOf('02')
|
|
const w = await pieceOf('03')
|
|
const x = await pieceOf('20')
|
|
expect(x.text.startsWith('{\n')).toBe(true)
|
|
expect(JSON.parse(x.text)).toEqual({ '/p/x.jsonl': fileAt('20', 'x') })
|
|
|
|
const cache = await loadCache(day20)
|
|
const section = cache.providers['claude']!
|
|
expect(Object.keys(section.files)).toEqual(['/p/x.jsonl'])
|
|
expect([...cacheStubs(cache, 'claude')!.keys()]).toEqual(['/p/y.jsonl', '/p/w.jsonl'])
|
|
|
|
// What a re-parse does: delete, then install; plus one new file.
|
|
delete section.files['/p/x.jsonl']
|
|
section.files['/p/x.jsonl'] = fileAt('20', 'x', 1)
|
|
section.files['/p/z.jsonl'] = fileAt('21', 'z')
|
|
markCacheDirty(cache, 'claude', '/p/x.jsonl')
|
|
markCacheDirty(cache, 'claude', '/p/z.jsonl')
|
|
expect(await saveCache(cache)).toBe(true)
|
|
|
|
// The stubs' pieces are not touched at all.
|
|
expect(await pieceOf('02')).toEqual(y)
|
|
expect(await pieceOf('03')).toEqual(w)
|
|
const after = await pieceOf('20')
|
|
expect(after.name).not.toBe(x.name)
|
|
expect(JSON.parse(after.text)).toEqual({ '/p/x.jsonl': fileAt('20', 'x', 1) })
|
|
|
|
// Dirty but unchanged: the published piece is kept.
|
|
markCacheDirty(cache, 'claude', '/p/x.jsonl')
|
|
expect(await saveCache(cache)).toBe(true)
|
|
expect((await pieceOf('20')).name).toBe(after.name)
|
|
|
|
// A full load would hold the re-parsed file after the loaded ones.
|
|
clearLoadCacheMemo()
|
|
const full = await loadCache()
|
|
expect(Object.keys(full.providers['claude']!.files)).toEqual(['/p/y.jsonl', '/p/w.jsonl', '/p/x.jsonl', '/p/z.jsonl'])
|
|
expect(full.providers['claude']!.files['/p/x.jsonl']).toEqual(fileAt('20', 'x', 1))
|
|
})
|
|
|
|
it('re-lays a single-line v9 shard out and still loads stubs from its pieces', async () => {
|
|
const files = { '/p/x.jsonl': fileAt('20', 'x'), '/p/y.jsonl': fileAt('02', 'y') }
|
|
const v9 = join(TMP_DIR, 'session-cache.v9')
|
|
await mkdir(v9, { recursive: true })
|
|
await writeFile(join(v9, 'claude.2026-05.0123456789abcdef.json'), JSON.stringify(files))
|
|
await writeFile(join(v9, 'envelope.json'), JSON.stringify({
|
|
version: 9, complete: true, nonce: 'legacy',
|
|
providers: { claude: { envFingerprint: computeEnvFingerprint('claude'), complete: true, shards: { '2026-05': { name: 'claude.2026-05.0123456789abcdef.json', until: '2026-05' } } } },
|
|
}))
|
|
|
|
const cache = await loadCache(day20)
|
|
expect(existsSync(v9)).toBe(false)
|
|
expect(Object.keys(cache.providers['claude']!.files)).toEqual(['/p/x.jsonl'])
|
|
expect([...cacheStubs(cache, 'claude')!.keys()]).toEqual(['/p/y.jsonl'])
|
|
expect(JSON.parse((await pieceOf('02')).text)).toEqual({ '/p/y.jsonl': fileAt('02', 'y') })
|
|
|
|
// A wider request in this process loads the stub from its piece.
|
|
const wider = await loadCache(monthScopeForRange(new Date('2026-05-01T00:00:00.000Z'), new Date('2026-05-20T23:59:59.999Z')))
|
|
expect(wider).toBe(cache)
|
|
expect(cacheStubs(cache, 'claude')!.size).toBe(0)
|
|
expect(cache.providers['claude']!.files).toEqual(files)
|
|
})
|
|
|
|
it('reads the same members as a full load for every range', async () => {
|
|
const files = { '/p/a.jsonl': fileAt('01', 'a'), '/p/b.jsonl': fileAt('15', 'b', 2), '/p/c.jsonl': fileAt('20', 'c') }
|
|
await publish(files)
|
|
for (const [from, to] of [['01', '01'], ['15', '15'], ['02', '14'], ['01', '31']]) {
|
|
clearLoadCacheMemo()
|
|
const cache = await loadCache(monthScopeForRange(new Date(`2026-05-${from}T00:00:00.000Z`), new Date(`2026-05-${to}T23:59:59.999Z`)))
|
|
const held = { ...cache.providers['claude']!.files }
|
|
for (const [path, stub] of cacheStubs(cache, 'claude') ?? []) {
|
|
expect(stub.keys).toEqual(files[path as keyof typeof files].turns.map(t => t.calls.map(c => c.deduplicationKey)))
|
|
expect(stub.fingerprint).toEqual(files[path as keyof typeof files].fingerprint)
|
|
held[path] = files[path as keyof typeof files]
|
|
}
|
|
expect(held).toEqual(files)
|
|
}
|
|
})
|
|
})
|