codeburn/src/parse-workers.ts
iamtoruk 5bc78b8e9e perf(parse-workers): gate on pending bytes, size the per-worker budget per parse
Three fixes from review, all measured on this box.

The workload gate was files OR bytes. The files arm is wrong: 250 pending files
holding 117 KB between them spawned 5 threads and ran ~5% SLOWER than serial,
and a file count only starts paying for itself around 400. Gate on bytes alone;
the count still takes max(files / 50, bytes / 200 MB), so a few hundred huge
rollouts keep their threads.

The flat 256 MB per-worker memory budget was contradicted by the Codex workload:
a 260 MB rollout peaks near 430 MB in its worker, linearly across the pool. It is
now derived per parse as clamp(256 MB, 2 x average pending file + 128 MB, 1 GB),
which leaves a corpus of small Claude transcripts where it was and stops
over-subscribing on rollouts. The parent's buffer of up to pool.size finished
results is part of that peak and is named in the comment.

The worker/file pairing at both install sites was positional, guarded only by
position (Claude) or a path membership check (Codex). Each worker now echoes its
path and the parent asserts it, outside the per-file try: a misalignment would
install one session's turns under another's path -- a wrong number nobody would
ever notice -- so it fails the run rather than being swallowed as a parse
failure. On the Claude side that meant hoisting the whole worker-result block
above the try, which is safe because an append never consumes a result in either
its shortcut or its straddled-fallthrough case.
2026-08-17 02:53:19 -07:00

245 lines
10 KiB
TypeScript

import { availableParallelism, totalmem } from 'os'
import { Worker } from 'worker_threads'
import { snapshotPricingState } from './models.js'
import type { ClaudeFileParse } from './parser.js'
import type { SessionSource } from './providers/types.js'
// A worker holds one file's entries plus its serialized result, and the parent
// buffers up to `pool.size` finished results while it installs one — both are in
// this budget. A flat 256 MB was measured wrong on Codex: a 260 MB rollout peaks
// near 430 MB per worker and scales linearly with the pool. So derive it from the
// average pending file instead, floored at the small-transcript figure and capped
// at 1 GB. Going over the budget is what turns a parallel parse into a swapping one.
const MIN_PER_WORKER_RSS_BYTES = 256 * 1024 * 1024
const MAX_PER_WORKER_RSS_BYTES = 1024 * 1024 * 1024
const PER_WORKER_RSS_OVERHEAD_BYTES = 128 * 1024 * 1024
const MEMORY_BUDGET_CAP_BYTES = 2 * 1024 * 1024 * 1024
const MIN_AVAILABLE_BYTES = 4 * 1024 * 1024 * 1024
const MIN_FILES_PER_WORKER = 50
const MIN_BYTES_PER_WORKER = 200 * 1024 * 1024
// Below this a parse is warm/incremental and the thread startup + result transfer
// costs more than the parallelism buys. Bytes, not file count: 250 pending files
// holding under a megabyte between them spawn threads that make the run ~5%
// SLOWER, and the file count only starts paying for itself around 400.
const MIN_PENDING_BYTES = 200 * 1024 * 1024
export type ParseWorkerDecision = { workers: number; reason: string }
export type SystemCapacity = { cores: number; availableBytes: number }
// `process.availableMemory()` respects a container's cgroup / rlimit, which is the
// case this gate exists for; where it is absent it falls back to total RAM. Free
// memory is deliberately NOT used: on macOS `os.freemem()` counts free pages, not
// available memory, and reads as a few hundred MB on an idle 128 GB machine — a
// gate built on it turns the feature on and off at random.
function currentSystemCapacity(): SystemCapacity {
return {
cores: availableParallelism(),
availableBytes: typeof process.availableMemory === 'function' ? process.availableMemory() : totalmem(),
}
}
/// Decide how many parse worker threads a pending workload earns. Returning 0
/// means "parse serially" — the only behaviour before this existed, and still
/// the behaviour for every warm run, every small corpus, and every low-spec box.
export function decideParseWorkers(
pending: { files: number; bytes: number },
sys: SystemCapacity = currentSystemCapacity(),
env: NodeJS.ProcessEnv = process.env,
): ParseWorkerDecision {
// Every reason carries the full decision input, so a support log line explains
// itself without a second run.
const inputs = `${sys.cores} cores, ${Math.round(sys.availableBytes / 1e9 * 10) / 10} GB available, ${pending.files} pending files / ${Math.round(pending.bytes / 1e6)} MB`
const override = env['CODEBURN_PARSE_WORKERS']
if (override !== undefined && override !== '') {
const n = Number(override)
if (!Number.isFinite(n) || n < 0) return { workers: 0, reason: `invalid CODEBURN_PARSE_WORKERS=${override}` }
const capped = Math.min(Math.floor(n), sys.cores)
return { workers: capped, reason: `${capped === 0 ? 'forced serial' : 'forced'} by CODEBURN_PARSE_WORKERS=${override}; ${inputs}` }
}
// Workload gates first, so a warm run's log line says "warm", not whatever the
// machine happened to look like at that moment.
if (pending.bytes < MIN_PENDING_BYTES) return { workers: 0, reason: `below ${Math.round(MIN_PENDING_BYTES / 1e6)} MB pending; ${inputs}` }
if (sys.cores <= 2) return { workers: 0, reason: `too few cores; ${inputs}` }
if (sys.availableBytes < MIN_AVAILABLE_BYTES) return { workers: 0, reason: `below ${Math.round(MIN_AVAILABLE_BYTES / 1e9)} GB available memory; ${inputs}` }
const memoryBudget = Math.min(0.25 * sys.availableBytes, MEMORY_BUDGET_CAP_BYTES)
const perWorker = Math.min(
MAX_PER_WORKER_RSS_BYTES,
Math.max(MIN_PER_WORKER_RSS_BYTES, 2 * (pending.bytes / Math.max(1, pending.files)) + PER_WORKER_RSS_OVERHEAD_BYTES),
)
// Files and bytes each earn threads on their own: a few hundred huge rollouts
// are as parallelisable as a few thousand small transcripts, and gating the
// count on files alone would hand a 6 GB / 60-file workload a single thread.
const workers = Math.min(
sys.cores - 1,
Math.floor(memoryBudget / perWorker),
Math.max(
Math.floor(pending.files / MIN_FILES_PER_WORKER),
Math.floor(pending.bytes / MIN_BYTES_PER_WORKER),
),
)
return { workers, reason: inputs }
}
// In dist the entry is the bundled sibling of this module and a worker can load
// it directly. Running from source (tsx, vitest) the entry is TypeScript, and a
// worker thread inherits none of the parent's loader hooks — so register tsx's
// inside the thread before importing. tsx is a devDependency, which is exactly
// the only situation where the entry can be a .ts file at all.
function workerBootstrap(entryUrl: string): { source: string | URL; eval: boolean } {
if (!entryUrl.endsWith('.ts')) return { source: new URL(entryUrl), eval: false }
return {
eval: true,
// Chained, not awaited: the eval scope is CommonJS, and a top-level await of
// the entry re-enters it as a require(esm) cycle.
source: `
process.noDeprecation = true
import('tsx/esm/api').then(tsx => { tsx.register(); return import(${JSON.stringify(entryUrl)}) })
`,
}
}
function workerEntryUrl(): string {
const ext = import.meta.url.endsWith('.ts') ? '.ts' : '.js'
return new URL(`./parse-worker${ext}`, import.meta.url).href
}
/// One whole-file parse for a worker to run. Both kinds carry exactly what the
/// serial per-file parse takes, so the worker can run that same function.
export type ParseJob =
| { kind: 'claude'; filePath: string }
| { kind: 'codex'; source: SessionSource }
export type ParseWorkerResult<T> =
| { ok: true; parsed: T | null }
| { ok: false; error: string }
export type ClaudeWorkerParse = ClaudeFileParse & { msgIds: string[]; path: string }
type Task = { job: ParseJob; resolve: (r: ParseWorkerResult<unknown>) => void }
type WorkerMessage = { json?: string | null; error?: string }
export class ParseWorkerPool {
private readonly workers: Worker[] = []
private readonly idle: Worker[] = []
private readonly inflight = new Map<Worker, Task>()
private readonly queue: Task[] = []
private closed = false
constructor(size: number) {
const boot = workerBootstrap(workerEntryUrl())
const workerData = { pricing: snapshotPricingState() }
try {
for (let i = 0; i < size; i++) {
const worker = new Worker(boot.source, { eval: boot.eval, workerData })
worker.on('message', (msg: WorkerMessage) => this.settle(worker, msg))
worker.on('error', (err: Error) => this.settle(worker, { error: err.message }, true))
worker.on('exit', () => this.drop(worker))
this.workers.push(worker)
this.idle.push(worker)
}
} catch (err) {
for (const w of this.workers) void w.terminate()
this.workers.length = 0
this.idle.length = 0
throw err
}
}
get size(): number {
return this.workers.length
}
/// Parse one file off-thread. Never rejects: a worker-side failure (or a dead
/// pool) comes back as `ok: false` so the caller can fall back to an in-process
/// parse and never lose a file to a crashed thread.
submit<T>(job: ParseJob): Promise<ParseWorkerResult<T>> {
return new Promise<ParseWorkerResult<T>>((resolve) => {
if (this.closed || this.workers.length === 0) {
resolve({ ok: false, error: 'parse worker pool unavailable' })
return
}
this.queue.push({ job, resolve: resolve as (r: ParseWorkerResult<unknown>) => void })
this.pump()
})
}
async close(): Promise<void> {
this.closed = true
const pending = [...this.queue]
this.queue.length = 0
for (const task of pending) task.resolve({ ok: false, error: 'parse worker pool closed' })
await Promise.all(this.workers.map(w => w.terminate()))
this.workers.length = 0
this.idle.length = 0
this.inflight.clear()
}
private pump(): void {
while (this.queue.length > 0 && this.idle.length > 0) {
const worker = this.idle.pop()!
const task = this.queue.shift()!
this.inflight.set(worker, task)
worker.postMessage(task.job)
}
}
private settle(worker: Worker, msg: WorkerMessage, fatal = false): void {
const task = this.inflight.get(worker)
this.inflight.delete(worker)
if (task) {
if (msg.error !== undefined) task.resolve({ ok: false, error: msg.error })
else task.resolve({ ok: true, parsed: msg.json == null ? null : JSON.parse(msg.json) })
}
if (fatal) return
if (!this.closed) {
this.idle.push(worker)
this.pump()
}
}
// A thread that died takes its queue slot with it; the remaining files are
// handed back for a serial parse rather than being lost.
private drop(worker: Worker): void {
const i = this.workers.indexOf(worker)
if (i >= 0) this.workers.splice(i, 1)
const j = this.idle.indexOf(worker)
if (j >= 0) this.idle.splice(j, 1)
const task = this.inflight.get(worker)
if (task) {
this.inflight.delete(worker)
task.resolve({ ok: false, error: 'parse worker exited' })
}
if (this.workers.length === 0) {
const pending = [...this.queue]
this.queue.length = 0
for (const t of pending) t.resolve({ ok: false, error: 'all parse workers exited' })
}
}
}
/// Yield results for `jobs` in the SAME order they were given, no matter which
/// worker finishes first. Keeps exactly `pool.size` files in flight, so at most
/// that many parsed results are buffered while the caller installs one.
export async function* parseFilesInOrder<T>(
pool: ParseWorkerPool,
jobs: readonly ParseJob[],
): AsyncGenerator<ParseWorkerResult<T>, void, void> {
const inflight: Array<Promise<ParseWorkerResult<T>>> = []
let next = 0
const fill = (): void => {
while (inflight.length < Math.max(1, pool.size) && next < jobs.length) {
inflight.push(pool.submit<T>(jobs[next++]!))
}
}
fill()
for (let i = 0; i < jobs.length; i++) {
const result = await inflight.shift()!
fill()
yield result
}
}