perf(persistence): save the GitHub cache as per-entry SQLite rows every few minutes (#39993)

The PR/issue cache only seeds next launch's sidebar, but every flush rewrote the whole
~200 KB orca-github-cache.json. Store one row per entry in orca-github-cache.db, write only
changed rows, and save at most every three minutes plus at quit and explicit flush barriers.
An existing sidecar is imported and removed; older builds read a missing sidecar as empty.
This commit is contained in:
Neil
2026-11-08 14:28:15 -08:00
committed by GitHub
parent 06a528f774
commit 176e6a4e98
15 changed files with 625 additions and 181 deletions
+3 -2
View File
@@ -373,8 +373,9 @@
2 src/main/orcad/orcad-wsl-host-claude-profile-refresh.ts
1 src/main/orcad/serve-profile-ssh-targets.ts
6 src/main/persistence/atomic-snapshot-writer.ts
6 src/main/persistence/loading-store/user-data-path.ts
3 src/main/persistence/loading-store/write-flush-barriers.ts
1 src/main/persistence/loading-store/github-cache-sidecar.ts
1 src/main/persistence/loading-store/github-cache-startup-read.ts
5 src/main/persistence/loading-store/user-data-path.ts
9 src/main/persistence/migrating-orcad-catalog/migration-journal-directory.ts
4 src/main/persistence/migrating-orcad-catalog/orcad-migration-blob-installers.ts
3 src/main/persistence/migrating-orcad-catalog/orcad-migration-blob-staging.ts
@@ -0,0 +1,117 @@
import {
existsSync,
mkdtempSync,
openSync,
closeSync,
rmSync,
writeSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import SyncDatabase from '../../sqlite/sync-database'
import { getGithubCacheDatabaseFile, writeGitHubCacheDatabase } from './github-cache-database'
import { readGitHubCacheDatabase } from './github-cache-startup-read'
const directories: string[] = []
afterEach(() => {
for (const directory of directories.splice(0)) {
rmSync(directory, { recursive: true, force: true })
}
})
function profileDataFile(): string {
const directory = mkdtempSync(join(tmpdir(), 'orca-github-cache-'))
directories.push(directory)
return join(directory, 'orca-data.json')
}
const pr = (fetchedAt: number) => ({ data: null, fetchedAt })
const sidecarPath = (dataFile: string) => join(dataFile, '..', 'orca-github-cache.json')
const save = (dataFile: string, cache: Parameters<typeof writeGitHubCacheDatabase>[1]): void => {
void writeGitHubCacheDatabase(dataFile, cache, { busyTimeoutMs: 0, importedLegacyFileHash: null })
}
describe('github cache database', () => {
it('reads back saved entries, including removals', () => {
const dataFile = profileDataFile()
save(dataFile, { pr: { 'o/r#1': pr(1), 'o/r#2': pr(2) }, issue: {} })
save(dataFile, { pr: { 'o/r#2': pr(3) }, issue: { 'o/r#9': pr(4) } })
expect(readGitHubCacheDatabase(dataFile)).toEqual({
cache: { pr: { 'o/r#2': pr(3) }, issue: { 'o/r#9': pr(4) } },
fromSidecar: false,
sidecarHash: null
})
})
it('keeps an older build sidecar until its imported rows commit', async () => {
const dataFile = profileDataFile()
save(dataFile, { pr: { 'o/r#1': pr(1) }, issue: {} })
writeFileSync(sidecarPath(dataFile), JSON.stringify({ pr: { 'o/r#5': pr(5) }, issue: {} }))
const legacy = readGitHubCacheDatabase(dataFile)
expect(legacy.cache).toEqual({ pr: { 'o/r#5': pr(5) }, issue: {} })
const locker = new SyncDatabase(getGithubCacheDatabaseFile(dataFile))
locker.exec('BEGIN IMMEDIATE')
const options = { busyTimeoutMs: 0, importedLegacyFileHash: legacy.sidecarHash }
expect(() => writeGitHubCacheDatabase(dataFile, legacy.cache!, options)).toThrow(/locked/)
expect(existsSync(sidecarPath(dataFile))).toBe(true)
locker.exec('ROLLBACK')
locker.close()
await writeGitHubCacheDatabase(dataFile, legacy.cache!, options)
expect(existsSync(sidecarPath(dataFile))).toBe(false)
expect(readGitHubCacheDatabase(dataFile)).toEqual({
cache: { pr: { 'o/r#5': pr(5) }, issue: {} },
fromSidecar: false,
sidecarHash: null
})
})
it('never lets an already imported sidecar shadow later saves, even while locked', async () => {
const dataFile = profileDataFile()
const sidecar = JSON.stringify({ pr: { 'o/r#5': pr(5) }, issue: {} })
writeFileSync(sidecarPath(dataFile), sidecar)
const legacy = readGitHubCacheDatabase(dataFile)
await writeGitHubCacheDatabase(dataFile, legacy.cache!, {
busyTimeoutMs: 0,
importedLegacyFileHash: legacy.sidecarHash
})
// The same imported file survives, as when its deletion failed.
writeFileSync(sidecarPath(dataFile), sidecar)
save(dataFile, { pr: { 'o/r#6': pr(6) }, issue: {} })
const locker = new SyncDatabase(getGithubCacheDatabaseFile(dataFile))
locker.exec('BEGIN IMMEDIATE')
try {
expect(readGitHubCacheDatabase(dataFile).cache).toEqual({ pr: { 'o/r#6': pr(6) }, issue: {} })
} finally {
locker.exec('ROLLBACK')
locker.close()
}
})
it('rebuilds a database whose entry rows are corrupt and saves again', () => {
const dataFile = profileDataFile()
save(dataFile, { pr: { 'o/r#1': pr(1) }, issue: {} })
const path = getGithubCacheDatabaseFile(dataFile)
const db = new SyncDatabase(path)
const root = db.prepare(`SELECT rootpage FROM sqlite_master WHERE name = 'entries'`).get()
const pageSize = db.pragma('page_size', { simple: true })
db.close()
const fd = openSync(path, 'r+')
writeSync(
fd,
Buffer.from([0xff, 0xff, 0xff, 0xff]),
0,
4,
(Number(root?.rootpage) - 1) * Number(pageSize)
)
closeSync(fd)
save(dataFile, { pr: { 'o/r#2': pr(2) }, issue: {} })
expect(readGitHubCacheDatabase(dataFile).cache).toEqual({ pr: { 'o/r#2': pr(2) }, issue: {} })
})
})
@@ -0,0 +1,170 @@
import { dirname, join } from 'node:path'
import type { PersistedState } from '../../../shared/persisted-state-types'
import type SyncDatabase from '../../sqlite/sync-database'
import {
isUnusableDatabaseError,
openVersionedCacheDatabase,
removeVersionedCacheDatabase
} from '../../sqlite/versioned-cache-database'
import { retireGitHubCacheSidecar, sha256 } from './github-cache-sidecar'
type GitHubCache = PersistedState['githubCache']
type GitHubCacheKind = keyof GitHubCache
/**
* Next-launch copy of the PR/issue caches, one row per entry. A save rewrites only entries
* whose content changed; the JSON sidecar it replaces was rewritten whole on every flush.
* Disposable: a corrupt or mismatched database rebuilds empty and the next poll refills it.
*/
const SCHEMA_SQL = `
CREATE TABLE IF NOT EXISTS meta(key TEXT PRIMARY KEY, value TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS entries(
kind TEXT NOT NULL,
key TEXT NOT NULL,
hash TEXT NOT NULL,
payload TEXT NOT NULL,
PRIMARY KEY(kind, key)
) WITHOUT ROWID;
`
/** Main-thread callers never wait out another writer's lock; they retry later instead. */
export const GITHUB_CACHE_ROUTINE_BUSY_TIMEOUT_MS = 25
/** Quit and explicit barriers may wait briefly so a transient lock does not drop the save. */
export const GITHUB_CACHE_BARRIER_BUSY_TIMEOUT_MS = 500
export const GITHUB_CACHE_SCHEMA_VERSION = 1
const KINDS: readonly GitHubCacheKind[] = ['pr', 'issue']
export function getGithubCacheDatabaseFile(dataFile: string): string {
return join(dirname(dataFile), 'orca-github-cache.db')
}
export const IMPORTED_SIDECAR_KEY = 'imported_sidecar_sha256'
/**
* Save the cache, writing only rows whose content changed. `importedLegacyFileHash` names the
* sidecar whose content `cache` already holds (imported or superseded): the commit records it,
* then the returned promise removes the sidecar if it is still that file. If removal never
* happens (quit, failure), the recorded fingerprint keeps it from shadowing these rows.
* Throws synchronously on a failed commit, including SQLITE_BUSY.
*/
export function writeGitHubCacheDatabase(
dataFile: string,
cache: GitHubCache,
options: { busyTimeoutMs: number; importedLegacyFileHash: string | null }
): Promise<void> {
const path = getGithubCacheDatabaseFile(dataFile)
const imported = options.importedLegacyFileHash
withCacheDatabase(path, options.busyTimeoutMs, (db) => writeEntries(db, cache, imported))
return imported === null ? Promise.resolve() : retireGitHubCacheSidecar(dataFile, imported)
}
export function isGitHubCacheBusyError(error: unknown): boolean {
const code = error instanceof Error && 'code' in error ? String(error.code) : ''
const message = error instanceof Error ? error.message : ''
return /SQLITE_BUSY|SQLITE_LOCKED/.test(code) || /database (is|table is) locked/i.test(message)
}
/**
* Open, run, close: no handle outlives a call (profile moves, Windows). Corruption found
* anywhere, including in the rows, rebuilds the database once; lock and permission errors
* propagate unchanged so the caller retries later.
*/
function withCacheDatabase<T>(
path: string,
busyTimeoutMs: number,
run: (db: SyncDatabase) => T
): T {
// Fail-fast removal: a rebuild blocked by a held file throws to the caller's retry.
const schema = {
version: GITHUB_CACHE_SCHEMA_VERSION,
sql: SCHEMA_SQL,
busyTimeoutMs,
removal: 'fail-fast' as const
}
for (let attempt = 0; ; attempt++) {
let db: SyncDatabase | null = null
try {
db = openVersionedCacheDatabase(path, schema)
return run(db)
} catch (error) {
if (attempt > 0 || !isUnusableDatabaseError(error)) {
throw error
}
db?.close()
db = null
removeVersionedCacheDatabase(path, 'fail-fast')
} finally {
db?.close()
}
}
}
export function readGitHubCacheEntries(db: SyncDatabase): GitHubCache {
const cache: GitHubCache = { pr: {}, issue: {} }
for (const row of db.prepare('SELECT kind, key, payload FROM entries').all()) {
const kind = KINDS.find((candidate) => candidate === row.kind)
if (!kind || typeof row.key !== 'string' || typeof row.payload !== 'string') {
continue
}
try {
Object.assign(cache[kind], { [row.key]: JSON.parse(row.payload) })
} catch {
// An unreadable row is dropped; the next save rewrites or deletes it.
}
}
return cache
}
function writeEntries(db: SyncDatabase, cache: GitHubCache, importedSidecar: string | null): void {
const upsert = db.prepare(
`INSERT INTO entries(kind, key, hash, payload) VALUES (?, ?, ?, ?)
ON CONFLICT(kind, key) DO UPDATE SET hash = excluded.hash, payload = excluded.payload`
)
const remove = db.prepare('DELETE FROM entries WHERE kind = ? AND key = ?')
db.exec('BEGIN IMMEDIATE')
try {
// Read the baseline under the write lock, so another writer's commit cannot leave it stale.
const stored = new Map<string, string>()
for (const row of db.prepare('SELECT kind, key, hash FROM entries').all()) {
if (typeof row.kind === 'string' && typeof row.key === 'string') {
stored.set(rowId(row.kind, row.key), String(row.hash))
}
}
for (const kind of KINDS) {
for (const [key, entry] of Object.entries(cache[kind] ?? {})) {
const payload = JSON.stringify(entry)
if (payload === undefined) {
continue
}
const id = rowId(kind, key)
const hash = sha256(payload)
if (stored.get(id) !== hash) {
upsert.run(kind, key, hash, payload)
}
stored.delete(id)
}
}
for (const id of stored.keys()) {
const [kind, key] = id.split('\u0000', 2)
remove.run(kind, key)
}
if (importedSidecar !== null) {
db.prepare('INSERT OR REPLACE INTO meta(key, value) VALUES (?, ?)').run(
IMPORTED_SIDECAR_KEY,
importedSidecar
)
}
db.exec('COMMIT')
} catch (error) {
if (db.isTransaction) {
db.exec('ROLLBACK')
}
throw error
}
}
function rowId(kind: string, key: string): string {
return `${kind}\u0000${key}`
}
@@ -0,0 +1,118 @@
import type { StoreRuntimeState } from './store-runtime-state'
import {
GITHUB_CACHE_BARRIER_BUSY_TIMEOUT_MS,
GITHUB_CACHE_ROUTINE_BUSY_TIMEOUT_MS,
isGitHubCacheBusyError,
writeGitHubCacheDatabase
} from './github-cache-database'
type GitHubCachePersistenceRuntime = Pick<
StoreRuntimeState,
| 'dataFile'
| 'githubCacheDirty'
| 'githubCacheLegacyFileHash'
| 'githubCachePersistedAt'
| 'githubCachePersistTimer'
| 'githubCacheRetryDelayMs'
| 'profileMaintenancePending'
| 'quitFlushStarted'
| 'state'
| 'writesFrozen'
>
/**
* The cache only seeds next launch's sidebar before the first poll, so routine saves wait this
* long; quit and explicit flush barriers save at once. A crash loses at most this much churn,
* which the next poll refetches.
*/
const GITHUB_CACHE_PERSIST_INTERVAL_MS = 3 * 60_000
const GITHUB_CACHE_FIRST_RETRY_MS = 5_000
/**
* `routine` saves wait for the interval (or a pending retry) and never run while maintenance,
* finalization or a freeze owns the profile. `barrier` is an admitted maintenance or final flush:
* it saves now. Barriers are best-effort for this disposable cache: a failure (such as another
* writer's lock) is logged and retried later rather than failing quit or a profile switch, and
* the cost is stale sidebar facts until the first poll after restart.
*/
export function persistGithubCache(
runtime: GitHubCachePersistenceRuntime,
mode: 'routine' | 'barrier'
): void {
if (!runtime.githubCacheDirty) {
return
}
if (mode === 'barrier') {
writeGithubCacheNow(runtime, true)
return
}
// An armed timer is the next save: the interval or a busy retry's backoff.
if (isFenced(runtime) || runtime.githubCachePersistTimer !== null) {
return
}
const remaining = runtime.githubCachePersistedAt + GITHUB_CACHE_PERSIST_INTERVAL_MS - Date.now()
if (remaining <= 0) {
writeGithubCacheNow(runtime, false)
} else {
armGithubCacheTimer(runtime, remaining, 'interval')
}
}
/** Maintenance, freeze or quit owns the profile; resume (if any) rearms through persistGithubCache. */
export function suspendGithubCachePersistence(runtime: GitHubCachePersistenceRuntime): void {
if (runtime.githubCachePersistTimer !== null) {
clearTimeout(runtime.githubCachePersistTimer)
runtime.githubCachePersistTimer = null
}
}
/** `retry` saves at its own backoff deadline; `interval` re-applies the routine cadence. */
function armGithubCacheTimer(
runtime: GitHubCachePersistenceRuntime,
delayMs: number,
kind: 'interval' | 'retry'
): void {
runtime.githubCachePersistTimer = setTimeout(() => {
runtime.githubCachePersistTimer = null
if (kind === 'interval') {
persistGithubCache(runtime, 'routine')
} else if (runtime.githubCacheDirty && !isFenced(runtime)) {
writeGithubCacheNow(runtime, false)
}
}, delayMs)
runtime.githubCachePersistTimer.unref?.()
}
function isFenced(runtime: GitHubCachePersistenceRuntime): boolean {
return runtime.writesFrozen || runtime.profileMaintenancePending || runtime.quitFlushStarted
}
function writeGithubCacheNow(runtime: GitHubCachePersistenceRuntime, barrier: boolean): void {
suspendGithubCachePersistence(runtime)
try {
// Synchronous: no later mutation can interleave, so the cache is clean once this returns.
const retired = writeGitHubCacheDatabase(runtime.dataFile, runtime.state.githubCache, {
busyTimeoutMs: barrier
? GITHUB_CACHE_BARRIER_BUSY_TIMEOUT_MS
: GITHUB_CACHE_ROUTINE_BUSY_TIMEOUT_MS,
importedLegacyFileHash: runtime.githubCacheLegacyFileHash
})
// A sidecar left behind is harmless: the committed fingerprint retires it next launch.
void retired.catch(() => {})
runtime.githubCacheDirty = false
runtime.githubCacheLegacyFileHash = null
runtime.githubCachePersistedAt = Date.now()
runtime.githubCacheRetryDelayMs = 0
} catch (error) {
// Another connection holds the lock (or the disk refused): back off rather than block.
const delay = Math.min(
Math.max(GITHUB_CACHE_FIRST_RETRY_MS, runtime.githubCacheRetryDelayMs * 2),
GITHUB_CACHE_PERSIST_INTERVAL_MS
)
runtime.githubCacheRetryDelayMs = delay
armGithubCacheTimer(runtime, delay, 'retry')
if (!isGitHubCacheBusyError(error)) {
console.warn('[persistence] Failed to write github cache snapshot:', error)
}
}
}
@@ -0,0 +1,56 @@
import { createHash } from 'node:crypto'
import { readFileSync } from 'node:fs'
import { readFile, rm } from 'node:fs/promises'
import type { PersistedState } from '../../../shared/persisted-state-types'
import { getGithubCacheFile } from './user-data-path'
type GitHubCache = PersistedState['githubCache']
export function sha256(content: string | Buffer): string {
return createHash('sha256').update(content).digest('hex')
}
/**
* The JSON sidecar older builds write, parsed and fingerprinted from one read of its bytes, so a
* concurrent replacement can never pair one file's content with another's fingerprint.
* Missing or unparsable sidecars read as null, as they always have.
*/
export function readGitHubCacheSidecar(dataFile: string): GitHubCacheSidecar | null {
let bytes: Buffer
try {
// One-shot: the synchronous profile load reads this once at startup.
bytes = readFileSync(getGithubCacheFile(dataFile))
} catch {
return null
}
return parseGitHubCacheSidecar(bytes)
}
/** Remove the sidecar off the save path, only if it is still the file the rows superseded. */
export async function retireGitHubCacheSidecar(dataFile: string, hash: string): Promise<void> {
const path = getGithubCacheFile(dataFile)
const bytes = await readFile(path).catch(() => null)
if (bytes && parseGitHubCacheSidecar(bytes)?.hash === hash) {
await rm(path, { force: true })
}
}
type GitHubCacheSidecar = { cache: GitHubCache; hash: string }
function parseGitHubCacheSidecar(bytes: Buffer): GitHubCacheSidecar | null {
let parsed: unknown
try {
parsed = JSON.parse(bytes.toString('utf-8'))
} catch {
return null
}
if (!isRecord(parsed) || !isRecord(parsed.pr) || !isRecord(parsed.issue)) {
return null
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: same shape check the sidecar reader always applied; entries stay opaque cache payloads.
return { cache: parsed as GitHubCache, hash: sha256(bytes) }
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
@@ -0,0 +1,100 @@
import { existsSync } from 'node:fs'
import type { PersistedState } from '../../../shared/persisted-state-types'
import SyncDatabase from '../../sqlite/sync-database'
import {
isUnusableDatabaseError,
removeVersionedCacheDatabase
} from '../../sqlite/versioned-cache-database'
import {
GITHUB_CACHE_ROUTINE_BUSY_TIMEOUT_MS,
GITHUB_CACHE_SCHEMA_VERSION,
getGithubCacheDatabaseFile,
IMPORTED_SIDECAR_KEY,
readGitHubCacheEntries
} from './github-cache-database'
import { readGitHubCacheSidecar } from './github-cache-sidecar'
type GitHubCache = PersistedState['githubCache']
export type StoredGitHubCache = {
/** What to hydrate, or null when nothing can be hydrated now. */
cache: GitHubCache | null
/** `cache` came from an older build's sidecar, so it must be saved as rows. */
fromSidecar: boolean
/** Fingerprint of a sidecar to retire once rows that supersede it commit. */
sidecarHash: string | null
}
type SavedRows =
| { kind: 'rows'; cache: GitHubCache; importedSidecar: string | null }
| { kind: 'empty' }
/** Rows exist but cannot be read now, usually another connection's lock. */
| { kind: 'unavailable' }
/**
* Startup read. An older build's sidecar is newer than the rows unless the rows record having
* imported exactly its bytes; while the rows cannot be read, that is unknown, so the sidecar is
* neither hydrated nor allowed to replace rows (the first commit supersedes it instead).
*/
export function readGitHubCacheDatabase(dataFile: string): StoredGitHubCache {
const sidecar = readGitHubCacheSidecar(dataFile)
const saved = readSavedRows(getGithubCacheDatabaseFile(dataFile))
if (sidecar && saved.kind !== 'unavailable') {
if (saved.kind === 'empty' || saved.importedSidecar !== sidecar.hash) {
return { cache: sidecar.cache, fromSidecar: true, sidecarHash: sidecar.hash }
}
}
const rows = saved.kind === 'rows' ? saved.cache : null
const hasRows = rows !== null && Object.keys(rows.pr).length + Object.keys(rows.issue).length > 0
return { cache: hasRows ? rows : null, fromSidecar: false, sidecarHash: sidecar?.hash ?? null }
}
/** One read-only snapshot: no write lock, so another writer cannot block or fail startup. */
function readSavedRows(path: string): SavedRows {
// One-shot: the synchronous profile load reads this once at startup.
if (!existsSync(path)) {
return { kind: 'empty' }
}
let db: SyncDatabase | null = null
try {
db = new SyncDatabase(path, { readonly: true, fileMustExist: true })
db.pragma(`busy_timeout = ${GITHUB_CACHE_ROUTINE_BUSY_TIMEOUT_MS}`)
db.exec('BEGIN')
try {
const tables = db
.prepare(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name IN ('meta', 'entries')"
)
.all()
const version =
tables.length === 2
? db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get()
: undefined
if (version?.value !== String(GITHUB_CACHE_SCHEMA_VERSION)) {
// Another schema rebuilds empty at the next save, as a mismatched cache always has.
return { kind: 'empty' }
}
const marker = db.prepare('SELECT value FROM meta WHERE key = ?').get(IMPORTED_SIDECAR_KEY)
return {
kind: 'rows',
cache: readGitHubCacheEntries(db),
importedSidecar: typeof marker?.value === 'string' ? marker.value : null
}
} finally {
if (db.isTransaction) {
db.exec('COMMIT')
}
}
} catch (error) {
if (!isUnusableDatabaseError(error)) {
return { kind: 'unavailable' }
}
db?.close()
db = null
// Damaged rows hold nothing recoverable; the next save rebuilds the cache.
removeVersionedCacheDatabase(path, 'fail-fast')
return { kind: 'empty' }
} finally {
db?.close()
}
}
@@ -18,7 +18,7 @@ import {
isLegacyOpenCodeSessionCookie,
isLegacySshPtyOwnerLease
} from '../leasing-ssh-ptys/secret-validation'
import { readGithubCacheSnapshot } from './user-data-path'
import { readGitHubCacheDatabase } from './github-cache-startup-read'
import { retainLegacyOpenCodeGoApiKey } from './legacy-opencode-go-api-key-migration'
import {
gcStaleWorktreeMeta,
@@ -50,6 +50,7 @@ type LoadedStateParsingOperationsRuntime = Pick<
StoreRuntimeState,
| 'dataFile'
| 'githubCacheDirty'
| 'githubCacheLegacyFileHash'
| 'loadNeedsSave'
| 'originalExperimentalNativeChat'
| 'protectedSecrets'
@@ -287,18 +288,25 @@ export class LoadedStateParsingOperations {
chatUiLaunchTipAudience: audience
}
// Legacy inline caches seed the session, then move to the cache sidecar.
// Cache precedence, resolved once: an inline legacy cache, else an older build's sidecar,
// else the saved rows. Whatever wins is saved as rows, which retires any sidecar it supersedes.
const legacyCache = migrated.githubCache
const hasLegacyCache =
Object.keys(legacyCache?.pr ?? {}).length > 0 ||
Object.keys(legacyCache?.issue ?? {}).length > 0
const stored = readStoredGitHubCache(this.runtime.dataFile)
if (hasLegacyCache) {
this.runtime.loadNeedsSave = true
// Persist the seed even when no cache poll refresh occurs this session.
this.runtime.githubCacheDirty = true
} else {
migrated.githubCache = readGithubCacheSnapshot(this.runtime.dataFile) ?? migrated.githubCache
} else if (stored?.cache) {
migrated.githubCache = stored.cache
// A sidecar import, or rows that already hold it, saves so the sidecar retires.
this.runtime.githubCacheDirty ||= stored.sidecarHash !== null
}
// While the rows are unreadable nothing is dirty, so no save can replace them; a later
// commit of polled state supersedes the unhydrated sidecar instead.
this.runtime.githubCacheLegacyFileHash = stored?.sidecarHash ?? null
logStartupMilestone('persistence-load-done', () => ({
repos: migrated.repos.length,
@@ -307,3 +315,15 @@ export class LoadedStateParsingOperations {
return migrated
}
}
function readStoredGitHubCache(
dataFile: string
): ReturnType<typeof readGitHubCacheDatabase> | null {
try {
return readGitHubCacheDatabase(dataFile)
} catch (error) {
// A disposable cache must never block startup; the first poll refills it.
console.warn('[persistence] Failed to read github cache:', error)
return null
}
}
@@ -23,6 +23,7 @@ import {
} from '../applying-settings/feature-interaction-recording'
import type { StoreRuntimeState } from './store-runtime-state'
import { persistGithubCache } from './github-cache-persistence'
import type { WriteSchedulingOperations } from './write-scheduling'
import { scheduleSave } from './write-scheduling'
import { bumpLocalWorktreeScanGeneration } from '../../workspaces/catalog/local-worktree-scan-generation'
@@ -30,9 +31,16 @@ import { bumpLocalWorktreeScanGeneration } from '../../workspaces/catalog/local-
type ProfilePreferencesRuntime = Pick<
StoreRuntimeState,
| 'activeViewPreference'
| 'dataFile'
| 'githubCacheDirty'
| 'githubCacheGeneration'
| 'githubCachePersistedAt'
| 'githubCacheLegacyFileHash'
| 'githubCachePersistTimer'
| 'githubCacheRetryDelayMs'
| 'githubCacheWriteListeners'
| 'profileMaintenancePending'
| 'quitFlushStarted'
| 'writesFrozen'
| 'protectedSecrets'
| 'runDurableMutation'
| 'settingsChangeListeners'
@@ -197,7 +205,7 @@ export class ProfilePreferences {
// Cache polls write a sidecar at flush; scheduling a full profile save here amplifies every poll.
this[profilePreferencesContext].runtime.state.githubCache = cache
this[profilePreferencesContext].runtime.githubCacheDirty = true
this[profilePreferencesContext].runtime.githubCacheGeneration += 1
persistGithubCache(this[profilePreferencesContext].runtime, 'routine')
for (const listener of this[profilePreferencesContext].runtime.githubCacheWriteListeners) {
listener()
}
@@ -5,6 +5,7 @@ import type { StoreRuntimeState } from './store-runtime-state'
import { drainProfileStateOperations } from './profile-state-flush-lifetime'
import { flushCurrentStateAsync } from './write-flush-barriers'
import { scheduleSave } from './write-scheduling'
import { persistGithubCache, suspendGithubCachePersistence } from './github-cache-persistence'
export type ProfileStateMaintenanceOptions = {
signal?: AbortSignal
@@ -21,6 +22,7 @@ export function freezeProfileStateWrites(runtime: StoreRuntimeState): void {
clearTimeout(runtime.writeTimer)
runtime.writeTimer = null
}
suspendGithubCachePersistence(runtime)
runtime.profileStateAuthority?.close?.()
}
@@ -30,6 +32,7 @@ export async function freezeProfileStateWritesAsync(runtime: StoreRuntimeState):
clearTimeout(runtime.writeTimer)
runtime.writeTimer = null
}
suspendGithubCachePersistence(runtime)
try {
await drainProfileStateOperations([
runtime.pendingProfileMaintenance,
@@ -56,6 +59,7 @@ export function beginProfileStateMaintenance(
clearTimeout(runtime.writeTimer)
runtime.writeTimer = null
}
suspendGithubCachePersistence(runtime)
const resumePreference = runtime.activeViewPreference.pauseForMaintenance()
const resumeScheduling = () => {
runtime.writesFrozen = false
@@ -63,6 +67,8 @@ export function beginProfileStateMaintenance(
runtime.pendingProfileMaintenance = null
resumePreference()
scheduleSave(domains.scheduling)
// Updates admitted while paused (or a retry it skipped) resume their routine save.
persistGithubCache(runtime, 'routine')
}
const paused = pauseProfileState(runtime, domains, options).then((authority) => {
let consumed = false
@@ -162,7 +168,7 @@ async function drainProfileFileWork(runtime: StoreRuntimeState): Promise<void> {
runtime.staleProfileStateTempCleanup,
runtime.pendingWrite,
runtime.pendingSnapshotFileWork,
runtime.pendingGithubCacheWrite,
runtime.staleGithubCacheTempCleanup,
runtime.activeViewPreference.waitForPendingWrite(),
runtime.profileStateAuthority?.drainBackups?.(
runtime.profileMaintenancePending || runtime.quitFlushStarted
@@ -83,9 +83,12 @@ export class StoreRuntimeState {
dirtyProfileStateDomains: Set<string> | null = new Set()
pendingAutomationRunsAfter: readonly AutomationRun[] | undefined
githubCacheDirty = false
githubCacheGeneration = 0
githubCachePersistedAt = Date.now()
githubCachePersistTimer: ReturnType<typeof setTimeout> | null = null
githubCacheRetryDelayMs = 0
/** Fingerprint of a sidecar the loaded cache imported or supersedes; retired once rows commit. */
githubCacheLegacyFileHash: string | null = null
readonly githubCacheWriteListeners = new Set<() => void>()
pendingGithubCacheWrite: Promise<void> | null = null
readonly staleGithubCacheTempCleanup: Promise<void>
readonly staleProfileStateTempCleanup: Promise<void>
readonly gitUsernameCache = new Map<string, string>()
@@ -1,7 +1,6 @@
import { getAppEnvironment } from '../../../shared/app-environment'
import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'
import { copyFileSync, existsSync, mkdirSync, rmSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import type { PersistedState } from '../../../shared/persisted-state-types'
import { hardenExistingSecureFile } from '../../../shared/secure-storage/secure-file'
import { MOBILE_PAIRING_USERDATA_FILES } from '../../runtime/rpc/server/pairing/mobile-pairing-files'
@@ -29,22 +28,6 @@ export function getGithubCacheFile(dataFile = getDataFile()): string {
return join(dirname(dataFile), 'orca-github-cache.json')
}
export function readGithubCacheSnapshot(dataFile: string): PersistedState['githubCache'] | null {
try {
const parsed = JSON.parse(readFileSync(getGithubCacheFile(dataFile), 'utf-8')) as unknown
const isPlainRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value)
if (
isPlainRecord(parsed) &&
isPlainRecord((parsed as { pr?: unknown }).pr) &&
isPlainRecord((parsed as { issue?: unknown }).issue)
) {
return parsed as PersistedState['githubCache']
}
} catch {}
return null
}
/** Reuse the path captured before app.setName; late resolution can change casing and strand paired devices. */
export function getCanonicalUserDataPath(): string {
if (!_userDataDir) {
@@ -1,7 +1,4 @@
import { writeFileSync, renameSync, unlinkSync } from 'node:fs'
import { rename, rm, writeFile } from 'node:fs/promises'
import { durableWriteTempPath } from '../../filesystem/durable-file-write'
import { getGithubCacheFile } from './user-data-path'
import { persistGithubCache } from './github-cache-persistence'
import type { StoreRuntimeState } from './store-runtime-state'
import type { PrimaryStateWriteOperations } from './primary-state-writes'
@@ -16,16 +13,17 @@ type WriteFlushBarrierOperationsRuntime = Pick<
| 'dataFile'
| 'firstPendingSaveAt'
| 'githubCacheDirty'
| 'githubCacheGeneration'
| 'githubCachePersistedAt'
| 'githubCacheLegacyFileHash'
| 'githubCachePersistTimer'
| 'githubCacheRetryDelayMs'
| 'lastDurableWriteGeneration'
| 'pendingGithubCacheWrite'
| 'pendingProfileFlushes'
| 'pendingProfileMaintenance'
| 'profileMaintenancePending'
| 'profileStateAuthority'
| 'quitFlushPromise'
| 'quitFlushStarted'
| 'staleGithubCacheTempCleanup'
| 'staleProfileStateTempCleanup'
| 'state'
| 'writeGeneration'
@@ -70,7 +68,7 @@ export class WriteFlushBarrierOperations {
} catch (err) {
console.error('[active-view] Failed to flush preference:', err)
}
writeGithubCacheSnapshotSync(this)
persistGithubCache(runtime, 'routine')
}
flushAsync(): Promise<void> {
@@ -177,7 +175,11 @@ export async function flushCurrentStateAsync(
await (final
? runtime.activeViewPreference.flushAsync()
: runtime.activeViewPreference.flushPendingAsync(signal))
await writeGithubCacheSnapshotAsync(owner, final, signal)
// Quit and explicit barriers save the cache now; best-effort flushes may wait.
persistGithubCache(
runtime,
final || requireInitialGenerationDurable ? 'barrier' : 'routine'
)
if (final || runtime.profileMaintenancePending) {
await runtime.profileStateAuthority?.drainBackups?.(true)
}
@@ -200,83 +202,3 @@ export async function flushCurrentStateAsync(
}
})
}
export async function writeGithubCacheSnapshotAsync(
owner: WriteFlushBarrierOperations,
drainToStableGeneration = true,
signal?: AbortSignal
): Promise<void> {
const { runtime } = owner[writeFlushBarrierOperationsContext]
if (!runtime.githubCacheDirty) {
return
}
const previousWrite = runtime.pendingGithubCacheWrite ?? runtime.staleGithubCacheTempCleanup
const nextWrite = previousWrite
.then(async () => {
while (runtime.githubCacheDirty) {
if (signal?.aborted) {
throw new Error('GitHub cache flush aborted')
}
const generation = runtime.githubCacheGeneration
const cacheFile = getGithubCacheFile(runtime.dataFile)
const tmpFile = durableWriteTempPath(cacheFile)
let renamed = false
try {
await writeFile(tmpFile, JSON.stringify(runtime.state.githubCache), 'utf-8')
if (generation === runtime.githubCacheGeneration) {
await rename(tmpFile, cacheFile)
renamed = true
if (generation === runtime.githubCacheGeneration) {
runtime.githubCacheDirty = false
}
}
} finally {
if (!renamed) {
await rm(tmpFile).catch(() => {})
}
}
if (signal?.aborted) {
throw new Error('GitHub cache flush aborted')
}
if (!drainToStableGeneration) {
break
}
}
})
.catch((err) => {
console.warn('[persistence] Failed to write github cache snapshot:', err)
})
.finally(() => {
if (runtime.pendingGithubCacheWrite === nextWrite) {
runtime.pendingGithubCacheWrite = null
}
})
runtime.pendingGithubCacheWrite = nextWrite
await nextWrite
}
export function writeGithubCacheSnapshotSync(owner: WriteFlushBarrierOperations): void {
const { runtime } = owner[writeFlushBarrierOperationsContext]
if (!runtime.githubCacheDirty) {
return
}
if (runtime.pendingGithubCacheWrite) {
void writeGithubCacheSnapshotAsync(owner)
return
}
const cacheFile = getGithubCacheFile(runtime.dataFile)
const generation = runtime.githubCacheGeneration
const tmpFile = durableWriteTempPath(cacheFile)
try {
writeFileSync(tmpFile, JSON.stringify(runtime.state.githubCache), 'utf-8')
renameSync(tmpFile, cacheFile)
if (generation === runtime.githubCacheGeneration) {
runtime.githubCacheDirty = false
}
} catch (err) {
try {
unlinkSync(tmpFile)
} catch {}
console.warn('[persistence] Failed to write github cache snapshot:', err)
}
}
@@ -152,27 +152,6 @@ describe('worker persistence avoids synchronous profile filesystem calls', () =>
expect(readState().ui.sidebarWidth).toBe(511)
})
it('drains mutations made while a sidecar rename is stalled', async () => {
const { store, readState } = await createWorkerMaintenanceFixture()
const started = maintenanceBarrier()
const finish = maintenanceBarrier()
fsCalls.waitAsync = (name, path) => {
if (name !== 'rename' || !path.includes('orca-github-cache.json.')) {
return
}
started.resolve()
return finish.promise
}
store.updateUI({ sidebarWidth: 611 })
store.setGitHubCache({ pr: {}, issue: {} })
const barrier = store.flushPendingOrThrowAsync()
await started.promise
store.updateUI({ sidebarWidth: 612 })
finish.resolve()
await barrier
expect(readState().ui.sidebarWidth).toBe(612)
})
it('serializes queued durable mutations and refuses a synchronous live flush', async () => {
const { store, authority, readState } = await createWorkerMaintenanceFixture()
const gate = pauseCommit(authority)
@@ -190,7 +190,7 @@ describe('Store', () => {
})
})
it('keeps GitHub cache sidecars scoped to explicit profile data files', async () => {
it('keeps the GitHub cache scoped to explicit profile data files', async () => {
const profileADir = join(testState.dir, 'profiles', 'a')
const profileBDir = join(testState.dir, 'profiles', 'b')
const profileADataFile = join(profileADir, 'orca-data.json')
@@ -204,12 +204,12 @@ describe('Store', () => {
initDataPath()
const profileAStore = createSqliteTestStore(Store, { dataFile: profileADataFile })
profileAStore.setGitHubCache({ pr: { 'o/r#a': { fetchedAt: 10 } as never }, issue: {} })
profileAStore.flush()
await profileAStore.flushPendingOrThrowAsync()
const profileBStore = createSqliteTestStore(Store, { dataFile: profileBDataFile })
expect(profileBStore.getGitHubCache().pr['o/r#a']).toBeUndefined()
profileBStore.setGitHubCache({ pr: { 'o/r#b': { fetchedAt: 20 } as never }, issue: {} })
profileBStore.flush()
await profileBStore.flushPendingOrThrowAsync()
const restartedProfileA = createSqliteTestStore(Store, { dataFile: profileADataFile })
const restartedProfileB = createSqliteTestStore(Store, { dataFile: profileBDataFile })
@@ -286,45 +286,6 @@ describe('quit-path durable writes never park the main thread', () => {
expect(readState(dir).ui.sidebarWidth).toBe(200)
})
it('serializes github-cache snapshots and keeps the newest generation', async () => {
const dir = makeDir()
const store = await createStore(dir)
let releaseWrite!: () => void
const writeRelease = new Promise<void>((resolve) => {
releaseWrite = resolve
})
let signalWrite!: () => void
releases.push(releaseWrite)
const writeStarted = new Promise<void>((resolve) => {
signalWrite = resolve
})
let held = false
fsCalls.dirPrefix = dir
fsCalls.recording = true
fsCalls.waitAsync = (fn, target) => {
if (held || fn !== 'writeFile' || !target.includes('orca-github-cache.json')) {
return null
}
held = true
signalWrite()
return writeRelease
}
store.setGitHubCache({ pr: { test: { data: null, fetchedAt: 1 } }, issue: {} })
const finalFlush = store.flushAsync()
await writeStarted
store.setGitHubCache({ pr: { test: { data: null, fetchedAt: 2 } }, issue: {} })
releaseWrite()
await finalFlush
fsCalls.recording = false
expect(JSON.parse(readFileSync(join(dir, 'orca-github-cache.json'), 'utf-8'))).toEqual({
pr: { test: { data: null, fetchedAt: 2 } },
issue: {}
})
})
it('stats.flushAsync() wins over a debounced write already in flight', async () => {
const dir = makeDir()
const stats = await createStatsCollector(dir)