codeburn/tests/discovery-concurrency.test.ts
iamtoruk d5e485f415 perf: fan out the discovery sweep instead of walking it one syscall at a time
Every dated command re-walks and re-stats every provider tree before it can
decide what the cache already covers. That sweep was strictly serial at every
level -- one readdir, one stat, one state.json read at a time, and one provider
after another -- so on a 21k-file / 9-provider corpus it owned most of a warm
run's wall clock with the machine idle waiting on the kernel.

Measured per warm `today` pass on that corpus before this change: 859 ms in
discoverAllSessions (kimicode 467, codex 143, claude 135, grok 77) and 727 ms in
the Claude project walk + fingerprint pass.

- fs-utils: mapWithConcurrency + FS_SCAN_CONCURRENCY, one bounded, order-
  preserving helper for the whole sweep.
- providers/index: run provider discovery concurrently, concatenated in
  registry order.
- claude/codex/grok/kimicode: walk each level with the level fanned out,
  re-concatenated in readdir order before anything reconciles.
- parser: the Claude dir walk and both fingerprint passes (scanProjectDirs and
  parseProviderSources) fan out, then reconcile serially in discovery order,
  which is what changedFiles ordering and the seenMsgIds pre-seed depend on.
- parser: collectJsonlFiles reads entries with their types, so a plain file no
  longer costs a wasted subagents/ probe.
- codex-cache: share one in-flight load between concurrent readers. The memo is
  only populated after the read + parse resolves, so concurrent discovery had
  every caller re-reading and re-parsing the same (here 59 MB) file.

No reconciliation logic changed: the same fingerprints reach the same cache
comparison in the same order. Warm `today` 5.30s -> 2.99s, cold 34.7s -> 31.1s
(medians of 5 / 2, isolated HOME + cache). today/report -p month/models/sessions
JSON, warm and cold, per-provider and combined, are byte-identical apart from
the run's own `generated` timestamp.

Closes #1104
2026-08-22 15:52:47 -07:00

111 lines
4.2 KiB
TypeScript

// The discovery sweep (provider walks + per-file stats) now issues its metadata
// syscalls concurrently instead of one at a time. Nothing downstream may notice:
// source order still follows the registry / readdir order, and nothing that was
// discovered serially may be dropped.
import { mkdtemp, mkdir, rm, symlink, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import { basename, join } from 'path'
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { FS_SCAN_CONCURRENCY, mapWithConcurrency } from '../src/fs-utils.js'
import { collectJsonlFiles } from '../src/parser.js'
import { discoverAllSessions } from '../src/providers/index.js'
import type { Provider, SessionSource } from '../src/providers/types.js'
let root: string
beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'codeburn-disc-')) })
afterEach(async () => { await rm(root, { recursive: true, force: true }) })
const tick = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
describe('mapWithConcurrency', () => {
it('returns results in input order even when they settle out of order', async () => {
const items = [40, 0, 20, 0, 10]
const out = await mapWithConcurrency(items, 8, async (ms, i) => {
await tick(ms)
return i
})
expect(out).toEqual([0, 1, 2, 3, 4])
})
it('never exceeds the requested number of in-flight workers', async () => {
let inFlight = 0
let peak = 0
await mapWithConcurrency(Array.from({ length: 50 }, (_, i) => i), 4, async () => {
inFlight++
peak = Math.max(peak, inFlight)
await tick(1)
inFlight--
})
expect(peak).toBe(4)
})
it('handles an empty list without spawning workers', async () => {
await expect(mapWithConcurrency([], FS_SCAN_CONCURRENCY, async () => 1)).resolves.toEqual([])
})
})
describe('collectJsonlFiles', () => {
// The walk skips the subagents/ probe for entries readdir reports as plain
// files. A symlink is NOT a plain file, so a symlinked session directory must
// still be probed — dropping it would lose that session's subagent spend.
it('still probes subagents under a symlinked session directory', async () => {
const real = join(root, 'real-session')
await mkdir(join(real, 'subagents'), { recursive: true })
await writeFile(join(real, 'subagents', 'agent-linked.jsonl'), '{}\n')
await symlink(real, join(root, 'linked-session'), 'dir')
// A plain sibling file is the case the skip exists for: no probe, no effect.
await writeFile(join(root, 'notes.txt'), 'x')
const found = (await collectJsonlFiles(root)).map(f => basename(f))
expect(found).toContain('agent-linked.jsonl')
expect(found).not.toContain('notes.txt')
})
})
function fakeProvider(name: string, delayMs: number, paths: string[]): Provider {
return {
name,
displayName: name,
modelDisplayName: (m: string) => m,
toolDisplayName: (t: string) => t,
async discoverSessions(): Promise<SessionSource[]> {
await tick(delayMs)
return paths.map(path => ({ path, project: name, provider: name }))
},
createSessionParser() { throw new Error('not used') },
}
}
describe('discoverAllSessions', () => {
it('concatenates providers in registry order regardless of which finishes first', async () => {
const providers = [
fakeProvider('slow', 30, ['/s1', '/s2']),
fakeProvider('fast', 0, ['/f1']),
fakeProvider('mid', 10, ['/m1']),
]
const sources = await discoverAllSessions('all', providers)
expect(sources.map(s => s.path)).toEqual(['/s1', '/s2', '/f1', '/m1'])
})
it('keeps a throwing provider isolated without dropping the others', async () => {
const boom = fakeProvider('boom', 0, [])
boom.discoverSessions = async () => { throw new Error('nope') }
const sources = await discoverAllSessions('all', [
fakeProvider('a', 5, ['/a1']),
boom,
fakeProvider('b', 0, ['/b1']),
])
expect(sources.map(s => s.path)).toEqual(['/a1', '/b1'])
})
it('honours a provider filter', async () => {
const sources = await discoverAllSessions('b', [
fakeProvider('a', 0, ['/a1']),
fakeProvider('b', 0, ['/b1']),
])
expect(sources.map(s => s.path)).toEqual(['/b1'])
})
})