mirror of
https://github.com/stablyai/orca.git
synced 2026-10-16 16:03:26 +00:00
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:
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user