perf(usage): keep per-source usage caches off the main thread (#26980)

* perf(usage): keep per-source usage caches off the main thread

The usage stores kept every provider's per-source scan cache
(processedFiles / processedDatabases) inside the one JSON file main
loads at startup. On a large Claude history that file is 56 MB, 43 MB
of it per-source records only a scan reads, so main paid a sync
read+parse at launch, a structured clone of the whole cache to and
from the scan worker on every scan, and a pretty-printed rewrite
after every scan.

The usage scan worker now owns those records in a sidecar
(`<cache>-sources.json`): it reads it before a scan, writes it after
(compact, durable rename), and returns only sessions and daily
aggregates. Main keeps and writes only that report, compact. A cache
still carrying inline records is split on the worker the first time
it is loaded (above 8 MB); smaller ones parse on main and rescan cold.
Sync IPC getters and automation lookups wait for the load.

Synthetic 56 MB cache, same machine, interleaved runs:
- first launch: 108 ms sync parse -> 18 ms main busy, 4 ms longest block
- later launches: ~69 ms -> ~7 ms
- forced scan, longest main-thread block: 89 ms -> 11 ms
  (real built app, headless: 84-94 ms -> 11-13 ms)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(usage): preserve cache attribution and legacy warm scans

* fix(usage): reclaim cache temps after worker retirement

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Neil <neil@stably.ai>
This commit is contained in:
outofplace
2026-10-09 17:19:29 -07:00
committed by GitHub
co-authored by Claude Opus 5.5 Neil
parent 618dc62889
commit 09aff1b94d
36 changed files with 1175 additions and 429 deletions
@@ -1,10 +1,6 @@
import type { UsageProvider } from '../usage/usage-provider-contract'
import { scanOpenCodeUsageDatabasesViaWorker } from '../usage/usage-scan-worker-spawn'
import type {
OpenCodeUsageDailyAggregate,
OpenCodeUsagePersistedDatabase,
OpenCodeUsageSession
} from './types'
import type { OpenCodeUsageDailyAggregate, OpenCodeUsageSession } from './types'
// Why: v4 reads OpenCode 2's `session_v2` table; v3 caches miss every v2 session.
// v5 merges a migrated session's two rows per column instead of picking one, so
@@ -17,9 +13,4 @@ export const openCodeUsageProvider = {
label: 'OpenCode',
schemaVersion: OPENCODE_USAGE_SCHEMA_VERSION,
scan: scanOpenCodeUsageDatabasesViaWorker
} satisfies UsageProvider<
'processedDatabases',
OpenCodeUsagePersistedDatabase,
OpenCodeUsageSession,
OpenCodeUsageDailyAggregate
>
} satisfies UsageProvider<OpenCodeUsageSession, OpenCodeUsageDailyAggregate>
@@ -0,0 +1,46 @@
import type {
OpenCodeUsageDailyAggregate,
OpenCodeUsagePersistedDatabase,
OpenCodeUsagePersistedState
} from './types'
/** Fills cost fields older OpenCode caches never wrote. Electron-free: the scan worker runs it too. */
export function normalizeOpenCodeUsagePersistedDatabases(
databases: OpenCodeUsagePersistedDatabase[] | undefined
): OpenCodeUsagePersistedDatabase[] {
return (databases ?? []).map((database) => ({
...database,
sessions: (database.sessions ?? []).map(normalizeSessionCost),
dailyAggregates: (database.dailyAggregates ?? []).map(normalizeDailyAggregateCost)
}))
}
export function normalizeDailyAggregateCost(
entry: OpenCodeUsageDailyAggregate
): OpenCodeUsageDailyAggregate {
return {
...entry,
estimatedCostUsd: entry.estimatedCostUsd ?? null
}
}
export function normalizeSessionCost(
session: OpenCodeUsagePersistedState['sessions'][number]
): OpenCodeUsagePersistedState['sessions'][number] {
return {
...session,
estimatedCostUsd: session.estimatedCostUsd ?? null,
locationBreakdown: (session.locationBreakdown ?? []).map((entry) => ({
...entry,
estimatedCostUsd: entry.estimatedCostUsd ?? null
})),
modelBreakdown: (session.modelBreakdown ?? []).map((entry) => ({
...entry,
estimatedCostUsd: entry.estimatedCostUsd ?? null
})),
locationModelBreakdown: (session.locationModelBreakdown ?? []).map((entry) => ({
...entry,
estimatedCostUsd: entry.estimatedCostUsd ?? null
}))
}
}
@@ -1,5 +1,10 @@
import type { OpenCodeUsageDailyAggregate, OpenCodeUsagePersistedState } from './types'
import type { OpenCodeUsagePersistedState } from './types'
import { OPENCODE_USAGE_SCHEMA_VERSION } from './opencode-usage-provider'
import {
normalizeDailyAggregateCost,
normalizeOpenCodeUsagePersistedDatabases,
normalizeSessionCost
} from './persisted-database-normalization'
const SCHEMA_VERSION = OPENCODE_USAGE_SCHEMA_VERSION
@@ -31,42 +36,8 @@ export function normalizePersistedState(
}
return {
...state,
processedDatabases: (state.processedDatabases ?? []).map((database) => ({
...database,
sessions: (database.sessions ?? []).map(normalizeSessionCost),
dailyAggregates: (database.dailyAggregates ?? []).map(normalizeDailyAggregateCost)
})),
processedDatabases: normalizeOpenCodeUsagePersistedDatabases(state.processedDatabases),
sessions: state.sessions.map(normalizeSessionCost),
dailyAggregates: state.dailyAggregates.map(normalizeDailyAggregateCost)
}
}
function normalizeDailyAggregateCost(
entry: OpenCodeUsageDailyAggregate
): OpenCodeUsageDailyAggregate {
return {
...entry,
estimatedCostUsd: entry.estimatedCostUsd ?? null
}
}
function normalizeSessionCost(
session: OpenCodeUsagePersistedState['sessions'][number]
): OpenCodeUsagePersistedState['sessions'][number] {
return {
...session,
estimatedCostUsd: session.estimatedCostUsd ?? null,
locationBreakdown: (session.locationBreakdown ?? []).map((entry) => ({
...entry,
estimatedCostUsd: entry.estimatedCostUsd ?? null
})),
modelBreakdown: (session.modelBreakdown ?? []).map((entry) => ({
...entry,
estimatedCostUsd: entry.estimatedCostUsd ?? null
})),
locationModelBreakdown: (session.locationModelBreakdown ?? []).map((entry) => ({
...entry,
estimatedCostUsd: entry.estimatedCostUsd ?? null
}))
}
}
+10 -5
View File
@@ -19,7 +19,8 @@ vi.mock('electron', () => ({
}))
vi.mock('../usage/usage-scan-worker-spawn', () => ({
scanOpenCodeUsageDatabasesViaWorker: vi.fn()
scanOpenCodeUsageDatabasesViaWorker: vi.fn(),
splitUsageCacheFileViaWorker: vi.fn()
}))
import { OpenCodeUsageStore, initOpenCodeUsagePath } from './store'
@@ -29,7 +30,6 @@ import { scanOpenCodeUsageDatabasesViaWorker } from '../usage/usage-scan-worker-
function createEmptyScanResult() {
return {
processedDatabases: [],
sessions: [],
dailyAggregates: []
}
@@ -175,7 +175,7 @@ describe('OpenCodeUsageStore', () => {
rmSync(tempUserData, { recursive: true, force: true })
})
it('adapts OpenCode scans to pretty-printed cache persistence', async () => {
it('hands OpenCode scans the worker-owned source cache and persists a compact report', async () => {
const store = createStoreWithState({
scanState: {
enabled: true,
@@ -188,8 +188,13 @@ describe('OpenCodeUsageStore', () => {
await store.refresh(true)
const persistedJson = readFileSync(join(tempUserData, 'orca-opencode-usage.json'), 'utf-8')
expect(scanOpenCodeUsageDatabasesViaWorker).toHaveBeenCalledWith([], [])
expect(persistedJson).toContain('\n')
expect(scanOpenCodeUsageDatabasesViaWorker).toHaveBeenCalledWith([], {
path: join(tempUserData, 'orca-opencode-usage-sources.json'),
schemaVersion: OPENCODE_USAGE_SCHEMA_VERSION,
worktreeFingerprint: '[]',
reuse: false
})
expect(persistedJson).not.toContain('\n')
})
it('reports no data for Orca scope when only non-Orca OpenCode usage exists', async () => {
+3 -2
View File
@@ -30,6 +30,7 @@ import {
buildOpenCodeUsageSummary
} from './snapshot-rollups'
import { UsageProviderStoreLifecycle } from '../usage/usage-provider-store-lifecycle'
import { splitUsageCacheFileViaWorker } from '../usage/usage-scan-worker-spawn'
let _openCodeUsageFile: string | null = null
@@ -61,8 +62,8 @@ export class OpenCodeUsageStore extends UsageProviderStoreLifecycle<
normalizeState: normalizePersistedState,
sourceKey: 'processedDatabases',
dataPresenceKey: 'hasAnyOpenCodeData',
jsonIndent: 2,
scan: openCodeUsageProvider.scan
scan: openCodeUsageProvider.scan,
splitCacheFile: splitUsageCacheFileViaWorker
})
}