From 05fdd91169ff15637c8abbc36db72fc6a07d24cb Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 2 Jul 2026 02:40:08 -0700 Subject: [PATCH] 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 --- src/main/persistence.test.ts | 43 ++++++++++++++++++ src/main/persistence.ts | 87 ++++++++++++++++++++++++++++++++++-- 2 files changed, 127 insertions(+), 3 deletions(-) diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index b1856b1a8c4..60ab8fb3a19 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -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 () => { diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 5d9ef610f48..3ecb9fd226a 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -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() 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 { + 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) + } } }