From 39d3592080d912537d414e4c1203b46d20b9cec5 Mon Sep 17 00:00:00 2001 From: h0x91B Date: Thu, 11 Jun 2026 12:35:10 +0300 Subject: [PATCH] Cut background polling CPU churn with read caches and fetch backoff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Log audit showed ~8,200 subprocess spawns and 6,400 full tasks.json re-parses per hour driven by background pollers. Four targeted fixes: - data.ts: mtime/size stat-validated cache for loadProjects/loadTasks; cache hits return shallow copies, mutators bypass, saves invalidate - git.ts fetchOrigin: exponential backoff (2min..30min) for failing fetches — cooldown was previously only set on success, so repos with a dead remote were re-fetched on every poller tick forever - git.ts detectDefaultCompareRef: 10min TTL cache (runs git shortlog over two weeks of history, was invoked by every getResolvedProject) - github.ts: cache gh auth status (60s, authenticated only) and gh auth token (5min) instead of 3 subprocesses per GitHub call See decisions/067-background-polling-read-caches.md. --- .../06/11/fix-background-polling-cpu-churn.md | 1 + .../067-background-polling-read-caches.md | 26 +++ src/bun/__tests__/data-cache.test.ts | 189 ++++++++++++++++++ src/bun/__tests__/git-branch-ops.test.ts | 60 ++++++ src/bun/__tests__/git-fetch-snapshot.test.ts | 73 +++++++ src/bun/__tests__/github.test.ts | 86 ++++++++ src/bun/data.ts | 68 ++++++- src/bun/git.ts | 67 +++++++ src/bun/github.ts | 54 ++++- 9 files changed, 619 insertions(+), 5 deletions(-) create mode 100644 change-logs/2026/06/11/fix-background-polling-cpu-churn.md create mode 100644 decisions/067-background-polling-read-caches.md create mode 100644 src/bun/__tests__/data-cache.test.ts diff --git a/change-logs/2026/06/11/fix-background-polling-cpu-churn.md b/change-logs/2026/06/11/fix-background-polling-cpu-churn.md new file mode 100644 index 00000000..f8524a55 --- /dev/null +++ b/change-logs/2026/06/11/fix-background-polling-cpu-churn.md @@ -0,0 +1 @@ +Cut background CPU churn from polling: tasks.json/projects.json reads are now served from an mtime-validated cache (was ~6,400 full re-parses per hour), failing `git fetch origin` gets an exponential backoff instead of retrying every poller tick, `detectDefaultCompareRef` (git shortlog) is cached for 10 minutes, and `gh auth status`/`gh auth token` results are cached instead of spawning three subprocesses per GitHub call. diff --git a/decisions/067-background-polling-read-caches.md b/decisions/067-background-polling-read-caches.md new file mode 100644 index 00000000..a00dff1a --- /dev/null +++ b/decisions/067-background-polling-read-caches.md @@ -0,0 +1,26 @@ +# 067 — Read caches for background polling hot paths + +## Context + +A one-hour log audit showed ~8,200 subprocess spawns/hour and 6,400+ full reads of `tasks.json` (1.4 MB for large projects). Main drivers: the 60s merge-detection poller, the renderer's 10-15s status polls, and `gh` auth resolution spawning 3 subprocesses per GitHub call. Repos with a dead remote were re-fetched every poller tick forever because the fetch cooldown was only set on success. + +## Decision + +Four independent caches, all in the bun process: + +1. **`src/bun/data.ts`** — mtime+size stat-validated cache for `loadProjects()`/`loadTasks()`. `stat()` is taken *before* `readFile` so a concurrent write can only over-invalidate, never serve stale. Cache hits return shallow copies (`{...item}`); mutator paths (`strict`/`persistMigrations`) bypass the cache; saves invalidate it. +2. **`src/bun/git.ts` `fetchOrigin`** — exponential failure backoff (2 min base, doubling, 30 min cap) per `projectPath:branch` key, cleared on success. +3. **`src/bun/git.ts` `detectDefaultCompareRef`** — 10 min TTL promise cache keyed on `projectPath\0baseBranch` (it runs `git shortlog` over 2 weeks of history on every `resolveProjectConfig`). +4. **`src/bun/github.ts`** — `gh auth status` cached 60s (only the `authenticated` result, so a fresh `gh auth login` is visible immediately) and `gh auth token` cached 5 min per host+login. + +## Risks + +- Callers of `loadProjects()`/`loadTasks()` must treat results as read-only snapshots. Shallow copies protect against array/top-level field mutation, but nested objects (notes arrays) are shared with the cache. All mutations must keep going through `data.updateTask`/`saveTasks`. +- Multi-instance setups (prod + dev app sharing `~/.dev3.0/`) stay correct because validation is a per-read `stat()`, not in-process invalidation. Two writes within the same mtime tick AND identical file size could serve one stale read; considered negligible. +- A user-triggered git operation during a fetch failure backoff window skips the fetch (returns `false`) — same observable behavior as the fetch failing, which it just did. + +## Alternatives considered + +- Slowing down poller intervals: treats the symptom, degrades merge-detection latency, and doesn't fix the unbounded retry of failing fetches. +- Deep-cloning cache hits (`structuredClone`): eats most of the parse-avoidance win; rejected in favor of the read-only contract. +- Event-driven invalidation (fs watchers): more machinery for no extra correctness — stat-per-read already handles cross-process writes. diff --git a/src/bun/__tests__/data-cache.test.ts b/src/bun/__tests__/data-cache.test.ts new file mode 100644 index 00000000..47c921c6 --- /dev/null +++ b/src/bun/__tests__/data-cache.test.ts @@ -0,0 +1,189 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { mkdirSync, rmSync, writeFileSync, utimesSync } from "node:fs"; +import type { Project, Task } from "../../shared/types"; + +const { logDebug } = vi.hoisted(() => ({ logDebug: vi.fn() })); + +vi.mock("../logger", () => ({ + createLogger: () => ({ + debug: logDebug, + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), +})); + +vi.mock("../paths", () => ({ + DEV3_HOME: "/tmp/dev3-test-data-cache", +})); + +vi.mock("../cow-clone", () => ({ + detectClonePaths: vi.fn(() => Promise.resolve([])), +})); + +vi.mock("../file-lock", () => ({ + withFileLock: async (_filePath: string, fn: () => Promise): Promise => fn(), +})); + +import { _resetDataCaches, loadProjects, loadTasks, saveTasks } from "../data"; + +const HOME = "/tmp/dev3-test-data-cache"; +const PROJECT_PATH = "/tmp/dev3-cache-project"; +const SLUG = "tmp-dev3-cache-project"; + +function makeProject(overrides: Partial = {}): Project { + return { + id: "p1", + path: PROJECT_PATH, + name: "cache-project", + defaultBaseBranch: "main", + labels: [], + customColumns: [], + ...overrides, + } as Project; +} + +function makeTask(overrides: Partial = {}): Task { + return { + id: "t1", + seq: 1, + projectId: "p1", + title: "task one", + description: "task one", + status: "todo", + baseBranch: "main", + worktreePath: null, + branchName: null, + groupId: null, + variantIndex: null, + agentId: null, + configId: null, + labelIds: [], + notes: [], + customTitle: null, + titleEditedByUser: false, + customColumnId: null, + overview: null, + userOverview: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + tmuxSocket: "dev3", + ...overrides, + } as Task; +} + +function writeTasksFile(tasks: Task[]): void { + mkdirSync(`${HOME}/data/${SLUG}`, { recursive: true }); + writeFileSync(`${HOME}/data/${SLUG}/tasks.json`, JSON.stringify(tasks, null, 2)); +} + +function writeProjectsFile(projects: Project[]): void { + mkdirSync(HOME, { recursive: true }); + writeFileSync(`${HOME}/projects.json`, JSON.stringify(projects, null, 2)); +} + +function loadingTasksLogCount(): number { + return logDebug.mock.calls.filter(([msg]) => msg === "Loading tasks").length; +} + +function loadingProjectsLogCount(): number { + return logDebug.mock.calls.filter(([msg]) => msg === "Loading all projects").length; +} + +beforeEach(() => { + rmSync(HOME, { recursive: true, force: true }); + mkdirSync(HOME, { recursive: true }); + _resetDataCaches(); + logDebug.mockClear(); +}); + +describe("tasks read cache", () => { + it("serves repeated loads from cache without re-reading the file", async () => { + const project = makeProject(); + writeTasksFile([makeTask()]); + + const first = await loadTasks(project); + expect(first).toHaveLength(1); + expect(loadingTasksLogCount()).toBe(1); + + const second = await loadTasks(project); + expect(second).toHaveLength(1); + expect(second[0].id).toBe("t1"); + // Cache hit: no second disk read. + expect(loadingTasksLogCount()).toBe(1); + }); + + it("cache hits return independent copies", async () => { + const project = makeProject(); + writeTasksFile([makeTask()]); + + const first = await loadTasks(project); + const second = await loadTasks(project); + expect(second).not.toBe(first); + expect(second[0]).not.toBe(first[0]); + + first[0].title = "mutated"; + first.push(makeTask({ id: "phantom" })); + const third = await loadTasks(project); + expect(third).toHaveLength(1); + expect(third[0].title).toBe("task one"); + }); + + it("re-reads after saveTasks invalidates the cache", async () => { + const project = makeProject(); + writeTasksFile([makeTask()]); + + await loadTasks(project); + await saveTasks(project, [makeTask(), makeTask({ id: "t2", seq: 2 })]); + + const reloaded = await loadTasks(project); + expect(reloaded).toHaveLength(2); + }); + + it("re-reads after an external write changes the file", async () => { + const project = makeProject(); + writeTasksFile([makeTask()]); + + await loadTasks(project); + writeTasksFile([makeTask(), makeTask({ id: "t2", seq: 2 }), makeTask({ id: "t3", seq: 3 })]); + // Force a distinct mtime in case writes land within the same clock tick. + utimesSync(`${HOME}/data/${SLUG}/tasks.json`, new Date(), new Date(Date.now() + 5000)); + + const reloaded = await loadTasks(project); + expect(reloaded).toHaveLength(3); + }); + + it("still returns empty list when tasks file is missing", async () => { + const project = makeProject(); + const tasks = await loadTasks(project); + expect(tasks).toEqual([]); + }); +}); + +describe("projects read cache", () => { + it("serves repeated loads from cache and re-reads after external change", async () => { + writeProjectsFile([makeProject()]); + + const first = await loadProjects(); + expect(first).toHaveLength(1); + expect(loadingProjectsLogCount()).toBe(1); + + const second = await loadProjects(); + expect(second).toHaveLength(1); + expect(loadingProjectsLogCount()).toBe(1); + + writeProjectsFile([makeProject(), makeProject({ id: "p2", path: "/tmp/dev3-cache-project-2" })]); + utimesSync(`${HOME}/projects.json`, new Date(), new Date(Date.now() + 5000)); + const third = await loadProjects(); + expect(third).toHaveLength(2); + }); + + it("cache hits return independent project copies", async () => { + writeProjectsFile([makeProject()]); + + const first = await loadProjects(); + first[0].name = "mutated"; + const second = await loadProjects(); + expect(second[0].name).toBe("cache-project"); + }); +}); diff --git a/src/bun/__tests__/git-branch-ops.test.ts b/src/bun/__tests__/git-branch-ops.test.ts index c976eafa..270d58f7 100644 --- a/src/bun/__tests__/git-branch-ops.test.ts +++ b/src/bun/__tests__/git-branch-ops.test.ts @@ -78,11 +78,13 @@ import { fetchFork, pullOrigin, _resetFetchState, + _resetCompareRefCache, } from "../git"; beforeEach(() => { spawnResponses = []; _resetFetchState(); + _resetCompareRefCache(); spawnMock.mockClear(); spawnMock.mockImplementation(() => { const response = spawnResponses.shift() ?? { exitCode: 1, stdout: "", stderr: "no response queued" }; @@ -566,6 +568,64 @@ describe("detectDefaultCompareRef", () => { expect(ref).toBe("origin/master"); }); + + it("caches the result for repeated calls (no extra git spawns)", async () => { + queueResponse(0, "origin\n"); // remotes + queueResponse(0, "abc123\n"); // rev-parse --verify origin/main + queueResponse(0, "abc123\n"); // rev-parse --verify main + queueResponse(0, ""); // branch --set-upstream-to origin/main main + queueResponse(0, " 10 Arseniy Pavlenko \n"); // shortlog + + const first = await detectDefaultCompareRef("/repo", "main"); + expect(first).toBe("main"); + expect(spawnResponses).toHaveLength(0); + + // Second call hits the cache — nothing queued, no spawn attempted + const second = await detectDefaultCompareRef("/repo", "main"); + expect(second).toBe("main"); + }); + + it("caches per projectPath+baseBranch key", async () => { + queueResponse(0, "origin\n"); + queueResponse(0, "abc123\n"); + queueResponse(0, "abc123\n"); + queueResponse(0, ""); + queueResponse(0, " 10 Arseniy Pavlenko \n"); + expect(await detectDefaultCompareRef("/repo", "main")).toBe("main"); + + // Different repo: full detection runs again + queueResponse(0, "origin\n"); + queueResponse(0, "abc123\n"); + queueResponse(0, "def456\n"); + queueResponse(0, ""); + queueResponse(0, " 10 a \n 3 b \n"); + expect(await detectDefaultCompareRef("/other-repo", "main")).toBe("origin/main"); + expect(spawnResponses).toHaveLength(0); + }); + + it("expires the cache after the TTL", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + try { + queueResponse(0, "origin\n"); + queueResponse(0, "abc123\n"); + queueResponse(0, "abc123\n"); + queueResponse(0, ""); + queueResponse(0, " 10 Arseniy Pavlenko \n"); + expect(await detectDefaultCompareRef("/repo", "main")).toBe("main"); + + vi.setSystemTime(Date.now() + 11 * 60_000); + + queueResponse(0, "origin\n"); + queueResponse(0, "abc123\n"); + queueResponse(0, "def456\n"); + queueResponse(0, ""); + queueResponse(0, " 10 a \n 3 b \n"); + expect(await detectDefaultCompareRef("/repo", "main")).toBe("origin/main"); + expect(spawnResponses).toHaveLength(0); + } finally { + vi.useRealTimers(); + } + }); }); // ─── getOriginUrl ──────────────────────────────────────────────────────────── diff --git a/src/bun/__tests__/git-fetch-snapshot.test.ts b/src/bun/__tests__/git-fetch-snapshot.test.ts index c82ba9fb..4c306908 100644 --- a/src/bun/__tests__/git-fetch-snapshot.test.ts +++ b/src/bun/__tests__/git-fetch-snapshot.test.ts @@ -99,6 +99,79 @@ describe("fetchOrigin", () => { expect(ok2).toBe(true); }); + it("skips retry while in failure backoff", async () => { + queueResponse(128, "", "fatal: no remote"); + const ok1 = await fetchOrigin("/repo"); + expect(ok1).toBe(false); + + // Immediate retry must be skipped without spawning git + queueResponse(0, ""); + const ok2 = await fetchOrigin("/repo"); + expect(ok2).toBe(false); + expect(spawnResponses).toHaveLength(1); // queued response not consumed + }); + + it("retries after the failure backoff window elapses", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + try { + queueResponse(128, "", "fatal: no remote"); + expect(await fetchOrigin("/repo")).toBe(false); + + // First failure backoff is 2 minutes — jump past it + vi.setSystemTime(Date.now() + 3 * 60_000); + queueResponse(0, ""); + expect(await fetchOrigin("/repo")).toBe(true); + expect(spawnResponses).toHaveLength(0); + } finally { + vi.useRealTimers(); + } + }); + + it("doubles the backoff on consecutive failures", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + try { + queueResponse(128, "", "fatal: no remote"); + expect(await fetchOrigin("/repo")).toBe(false); + + vi.setSystemTime(Date.now() + 3 * 60_000); + queueResponse(128, "", "fatal: no remote"); + expect(await fetchOrigin("/repo")).toBe(false); + + // Second failure backoff is 4 minutes — 3 minutes later is still inside it + vi.setSystemTime(Date.now() + 3 * 60_000); + queueResponse(0, ""); + expect(await fetchOrigin("/repo")).toBe(false); + expect(spawnResponses).toHaveLength(1); + + // Another 2 minutes (5 total) clears the 4-minute backoff + vi.setSystemTime(Date.now() + 2 * 60_000); + expect(await fetchOrigin("/repo")).toBe(true); + expect(spawnResponses).toHaveLength(0); + } finally { + vi.useRealTimers(); + } + }); + + it("a successful fetch clears the failure backoff", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + try { + queueResponse(128, "", "fatal: no remote"); + expect(await fetchOrigin("/repo")).toBe(false); + + vi.setSystemTime(Date.now() + 3 * 60_000); + queueResponse(0, ""); + expect(await fetchOrigin("/repo")).toBe(true); + + // Past the success cooldown: next fetch runs immediately (no backoff left) + vi.setSystemTime(Date.now() + 10_000); + queueResponse(0, ""); + expect(await fetchOrigin("/repo")).toBe(true); + expect(spawnResponses).toHaveLength(0); + } finally { + vi.useRealTimers(); + } + }); + it("allows fetch for different projects concurrently", async () => { queueResponse(0, ""); // for /repo-a queueResponse(0, ""); // for /repo-b diff --git a/src/bun/__tests__/github.test.ts b/src/bun/__tests__/github.test.ts index a3ef8b05..4e508629 100644 --- a/src/bun/__tests__/github.test.ts +++ b/src/bun/__tests__/github.test.ts @@ -181,6 +181,92 @@ describe("github", () => { }); }); + it("caches authenticated status — repeated calls spawn gh only once", async () => { + whichMock.mockResolvedValue("/opt/homebrew/bin/gh"); + spawnMock.mockImplementation(() => fakeProc(JSON.stringify({ + hosts: { + "github.com": [ + { login: "h0x91b", host: "github.com", active: true, state: "success" }, + ], + }, + }))); + + const { getGitHubCliStatus } = await import("../github"); + await getGitHubCliStatus(); + await getGitHubCliStatus(); + await getGitHubCliStatus(); + expect(spawnMock).toHaveBeenCalledTimes(1); + }); + + it("does not cache not_authenticated status", async () => { + whichMock.mockResolvedValue("/usr/bin/gh"); + spawnMock.mockImplementation((cmd: string[]) => { + if (cmd.join(" ") === "gh auth status --json hosts") { + return fakeProc("", "unknown flag: --json\n", 1); + } + return fakeProc("", "You are not logged into any GitHub hosts.\n", 1); + }); + + const { getGitHubCliStatus } = await import("../github"); + expect((await getGitHubCliStatus()).authStatus).toBe("not_authenticated"); + const callsAfterFirst = spawnMock.mock.calls.length; + + expect((await getGitHubCliStatus()).authStatus).toBe("not_authenticated"); + // Second call re-checks (not served from cache) + expect(spawnMock.mock.calls.length).toBeGreaterThan(callsAfterFirst); + }); + + it("caches the resolved token — repeated getGitHubAuthEnv spawns auth token once", async () => { + whichMock.mockResolvedValue("/opt/homebrew/bin/gh"); + const tokenCalls: string[] = []; + spawnMock.mockImplementation((cmd: string[]) => { + if (cmd.join(" ") === "gh auth status --json hosts") { + return fakeProc(JSON.stringify({ + hosts: { + "github.com": [ + { login: "h0x91b", host: "github.com", active: true, state: "success" }, + ], + }, + })); + } + if (cmd[1] === "auth" && cmd[2] === "token") { + tokenCalls.push(cmd.join(" ")); + return fakeProc("secret-token\n"); + } + throw new Error(`Unexpected command: ${cmd.join(" ")}`); + }); + + const { getGitHubAuthEnv } = await import("../github"); + const selection = { githubAuthHost: null, githubAuthLogin: null }; + await getGitHubAuthEnv(selection); + await getGitHubAuthEnv(selection); + expect(tokenCalls).toHaveLength(1); + }); + + it("expires the auth status cache after the TTL", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + try { + whichMock.mockResolvedValue("/opt/homebrew/bin/gh"); + spawnMock.mockImplementation(() => fakeProc(JSON.stringify({ + hosts: { + "github.com": [ + { login: "h0x91b", host: "github.com", active: true, state: "success" }, + ], + }, + }))); + + const { getGitHubCliStatus } = await import("../github"); + await getGitHubCliStatus(); + expect(spawnMock).toHaveBeenCalledTimes(1); + + vi.setSystemTime(Date.now() + 2 * 60_000); + await getGitHubCliStatus(); + expect(spawnMock).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); + it("builds shell exports for the resolved account", async () => { whichMock.mockResolvedValue("/opt/homebrew/bin/gh"); spawnMock.mockReturnValue(fakeProc(JSON.stringify({ diff --git a/src/bun/data.ts b/src/bun/data.ts index 01ffa8b1..44d6f4e6 100644 --- a/src/bun/data.ts +++ b/src/bun/data.ts @@ -1,4 +1,4 @@ -import { mkdir, readFile, readdir, unlink, writeFile } from "node:fs/promises"; +import { mkdir, readFile, readdir, stat, unlink, writeFile } from "node:fs/promises"; import type { Project, Task, TaskStatus, TipState } from "../shared/types"; import { titleFromDescription } from "../shared/types"; import { createLogger } from "./logger"; @@ -52,6 +52,48 @@ async function ensureDir(filePath: string): Promise { await mkdir(dir, { recursive: true }); } +// ---- Read cache (mtime/size keyed) ---- +// +// Background pollers re-read projects.json/tasks.json multiple times per second. +// Caching the parsed result and validating it with a cheap stat() avoids re-reading +// and re-parsing megabytes of JSON when the file hasn't changed. stat() is taken +// BEFORE readFile so a concurrent write can only over-invalidate, never serve stale. +// Cache hits return shallow copies; mutators bypass the cache and saves invalidate it, +// so callers of the public load APIs must treat results as read-only snapshots. + +interface FileCacheEntry { + mtimeMs: number; + size: number; + value: T[]; +} + +const projectsCache = new Map>(); +const tasksCache = new Map>(); + +async function cacheLookup( + cache: Map>, + file: string, +): Promise<{ hit: T[] | null; stat: { mtimeMs: number; size: number } | null }> { + let fileStat: { mtimeMs: number; size: number } | null = null; + try { + const st = await stat(file); + fileStat = { mtimeMs: st.mtimeMs, size: st.size }; + } catch { + return { hit: null, stat: null }; + } + const entry = cache.get(file); + if (entry && entry.mtimeMs === fileStat.mtimeMs && entry.size === fileStat.size) { + return { hit: entry.value.map((item) => ({ ...item })), stat: fileStat }; + } + return { hit: null, stat: fileStat }; +} + +/** Test-only: clear in-memory read caches. */ +export function _resetDataCaches(): void { + projectsCache.clear(); + tasksCache.clear(); +} + // ---- Projects (raw internal helpers — no locking) ---- function toDataFileReadError( @@ -69,6 +111,14 @@ function toDataFileReadError( } async function rawLoadAllProjects(options?: { strict?: boolean; persistMigrations?: boolean }): Promise { + // Mutators (strict/persistMigrations) always read fresh from disk. + const useCache = !options?.strict && !options?.persistMigrations; + let preReadStat: { mtimeMs: number; size: number } | null = null; + if (useCache) { + const { hit, stat: st } = await cacheLookup(projectsCache, PROJECTS_FILE); + if (hit) return hit; + preReadStat = st; + } log.debug("Loading all projects", { file: PROJECTS_FILE }); try { const projects = JSON.parse(await readFile(PROJECTS_FILE, "utf8")) as Project[]; @@ -107,6 +157,9 @@ async function rawLoadAllProjects(options?: { strict?: boolean; persistMigration await rawSaveProjects(projects); } log.info(`Loaded ${projects.length} project(s) (including deleted)`); + if (useCache && preReadStat) { + projectsCache.set(PROJECTS_FILE, { ...preReadStat, value: projects.map((p) => ({ ...p })) }); + } return projects; } catch (err: any) { if (err.code === "ENOENT") { @@ -125,6 +178,7 @@ async function rawSaveProjects(projects: Project[]): Promise { log.debug("Saving projects", { count: projects.length, file: PROJECTS_FILE }); await ensureDir(PROJECTS_FILE); await writeFile(PROJECTS_FILE, JSON.stringify(projects, null, 2)); + projectsCache.delete(PROJECTS_FILE); log.info(`Saved ${projects.length} project(s)`); } @@ -287,6 +341,14 @@ function nextSeq(tasks: Task[]): number { async function rawLoadTasks(project: Project, options?: { strict?: boolean; persistMigrations?: boolean }): Promise { const file = tasksFile(project); + // Mutators (strict/persistMigrations) always read fresh from disk. + const useCache = !options?.strict && !options?.persistMigrations; + let preReadStat: { mtimeMs: number; size: number } | null = null; + if (useCache) { + const { hit, stat: st } = await cacheLookup(tasksCache, file); + if (hit) return hit; + preReadStat = st; + } log.debug("Loading tasks", { projectId: project.id, file }); try { const tasks = JSON.parse(await readFile(file, "utf8")) as Task[]; @@ -337,6 +399,9 @@ async function rawLoadTasks(project: Project, options?: { strict?: boolean; pers } log.info(`Loaded ${tasks.length} task(s)`, { projectId: project.id }); + if (useCache && preReadStat) { + tasksCache.set(file, { ...preReadStat, value: tasks.map((t) => ({ ...t })) }); + } return tasks; } catch (err: any) { if (err.code === "ENOENT") { @@ -362,6 +427,7 @@ async function rawSaveTasks( log.warn("Failed to write hourly tasks backup (non-fatal)", { projectId: project.id, err }); }); await writeFile(file, JSON.stringify(tasks, null, 2)); + tasksCache.delete(file); log.info(`Saved ${tasks.length} task(s)`, { projectId: project.id }); } diff --git a/src/bun/git.ts b/src/bun/git.ts index 8b6fae83..99a9082f 100644 --- a/src/bun/git.ts +++ b/src/bun/git.ts @@ -872,9 +872,36 @@ function parseRecentCommitters(shortlogOutput: string): Set { return emails; } +// detectDefaultCompareRef runs `git shortlog` over two weeks of history — expensive +// on large repos. It is invoked by resolveProjectConfig, which the renderer polls +// every few seconds, so the result is cached with a TTL. The in-flight promise is +// cached too, coalescing concurrent callers. +const compareRefCache = new Map }>(); +const COMPARE_REF_CACHE_TTL_MS = 10 * 60_000; + +/** Test-only: clear the detectDefaultCompareRef cache. */ +export function _resetCompareRefCache(): void { + compareRefCache.clear(); +} + export async function detectDefaultCompareRef( projectPath: string, baseBranch: string, +): Promise { + const key = `${projectPath}\0${baseBranch}`; + const cached = compareRefCache.get(key); + if (cached && Date.now() - cached.at < COMPARE_REF_CACHE_TTL_MS) { + return cached.promise; + } + const promise = detectDefaultCompareRefUncached(projectPath, baseBranch); + compareRefCache.set(key, { at: Date.now(), promise }); + promise.catch(() => compareRefCache.delete(key)); + return promise; +} + +async function detectDefaultCompareRefUncached( + projectPath: string, + baseBranch: string, ): Promise { const remoteResult = await run(["git", "remote"], projectPath); const hasOriginRemote = remoteResult.ok && remoteResult.stdout @@ -955,6 +982,23 @@ const fetchLastSuccess = new Map(); const fetchProjectQueue = new Map>(); const FETCH_COOLDOWN_MS = 5_000; +// Failed fetches (dead remote, no network, auth issues) get an exponential +// backoff so background pollers don't retry them on every tick. Without this, +// a repo with an unreachable origin was re-fetched every poller cycle forever. +const fetchLastFailure = new Map(); +const FETCH_FAILURE_BACKOFF_BASE_MS = 2 * 60_000; +const FETCH_FAILURE_BACKOFF_MAX_MS = 30 * 60_000; + +function fetchFailureBackoffMs(failures: number): number { + return Math.min(FETCH_FAILURE_BACKOFF_BASE_MS * 2 ** (failures - 1), FETCH_FAILURE_BACKOFF_MAX_MS); +} + +function isInFailureBackoff(cacheKey: string, now: number): boolean { + const failure = fetchLastFailure.get(cacheKey); + if (!failure) return false; + return now - failure.at < fetchFailureBackoffMs(failure.failures); +} + export async function fetchOrigin(projectPath: string, branch?: string): Promise { await reportCurrentPreparationStage("fetching-origin"); const now = Date.now(); @@ -968,6 +1012,16 @@ export async function fetchOrigin(projectPath: string, branch?: string): Promise return true; } + // Skip if recent fetches for this key keep failing (exponential backoff) + if (isInFailureBackoff(cacheKey, now)) { + log.debug("fetchOrigin: skipping (failure backoff)", { + projectPath, + branch, + failures: fetchLastFailure.get(cacheKey)?.failures, + }); + return false; + } + // Reuse in-flight fetch for the same project+branch const existing = fetchInFlight.get(cacheKey); if (existing) { @@ -986,6 +1040,10 @@ export async function fetchOrigin(projectPath: string, branch?: string): Promise log.debug("fetchOrigin: skipping (cooldown after queue wait)", { projectPath, branch }); return true; } + if (isInFailureBackoff(cacheKey, Date.now())) { + log.debug("fetchOrigin: skipping (failure backoff after queue wait)", { projectPath, branch }); + return false; + } const startedAt = performance.now(); const cmd = branch @@ -995,16 +1053,21 @@ export async function fetchOrigin(projectPath: string, branch?: string): Promise const result = await run(cmd, projectPath); if (result.ok) { fetchLastSuccess.set(cacheKey, Date.now()); + fetchLastFailure.delete(cacheKey); log.info("fetchOrigin finished", { projectPath, branch, durationMs: Math.round(performance.now() - startedAt), }); } else { + const failures = (fetchLastFailure.get(cacheKey)?.failures ?? 0) + 1; + fetchLastFailure.set(cacheKey, { at: Date.now(), failures }); log.warn("fetchOrigin failed", { projectPath, branch, stderr: result.stderr, + failures, + nextRetryInMs: fetchFailureBackoffMs(failures), durationMs: Math.round(performance.now() - startedAt), }); } @@ -1124,6 +1187,9 @@ export function removeFetchCache(projectPath: string): void { for (const key of fetchLastSuccess.keys()) { if (key.startsWith(projectPath + ":")) fetchLastSuccess.delete(key); } + for (const key of fetchLastFailure.keys()) { + if (key.startsWith(projectPath + ":")) fetchLastFailure.delete(key); + } fetchProjectQueue.delete(projectPath); } @@ -1131,6 +1197,7 @@ export function removeFetchCache(projectPath: string): void { export function _resetFetchState(): void { fetchInFlight.clear(); fetchLastSuccess.clear(); + fetchLastFailure.clear(); fetchProjectQueue.clear(); } diff --git a/src/bun/github.ts b/src/bun/github.ts index a78597e2..fb151659 100644 --- a/src/bun/github.ts +++ b/src/bun/github.ts @@ -125,7 +125,43 @@ function parseAccounts(payload: GhAuthStatusResponse): GitHubAccount[] { }); } +// gh auth status/token are resolved by spawning the gh binary — three subprocesses +// per GitHub operation without caching. Background pollers (merge/PR detection) hit +// this constantly, so both are cached with a TTL. Only "authenticated" status is +// cached: after `gh auth login` the user sees the new state immediately. +const AUTH_STATUS_CACHE_TTL_MS = 60_000; +const TOKEN_CACHE_TTL_MS = 5 * 60_000; + +let authStatusCache: { at: number; promise: Promise } | null = null; +const tokenCache = new Map }>(); + +/** Test-only: clear gh auth caches. */ +export function _resetGitHubAuthCache(): void { + authStatusCache = null; + tokenCache.clear(); +} + export async function getGitHubCliStatus(): Promise { + if (authStatusCache && Date.now() - authStatusCache.at < AUTH_STATUS_CACHE_TTL_MS) { + return authStatusCache.promise; + } + const promise = getGitHubCliStatusUncached(); + const entry = { at: Date.now(), promise }; + authStatusCache = entry; + promise.then( + (status) => { + if (status.authStatus !== "authenticated" && authStatusCache === entry) { + authStatusCache = null; + } + }, + () => { + if (authStatusCache === entry) authStatusCache = null; + }, + ); + return promise; +} + +async function getGitHubCliStatusUncached(): Promise { const binaryPath = await which("gh"); if (!binaryPath) { return { @@ -219,11 +255,21 @@ export async function resolveGitHubAccount(project: ProjectGitHubSelection): Pro } async function getAccountToken(account: GitHubAccount): Promise { - const result = await runGh(["auth", "token", "--hostname", account.host, "--user", account.login]); - if (!result.ok || !result.stdout) { - throw new Error(result.stderr || `Failed to resolve GitHub token for ${account.login}@${account.host}`); + const key = `${account.host}\0${account.login}`; + const cached = tokenCache.get(key); + if (cached && Date.now() - cached.at < TOKEN_CACHE_TTL_MS) { + return cached.promise; } - return result.stdout; + const promise = (async () => { + const result = await runGh(["auth", "token", "--hostname", account.host, "--user", account.login]); + if (!result.ok || !result.stdout) { + throw new Error(result.stderr || `Failed to resolve GitHub token for ${account.login}@${account.host}`); + } + return result.stdout; + })(); + tokenCache.set(key, { at: Date.now(), promise }); + promise.catch(() => tokenCache.delete(key)); + return promise; } export async function getGitHubAuthEnv(project: ProjectGitHubSelection): Promise> {