Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions change-logs/2026/06/11/fix-background-polling-cpu-churn.md
Original file line number Diff line number Diff line change
@@ -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.
26 changes: 26 additions & 0 deletions decisions/067-background-polling-read-caches.md
Original file line number Diff line number Diff line change
@@ -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.
189 changes: 189 additions & 0 deletions src/bun/__tests__/data-cache.test.ts
Original file line number Diff line number Diff line change
@@ -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 <T>(_filePath: string, fn: () => Promise<T>): Promise<T> => 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> = {}): Project {
return {
id: "p1",
path: PROJECT_PATH,
name: "cache-project",
defaultBaseBranch: "main",
labels: [],
customColumns: [],
...overrides,
} as Project;
}

function makeTask(overrides: Partial<Task> = {}): 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");
});
});
60 changes: 60 additions & 0 deletions src/bun/__tests__/git-branch-ops.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" };
Expand Down Expand Up @@ -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 <h0x91b@gmail.com>\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 <h0x91b@gmail.com>\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 <a@x.com>\n 3 b <b@x.com>\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 <h0x91b@gmail.com>\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 <a@x.com>\n 3 b <b@x.com>\n");
expect(await detectDefaultCompareRef("/repo", "main")).toBe("origin/main");
expect(spawnResponses).toHaveLength(0);
} finally {
vi.useRealTimers();
}
});
});

// ─── getOriginUrl ────────────────────────────────────────────────────────────
Expand Down
73 changes: 73 additions & 0 deletions src/bun/__tests__/git-fetch-snapshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading