perf(server): cache deterministic git rev-parse reads in fs exec route (#1399)

* perf(server): cache deterministic git rev-parse reads in fs exec route

A fresh client (e.g. immediately after a page reload) has an empty git
store and re-resolves every project's root from scratch, firing identical
`git rev-parse --absolute-git-dir` / `--git-common-dir` lookups against
`/api/fs/exec`. Each spawns a git subprocess server-side, so re-opening a
workspace recomputes everything.

Add a small TTL cache for an allowlist of deterministic, side-effect-free
git plumbing path queries, keyed by `(resolvedCwd, command)`:
- Only `git rev-parse` path lookups (absolute-git-dir, git-common-dir,
  show-toplevel) are cacheable; any other command — including any non-git
  command — always executes and is never stored.
- Only successful results are cached (failures may be transient).
- TTL is configurable via OPENCHAMBER_GIT_READ_CACHE_TTL_MS (default 30s,
  0 disables). The git directory layout is effectively static while the
  app runs, so a short TTL safely absorbs the post-reload burst.
- Expired entries are pruned alongside exec jobs.

Complements the client-side root-resolution cache: that one collapses the
in-session N² cascade, this one absorbs the cold-start burst on reload.

Adds tests covering cache hit, per-cwd keying, non-allowlisted commands,
failed-result bypass and the disable switch.

* fix(server): bound git-read cache with count + byte limits; test TTL expiry

Per the project caching policy (AGENTS.md: cap in-memory caches with both
count and byte limits), the git-read cache was unbounded between prunes.

Add dual-constraint LRU eviction (500 entries / 1MB, oldest-first) with
recency refresh on cache hits. Add tests for TTL expiry (fake timers) and
count-cap eviction.

* fix(server): dedupe in-flight git read cache hits
This commit is contained in:
Bohdan Triapitsyn
2026-05-24 13:21:09 +03:00
committed by GitHub
parent f94d87b5fd
commit 0776aca9c4
2 changed files with 374 additions and 4 deletions
+123 -4
View File
@@ -6,6 +6,36 @@ const createCommandTimeoutMs = () => {
return 5 * 60 * 1000;
};
// How long a cached git-read result stays fresh. The location of a repo's git
// directory is effectively static while the app runs, so a short TTL safely
// absorbs the burst of identical lookups a fresh client (e.g. right after a
// page reload) fires for every project. Set to 0 to disable caching.
const createGitReadCacheTtlMs = () => {
const raw = Number(process.env.OPENCHAMBER_GIT_READ_CACHE_TTL_MS);
if (Number.isFinite(raw) && raw >= 0) return raw;
return 30 * 1000;
};
// Only deterministic, side-effect-free git plumbing path queries are cacheable.
// Anything outside this allowlist (including any non-git command) runs normally
// — we never cache arbitrary exec.
const normalizeCommand = (command) =>
typeof command === 'string' ? command.trim().replace(/\s+/g, ' ') : '';
const isCacheableGitReadCommand = (command) => {
const normalized = normalizeCommand(command);
return /^git rev-parse(?: --(?:absolute-git-dir|git-common-dir|show-toplevel)){1,3}$/.test(normalized);
};
// Dual-constraint bound per the project's caching policy (count + bytes). Git
// rev-parse outputs are tiny, so these ceilings are generous and only guard
// against pathological growth on long-lived, many-directory deployments.
const GIT_READ_CACHE_MAX_ENTRIES = 500;
const GIT_READ_CACHE_MAX_BYTES = 1024 * 1024;
const gitReadEntryBytes = (key, result) =>
key.length + (result?.stdout?.length || 0) + (result?.stderr?.length || 0);
const isPathWithinRoot = (resolvedPath, rootPath, path, os) => {
const resolvedRoot = path.resolve(rootPath || os.homedir());
const relative = path.relative(resolvedRoot, resolvedPath);
@@ -243,6 +273,9 @@ export const registerFsRoutes = (app, dependencies) => {
const execJobs = new Map();
const commandTimeoutMs = createCommandTimeoutMs();
const gitReadCacheTtlMs = createGitReadCacheTtlMs();
const gitReadCache = new Map();
const inFlightGitReadCache = new Map();
const pruneExecJobs = () => {
const now = Date.now();
@@ -258,6 +291,94 @@ export const registerFsRoutes = (app, dependencies) => {
}
};
const pruneGitReadCache = () => {
if (gitReadCacheTtlMs <= 0) {
return;
}
const now = Date.now();
for (const [key, entry] of gitReadCache.entries()) {
if (!entry || now - entry.at > gitReadCacheTtlMs) {
gitReadCache.delete(key);
}
}
};
// Insert with LRU (oldest-first) eviction enforcing both count and byte caps.
// Map iteration order is insertion order, so deleting+re-setting a key moves
// it to the most-recently-used position.
const setGitReadCacheEntry = (key, result) => {
gitReadCache.delete(key);
gitReadCache.set(key, { result, at: Date.now() });
let totalBytes = 0;
for (const [k, entry] of gitReadCache) {
totalBytes += gitReadEntryBytes(k, entry.result);
}
while (
gitReadCache.size > GIT_READ_CACHE_MAX_ENTRIES ||
(totalBytes > GIT_READ_CACHE_MAX_BYTES && gitReadCache.size > 1)
) {
const oldest = gitReadCache.entries().next().value;
if (!oldest) {
break;
}
totalBytes -= gitReadEntryBytes(oldest[0], oldest[1].result);
gitReadCache.delete(oldest[0]);
}
};
// Runs a command, transparently serving/storing cacheable git-read results.
// Non-cacheable commands always execute and are never stored.
const runCommandWithGitReadCache = async ({ shell, shellFlag, command, resolvedCwd }) => {
const cacheable = gitReadCacheTtlMs > 0 && isCacheableGitReadCommand(command);
const cacheKey = cacheable ? `${resolvedCwd}${normalizeCommand(command)}` : null;
if (cacheKey) {
const cached = gitReadCache.get(cacheKey);
if (cached && Date.now() - cached.at < gitReadCacheTtlMs) {
// Refresh recency for LRU without altering the entry's age/TTL.
gitReadCache.delete(cacheKey);
gitReadCache.set(cacheKey, cached);
return { ...cached.result, command };
}
if (cached) {
gitReadCache.delete(cacheKey);
}
const inFlight = inFlightGitReadCache.get(cacheKey);
if (inFlight) {
const result = await inFlight;
return { ...result, command };
}
}
const runPromise = runCommandInDirectory({
shell,
shellFlag,
command,
resolvedCwd,
spawn,
buildAugmentedPath,
commandTimeoutMs,
}).then((result) => {
// Only cache successful results — failures may be transient.
if (cacheKey && result && result.success) {
setGitReadCacheEntry(cacheKey, result);
}
return result;
}).finally(() => {
if (cacheKey && inFlightGitReadCache.get(cacheKey) === runPromise) {
inFlightGitReadCache.delete(cacheKey);
}
});
if (cacheKey) {
inFlightGitReadCache.set(cacheKey, runPromise);
}
return runPromise;
};
const runExecJob = async (job) => {
job.status = 'running';
job.updatedAt = Date.now();
@@ -270,14 +391,11 @@ export const registerFsRoutes = (app, dependencies) => {
}
try {
const result = await runCommandInDirectory({
const result = await runCommandWithGitReadCache({
shell: job.shell,
shellFlag: job.shellFlag,
command,
resolvedCwd: job.resolvedCwd,
spawn,
buildAugmentedPath,
commandTimeoutMs,
});
results.push(result);
} catch (error) {
@@ -816,6 +934,7 @@ export const registerFsRoutes = (app, dependencies) => {
}
pruneExecJobs();
pruneGitReadCache();
try {
const resolvedCwd = path.resolve(normalizeDirectoryPath(cwd));
+251
View File
@@ -0,0 +1,251 @@
import { EventEmitter } from 'events';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { registerFsRoutes } from './routes.js';
const createRouteRegistry = () => {
const routes = new Map();
return {
app: {
get(routePath, handler) {
routes.set(`GET ${routePath}`, handler);
},
post(routePath, handler) {
routes.set(`POST ${routePath}`, handler);
},
},
getRoute(method, routePath) {
return routes.get(`${method} ${routePath}`);
},
};
};
const createMockResponse = () => {
let statusCode = 200;
let body = null;
return {
status(code) {
statusCode = code;
return this;
},
json(payload) {
body = payload;
return this;
},
get statusCode() {
return statusCode;
},
get body() {
return body;
},
};
};
// Fake child process: emits the configured stdout then closes with the given code.
const createSpawn = ({ stdoutByCommand = {}, exitCode = 0 } = {}) => {
const calls = [];
const spawn = vi.fn((_shell, args) => {
const command = args[args.length - 1];
calls.push(command);
const child = new EventEmitter();
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
child.kill = () => {};
queueMicrotask(() => {
const out = stdoutByCommand[command];
if (out) child.stdout.emit('data', Buffer.from(out));
child.emit('close', exitCode, null);
});
return child;
});
return { spawn, calls };
};
const createDeferredSpawn = ({ stdoutByCommand = {}, exitCode = 0 } = {}) => {
const calls = [];
const pending = [];
const spawn = vi.fn((_shell, args) => {
const command = args[args.length - 1];
calls.push(command);
const child = new EventEmitter();
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
child.kill = () => {};
pending.push({ child, command });
return child;
});
const closeNext = () => {
const entry = pending.shift();
if (!entry) return;
const out = stdoutByCommand[entry.command];
if (out) entry.child.stdout.emit('data', Buffer.from(out));
entry.child.emit('close', exitCode, null);
};
return { spawn, calls, closeNext };
};
const registerExec = ({ spawn }) => {
const { app, getRoute } = createRouteRegistry();
registerFsRoutes(app, {
os: { homedir: () => '/home/user' },
path,
fsPromises: { stat: async () => ({ isDirectory: () => true }) },
spawn,
crypto: { randomUUID: (() => { let n = 0; return () => `job-${n++}`; })() },
normalizeDirectoryPath: (p) => p,
resolveProjectDirectory: async () => ({ directory: '/repo' }),
buildAugmentedPath: () => '/usr/bin',
resolveGitBinaryForSpawn: () => 'git',
openchamberUserConfigRoot: '/home/user/.config',
});
return getRoute('POST', '/api/fs/exec');
};
const callExec = async (handler, body) => {
const res = createMockResponse();
await handler({ body }, res);
return res;
};
describe('fs exec git-read cache', () => {
beforeEach(() => {
delete process.env.OPENCHAMBER_GIT_READ_CACHE_TTL_MS;
});
afterEach(() => {
delete process.env.OPENCHAMBER_GIT_READ_CACHE_TTL_MS;
});
it('caches an allowlisted git rev-parse across identical requests', async () => {
const command = 'git rev-parse --absolute-git-dir --git-common-dir';
const { spawn, calls } = createSpawn({ stdoutByCommand: { [command]: '/repo/.git\n.git\n' } });
const handler = registerExec({ spawn });
const first = await callExec(handler, { commands: [command], cwd: '/repo' });
const second = await callExec(handler, { commands: [command], cwd: '/repo' });
expect(first.body.results[0].stdout).toBe('/repo/.git\n.git');
expect(second.body.results[0].stdout).toBe('/repo/.git\n.git');
expect(second.body.success).toBe(true);
// Spawned once; the second request is served from cache.
expect(calls.length).toBe(1);
});
it('dedupes concurrent identical git-read requests while the first is in flight', async () => {
const command = 'git rev-parse --absolute-git-dir --git-common-dir';
const { spawn, calls, closeNext } = createDeferredSpawn({ stdoutByCommand: { [command]: '/repo/.git\n.git\n' } });
const handler = registerExec({ spawn });
const first = callExec(handler, { commands: [command], cwd: '/repo' });
const second = callExec(handler, { commands: [command], cwd: '/repo' });
await new Promise((resolve) => setTimeout(resolve, 0));
expect(calls.length).toBe(1);
closeNext();
const [firstRes, secondRes] = await Promise.all([first, second]);
expect(firstRes.body.results[0].stdout).toBe('/repo/.git\n.git');
expect(secondRes.body.results[0].stdout).toBe('/repo/.git\n.git');
expect(calls.length).toBe(1);
});
it('returns the current request command for normalized cache hits', async () => {
const firstCommand = 'git rev-parse --absolute-git-dir';
const secondCommand = 'git rev-parse --absolute-git-dir';
const { spawn, calls } = createSpawn({ stdoutByCommand: { [firstCommand]: '/repo/.git\n' } });
const handler = registerExec({ spawn });
const first = await callExec(handler, { commands: [firstCommand], cwd: '/repo' });
const second = await callExec(handler, { commands: [secondCommand], cwd: '/repo' });
expect(first.body.results[0].command).toBe(firstCommand);
expect(second.body.results[0].command).toBe(secondCommand);
expect(calls.length).toBe(1);
});
it('keys the cache by working directory', async () => {
const command = 'git rev-parse --absolute-git-dir';
const { spawn, calls } = createSpawn({ stdoutByCommand: { [command]: '/x/.git\n' } });
const handler = registerExec({ spawn });
await callExec(handler, { commands: [command], cwd: '/repo-a' });
await callExec(handler, { commands: [command], cwd: '/repo-b' });
expect(calls.length).toBe(2);
});
it('never caches non-allowlisted commands', async () => {
const command = 'git status';
const { spawn, calls } = createSpawn({ stdoutByCommand: { [command]: 'clean\n' } });
const handler = registerExec({ spawn });
await callExec(handler, { commands: [command], cwd: '/repo' });
await callExec(handler, { commands: [command], cwd: '/repo' });
expect(calls.length).toBe(2);
});
it('does not cache failed git-read results', async () => {
const command = 'git rev-parse --absolute-git-dir';
const { spawn, calls } = createSpawn({ stdoutByCommand: {}, exitCode: 128 });
const handler = registerExec({ spawn });
await callExec(handler, { commands: [command], cwd: '/not-a-repo' });
await callExec(handler, { commands: [command], cwd: '/not-a-repo' });
expect(calls.length).toBe(2);
});
it('disables caching when TTL is 0', async () => {
process.env.OPENCHAMBER_GIT_READ_CACHE_TTL_MS = '0';
const command = 'git rev-parse --absolute-git-dir';
const { spawn, calls } = createSpawn({ stdoutByCommand: { [command]: '/repo/.git\n' } });
const handler = registerExec({ spawn });
await callExec(handler, { commands: [command], cwd: '/repo' });
await callExec(handler, { commands: [command], cwd: '/repo' });
expect(calls.length).toBe(2);
});
it('re-runs once a cached entry ages past the TTL', async () => {
vi.useFakeTimers();
try {
const command = 'git rev-parse --absolute-git-dir';
const { spawn, calls } = createSpawn({ stdoutByCommand: { [command]: '/repo/.git\n' } });
const handler = registerExec({ spawn }); // default 30s TTL
await callExec(handler, { commands: [command], cwd: '/repo' });
vi.advanceTimersByTime(31_000);
await callExec(handler, { commands: [command], cwd: '/repo' });
// Stale entry is not served; a fresh subprocess fires.
expect(calls.length).toBe(2);
} finally {
vi.useRealTimers();
}
});
it('bounds the cache by evicting the least-recently-used entry past the count cap', async () => {
const command = 'git rev-parse --absolute-git-dir';
const { spawn, calls } = createSpawn(); // exit 0, empty stdout — still cacheable
const handler = registerExec({ spawn });
// Fill to the 500-entry ceiling with distinct working directories.
for (let i = 0; i < 500; i += 1) {
await callExec(handler, { commands: [command], cwd: `/repo-${i}` });
}
const afterFill = calls.length;
expect(afterFill).toBe(500);
// One more distinct dir evicts the oldest entry (/repo-0).
await callExec(handler, { commands: [command], cwd: '/repo-overflow' });
// Evicted entry must re-run; a surviving entry must still be served.
await callExec(handler, { commands: [command], cwd: '/repo-0' }); // evicted -> spawns
await callExec(handler, { commands: [command], cwd: '/repo-499' }); // cached -> no spawn
expect(calls.length).toBe(afterFill + 2);
});
});