Move githubCache out of the durable state file into a quit-time sidecar

Every PR/issue poll restamps fetchedAt even when nothing changed, which
defeated the content-hash write guard by construction: ~100KB of
refetchable 5-min-TTL cache rewrote the whole 1.6MB orca-data.json once
per refresh cycle - the last remaining non-user-driven writer.

The cache is now memory-only during the session (setGitHubCache no
longer schedules a save; the durable payload and state hash both omit
it) and is snapshotted best-effort to orca-github-cache.json at flush
(quit/reload), so PR/issue badges still paint instantly on next launch.
A legacy in-file cache is used as the seed and stripped by the next
durable write; downgrades just recreate the key, which the next upgrade
strips again. Sidecar loss costs nothing - the data is refetchable.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil
2026-07-02 02:40:08 -07:00
co-authored by Orca
parent 443d6efb50
commit 05fdd91169
2 changed files with 127 additions and 3 deletions
+43
View File
@@ -4584,6 +4584,49 @@ describe('Store', () => {
expect(statSync(dataFile()).ino).toBe(inoBefore)
})
// ── GitHub cache sidecar ───────────────────────────────────────────
it('cache refreshes never rewrite the durable state file', async () => {
vi.useFakeTimers()
try {
const store = await createStore()
store.updateUI({ sidebarWidth: 411 })
vi.advanceTimersByTime(1000)
await store.waitForPendingWrite()
const inoBefore = statSync(dataFile()).ino
expect((readDataFile() as { githubCache?: unknown }).githubCache).toBeUndefined()
store.setGitHubCache({ pr: { 'o/r#1': { fetchedAt: 123 } as never }, issue: {} })
vi.advanceTimersByTime(6000)
await store.waitForPendingWrite()
expect(statSync(dataFile()).ino).toBe(inoBefore)
} finally {
vi.useRealTimers()
}
})
it('snapshots the cache at flush and seeds the next Store from the sidecar', async () => {
const store = await createStore()
store.setGitHubCache({ pr: { 'o/r#7': { fetchedAt: 7 } as never }, issue: {} })
store.flush()
expect(existsSync(join(testState.dir, 'orca-github-cache.json'))).toBe(true)
const restarted = await createStore()
expect(restarted.getGitHubCache().pr['o/r#7']).toEqual({ fetchedAt: 7 })
})
it('keeps a legacy in-file cache as the seed and strips it from disk', async () => {
writeDataFile({ githubCache: { pr: { legacy: { fetchedAt: 1 } }, issue: {} } })
const store = await createStore()
expect(store.getGitHubCache().pr.legacy).toEqual({ fetchedAt: 1 })
// The legacy key marks the state dirty at load; the next write drops it.
store.flush()
expect((readDataFile() as { githubCache?: unknown }).githubCache).toBeUndefined()
})
// ── UI state ───────────────────────────────────────────────────────
it('updateUI merges partial updates', async () => {
+84 -3
View File
@@ -325,6 +325,33 @@ function getDataFile(): string {
return _dataFile
}
// Why a sidecar: githubCache is a refetchable 5-min-TTL poll cache whose
// fetchedAt stamps change on every refresh — keeping it inside orca-data.json
// made every poll cycle rewrite the whole multi-MB durable state (defeating
// the content-hash guard by design). It lives in memory during the session
// and is snapshotted here best-effort at quit so PR/issue badges still paint
// instantly on the next launch. Loss of this file costs nothing.
function getGithubCacheFile(): string {
return join(dirname(getDataFile()), 'orca-github-cache.json')
}
function readGithubCacheSnapshot(): PersistedState['githubCache'] | null {
try {
const parsed = JSON.parse(readFileSync(getGithubCacheFile(), 'utf-8')) as unknown
if (
parsed &&
typeof parsed === 'object' &&
typeof (parsed as { pr?: unknown }).pr === 'object' &&
typeof (parsed as { issue?: unknown }).issue === 'object'
) {
return parsed as PersistedState['githubCache']
}
} catch {
// Missing or corrupt snapshot: start with an empty cache and refetch.
}
return null
}
/**
* Return the userData directory captured at initDataPath() time, before
* app.setName() can change how app.getPath('userData') resolves.
@@ -2451,6 +2478,7 @@ export class Store {
// on-disk bytes differ even for identical state.
private lastWrittenStateHash: string | null = null
private firstPendingSaveAt: number | null = null
private githubCacheDirty = false
private gitUsernameCache = new Map<string, string>()
private loadNeedsSave = false
private settingsChangeListeners = new Set<
@@ -3294,6 +3322,24 @@ export class Store {
result = folderScopeConnectionMigration.state
const migrated = this.migrateTelemetry(result, fileExistedOnLoad)
// githubCache lives in a sidecar file now (see getGithubCacheFile). A
// legacy in-file cache (pre-sidecar build, or a downgrade round-trip) is
// kept as this session's seed and stripped from the durable file by the
// save scheduled below; otherwise seed from the sidecar snapshot.
const legacyCache = migrated.githubCache
const hasLegacyCache =
Object.keys(legacyCache?.pr ?? {}).length > 0 ||
Object.keys(legacyCache?.issue ?? {}).length > 0
if (hasLegacyCache) {
this.loadNeedsSave = true
// Why: mark dirty so the first flush writes the sidecar even if no
// poll refresh happens this session — the seed survives the migration.
this.githubCacheDirty = true
} else {
migrated.githubCache = readGithubCacheSnapshot() ?? migrated.githubCache
}
logPersistenceStartupMilestone('persistence-load-done', {
repos: migrated.repos.length,
workspaceSessionBytes: Buffer.byteLength(JSON.stringify(migrated.workspaceSession))
@@ -3403,8 +3449,16 @@ export class Store {
}
}
// Why githubCache is omitted: it is memory-only during the session (see
// getGithubCacheFile) — excluding it from both the payload and the hash
// keeps cache refreshes from ever touching the durable file.
private getDurableState(): Omit<PersistedState, 'githubCache'> {
const { githubCache: _memoryOnly, ...durable } = this.state
return durable
}
private computeStateHash(): string {
return createHash('sha1').update(JSON.stringify(this.state)).digest('hex')
return createHash('sha1').update(JSON.stringify(this.getDurableState())).digest('hex')
}
// Why: builds the on-disk payload synchronously so the hash and the
@@ -3414,7 +3468,7 @@ export class Store {
// Why: secrets must be encrypted on disk. Clone state so the in-memory
// this.state stays plaintext for the rest of the app.
const stateToSave = {
...this.state,
...this.getDurableState(),
settings: {
...this.state.settings,
opencodeSessionCookie: encrypt(this.state.settings.opencodeSessionCookie),
@@ -5241,8 +5295,12 @@ export class Store {
}
setGitHubCache(cache: PersistedState['githubCache']): void {
// Why no scheduleSave: the cache is memory-only during the session and
// snapshotted to its sidecar file at flush (quit/reload) time. Every poll
// refresh restamps fetchedAt, so persisting here rewrote the whole
// durable state file once per poll cycle for refetchable data.
this.state.githubCache = cache
this.scheduleSave()
this.githubCacheDirty = true
}
// ── Workspace Session ─────────────────────────────────────────────
@@ -5947,6 +6005,29 @@ export class Store {
} catch (err) {
console.error('[persistence] Failed to flush state:', err)
}
this.writeGithubCacheSnapshotSync()
}
// Why best-effort: the sidecar is a refetchable cache — a failed write only
// costs a cold badge paint on next launch, never data.
private writeGithubCacheSnapshotSync(): void {
if (!this.githubCacheDirty) {
return
}
const cacheFile = getGithubCacheFile()
const tmpFile = `${cacheFile}.${process.pid}.tmp`
try {
writeFileSync(tmpFile, JSON.stringify(this.state.githubCache), 'utf-8')
renameSync(tmpFile, cacheFile)
this.githubCacheDirty = false
} catch (err) {
try {
unlinkSync(tmpFile)
} catch {
// Best-effort cleanup.
}
console.warn('[persistence] Failed to write github cache snapshot:', err)
}
}
}