Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## Unreleased

### Changed
- **A warm launch rewrites only the month that changed, and a ranged query reads only the months it can report on.** Per-provider shards still meant one appended session republished that provider's entire history — 95 MB for Claude on a 6 GB corpus. Each provider's shard is now split again by the UTC month of the cached session's FIRST turn, a bucket that never moves as a session grows, so an append rewrites one month. Every shard records the newest month it holds, which lets `--period today/week` skip the shards that cannot contribute a turn to the range; the skipped months stay on disk untouched across the save, and providers whose cache is the only surviving record (durable) or whose parse fingerprint moved are always read in full. Remaining shards are read concurrently. Existing v8 and v7 caches are re-laid-out losslessly on first load and the old layout removed once the new one is published: nothing re-parses.
- **A warm launch rewrites only the provider that changed.** The session cache was a single blob, so any provider appending a few KB republished the whole thing — 147 MB of stringify + fsync on a 6 GB corpus, ~18% of a warm run. It is now a version-suffixed directory holding one shard per provider plus a small envelope, written per provider and published by a single envelope rename. An existing v7 cache is re-laid-out losslessly on first load and the old file removed once the new layout is on disk: nothing re-parses. One unreadable shard now costs that provider a re-parse instead of discarding every provider's history, and partial saves during a cold parse are triggered every 2000 files rather than every 5 seconds, so a slow cold parse no longer rewrites the growing cache on a wall clock.
- **An appended Codex rollout parses only its tail.** Rollout files are append-only and the active ones run to hundreds of MB, but the Codex result cache keyed on mtime + size alone, so any growth re-read the file from byte 0. Each entry now records a restart point at the last task boundary — byte offset plus the state the single-pass decode carries across it — and a grown file with the same inode resumes there, producing output identical to a full re-parse. An entry without a usable restart point simply re-parses in full once and gains one.
- **A date-ranged report classifies only the turns it keeps.** Every cached turn went through the turn classifier — category, retries, edit detection, and a full reconstruction of its API calls — before the date slice discarded most of them, so a week view paid to classify all of history to keep a few percent of it. The keep/drop decision is now taken on the raw cached turn and only the survivors are classified, still from their complete call list, with the branch and pull-request carries still walking the full ordered turn list. Output is byte-identical.
Expand Down
42 changes: 27 additions & 15 deletions src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
isCacheDirty,
loadCache,
markCacheDirty,
monthScopeForRange,
reconcileFile,
saveCache,
} from './session-cache.js'
Expand Down Expand Up @@ -2015,7 +2016,7 @@ async function scanProjectDirs(
// Marked here, not after the re-parse: an unreadable file `continue`s out
// below, and the deletion would otherwise live only in memory.
delete section.files[filePath]
markCacheDirty(diskCache, 'claude')
markCacheDirty(diskCache, 'claude', filePath)

try {
if (append) {
Expand Down Expand Up @@ -2120,7 +2121,7 @@ async function scanProjectDirs(
...(Object.keys(mergedSpawnLinks).length > 0 ? { agentSpawnLinks: mergedSpawnLinks } : {}),
...(mergedAmbiguousIds.length > 0 ? { ambiguousSpawnAgentIds: mergedAmbiguousIds } : {}),
}
markCacheDirty(diskCache, 'claude')
markCacheDirty(diskCache, 'claude', filePath)
filesDone++
await parseProgress.tick(filesDone)
if (filesDone % 50 === 0 || filesDone === progressTotal) {
Expand Down Expand Up @@ -2157,15 +2158,15 @@ async function scanProjectDirs(
...(Object.keys(sessionMeta.agentSpawnLinks).length > 0 ? { agentSpawnLinks: sessionMeta.agentSpawnLinks } : {}),
...(sessionMeta.ambiguousSpawnAgentIds.length > 0 ? { ambiguousSpawnAgentIds: sessionMeta.ambiguousSpawnAgentIds } : {}),
}
markCacheDirty(diskCache, 'claude')
markCacheDirty(diskCache, 'claude', filePath)
} catch (err) {
// A single malformed Claude session file must not abort the whole run — that
// would empty the daily-cache backfill and wipe the trend/history (issue #441,
// same isolation the provider path already has). Record a failure marker keyed
// by the current fingerprint so it isn't re-read and re-thrown every run; it
// re-parses only if the file changes.
section.files[filePath] = { fingerprint: info.fp, mcpInventory: [], turns: [], failed: true }
markCacheDirty(diskCache, 'claude')
markCacheDirty(diskCache, 'claude', filePath)
warnProviderParseFailure('claude', filePath, err)
}
filesDone++
Expand All @@ -2186,7 +2187,7 @@ async function scanProjectDirs(
// but they carry attributable PR spend (surfaced above as a legacy split).
if (section.files[cachedPath]?.prLinks?.length) continue
delete section.files[cachedPath]
markCacheDirty(diskCache, 'claude')
markCacheDirty(diskCache, 'claude', cachedPath)
}
}

Expand Down Expand Up @@ -2990,7 +2991,7 @@ async function parseProviderSources(
// that pruned-away data is preserved for monotonic monthly totals.
if (!provider.durableSources && !clearedPaths.has(source.path)) {
delete section.files[source.path]
markCacheDirty(diskCache, providerName)
markCacheDirty(diskCache, providerName, source.path)
clearedPaths.add(source.path)
}

Expand Down Expand Up @@ -3036,7 +3037,7 @@ async function parseProviderSources(
}
}
didParse = true
markCacheDirty(diskCache, providerName)
markCacheDirty(diskCache, providerName, source.path)
} catch (err) {
if (isSqliteBusyError(err)) {
warnProviderReadFailureOnce(providerName, err)
Expand All @@ -3049,7 +3050,7 @@ async function parseProviderSources(
// on every refresh; it re-parses only if it changes. Empty turns => no
// usage contributed.
section.files[source.path] = { fingerprint: fp, mcpInventory: [], turns: [], failed: true }
markCacheDirty(diskCache, providerName)
markCacheDirty(diskCache, providerName, source.path)
warnProviderParseFailure(providerName, source.path, err)
continue
}
Expand All @@ -3073,7 +3074,7 @@ async function parseProviderSources(
for (const cachedPath of Object.keys(section.files)) {
if (!allDiscoveredFiles.has(cachedPath)) {
delete section.files[cachedPath]
markCacheDirty(diskCache, providerName)
markCacheDirty(diskCache, providerName, cachedPath)
}
}
}
Expand All @@ -3090,7 +3091,7 @@ async function parseProviderSources(
.reduce((max, ts) => Math.max(max, ts), 0)
if (newestTs > 0 && newestTs < cutoffMs) {
delete section.files[cachedPath]
markCacheDirty(diskCache, providerName)
markCacheDirty(diskCache, providerName, cachedPath)
}
}
}
Expand Down Expand Up @@ -3793,7 +3794,18 @@ async function parseAllSessionsInCacheScope(dateRange?: DateRange, providerFilte
if (reused) return reused
}

let diskCache = await loadCache()
// Load only the month shards a query over `dateRange` can possibly report
// on. Sessions whose every turn falls outside the range are dropped from the
// report anyway, so skipping their shards changes nothing except the bytes
// read — and a save writes only dirty months, leaving the skipped ones on
// disk untouched (see saveCache). Cross-file dedup is weakened, not broken:
// the pre-seed of `seenMsgIds` / `seenKeys` only covers loaded files, so a key
// that a skipped file also holds is no longer suppressed. Totals are
// unaffected (a suppressed duplicate contributes nothing either way), but for
// a proxied key emitted under two providers the attribution can land on a
// different provider than a full load would pick.
const loadScope = dateRange ? monthScopeForRange(dateRange.start, dateRange.end) : undefined
let diskCache = await loadCache(loadScope)
await cleanupOrphanedTempFiles()

// Cold-hydration coordination (advisory, cross-process). Engages whenever the
Expand All @@ -3806,7 +3818,7 @@ async function parseAllSessionsInCacheScope(dateRange?: DateRange, providerFilte
// doubt it proceeds unlocked.
if (!isCacheComplete(diskCache)) {
const hydration = await beginColdHydration(true)
if (hydration.waited) diskCache = await loadCache()
if (hydration.waited) diskCache = await loadCache(loadScope)
const isCold = !isCacheComplete(diskCache)
try {
return await runParse(key, diskCache, dateRange, providerFilter, { isCold, burstSig, parseStartedAt })
Expand All @@ -3824,17 +3836,17 @@ async function parseAllSessionsInCacheScope(dateRange?: DateRange, providerFilte
return runParse(key, priorSnapshot, dateRange, providerFilter, { readOnly: true, burstSig, parseStartedAt })
}
if (refresh.outcome === 'completed-by-other') {
return runParse(key, await loadCache(), dateRange, providerFilter, { readOnly: true, burstSig, parseStartedAt })
return runParse(key, await loadCache(loadScope), dateRange, providerFilter, { readOnly: true, burstSig, parseStartedAt })
}

try {
// Reload only after ownership is canonical; this closes the lost-update
// window between the pre-gate read and the holder's completed publication.
diskCache = await loadCache()
diskCache = await loadCache(loadScope)
return await runParse(key, diskCache, dateRange, providerFilter, { refreshLock: refresh.handle, burstSig, parseStartedAt })
} catch (err) {
if (!(err instanceof RefreshFenceLostError) && !(err instanceof RefreshPublicationUnavailableError)) throw err
return runParse(key, await loadCache(), dateRange, providerFilter, { readOnly: true, burstSig, parseStartedAt })
return runParse(key, await loadCache(loadScope), dateRange, providerFilter, { readOnly: true, burstSig, parseStartedAt })
} finally {
await refresh.handle.release()
}
Expand Down
Loading
Loading