perf(anthropic): memoize the local Claude session scan (#114833)

* perf(anthropic): memoize the local Claude session scan

* fix(anthropic): make the scan memo freshness gate race-safe
This commit is contained in:
Peter Steinberger
2026-07-27 21:13:12 -04:00
committed by GitHub
parent 1d67a55681
commit 92fff941ef
2 changed files with 309 additions and 27 deletions
+177 -2
View File
@@ -1579,7 +1579,7 @@ describe("Claude session catalog", () => {
}
});
it("reuses cached metadata for unchanged discovered transcripts", async () => {
it("reuses the assembled scan within the TTL without per-file filesystem work", async () => {
const home = await createHome();
const sessionIds = ["cached-session-a", "cached-session-b"];
await writeProject({
@@ -1589,18 +1589,32 @@ describe("Claude session catalog", () => {
sessionIds.map((sessionId) => [sessionId, [sdkCliMessage(sessionId, sessionId)]]),
),
});
const realpathSpy = vi.spyOn(fs, "realpath");
const statSpy = vi.spyOn(fs, "stat");
const openSpy = vi.spyOn(fs, "open");
const readFileSpy = vi.spyOn(fs, "readFile");
const first = await listLocalClaudeSessionPage({}, home);
expect(openSpy).toHaveBeenCalledTimes(2);
realpathSpy.mockClear();
statSpy.mockClear();
openSpy.mockClear();
readFileSpy.mockClear();
const second = await listLocalClaudeSessionPage({}, home);
expect(second).toEqual(first);
const isCatalogFile = (value: unknown) =>
typeof value === "string" &&
(value.endsWith(".jsonl") ||
value.endsWith("sessions-index.json") ||
path.basename(value).startsWith("local_"));
expect(realpathSpy.mock.calls.filter(([filePath]) => isCatalogFile(filePath))).toEqual([]);
expect(statSpy.mock.calls.filter(([filePath]) => isCatalogFile(filePath))).toEqual([]);
expect(openSpy).not.toHaveBeenCalled();
expect(readFileSpy.mock.calls.filter(([filePath]) => isCatalogFile(filePath))).toEqual([]);
});
it("rescans only a changed transcript and refreshes a negative result", async () => {
it("invalidates the assembled scan on a project directory mtime change", async () => {
const home = await createHome();
const projectDir = path.join(home, ".claude", "projects", "-workspace");
const changedPath = path.join(projectDir, "changed-session.jsonl");
@@ -1621,6 +1635,7 @@ describe("Claude session catalog", () => {
);
const changedTime = new Date(Date.now() + 2_000);
await fs.utimes(changedPath, changedTime, changedTime);
await fs.utimes(projectDir, changedTime, changedTime);
const resolvedChangedPath = await fs.realpath(changedPath);
const resolvedUnchangedPath = await fs.realpath(unchangedPath);
openSpy.mockClear();
@@ -1640,14 +1655,17 @@ describe("Claude session catalog", () => {
const home = await createHome();
const projectDir = path.join(home, ".claude", "projects", "-workspace");
const newPath = path.join(projectDir, "new-session.jsonl");
const fixedDirectoryTime = new Date("2026-07-20T12:00:00.000Z");
await writeProject({
home,
entries: [],
transcripts: { "existing-session": [sdkCliMessage("existing-session", "Existing")] },
});
await fs.utimes(projectDir, fixedDirectoryTime, fixedDirectoryTime);
const openSpy = vi.spyOn(fs, "open");
await listLocalClaudeSessionPage({}, home);
await fs.writeFile(newPath, `${JSON.stringify(sdkCliMessage("new-session", "New"))}\n`);
await fs.utimes(projectDir, fixedDirectoryTime, fixedDirectoryTime);
const resolvedNewPath = await fs.realpath(newPath);
openSpy.mockClear();
@@ -1659,6 +1677,160 @@ describe("Claude session catalog", () => {
expect(openSpy.mock.calls.map(([filePath]) => filePath)).toEqual([resolvedNewPath]);
});
it("refreshes a warm catalog when a specific new transcript is requested", async () => {
const home = await createHome();
const projectDir = path.join(home, ".claude", "projects", "-workspace");
const sessionId = "just-created-session";
const fixedDirectoryTime = new Date("2026-07-20T12:00:00.000Z");
await writeProject({
home,
entries: [],
transcripts: { "existing-session": [sdkCliMessage("existing-session", "Existing")] },
});
await fs.utimes(projectDir, fixedDirectoryTime, fixedDirectoryTime);
await listLocalClaudeSessionPage({}, home);
await fs.writeFile(
path.join(projectDir, `${sessionId}.jsonl`),
`${JSON.stringify(sdkCliMessage(sessionId, "New transcript"))}\n`,
);
// Keep the directory fingerprint unchanged so this exercises the per-id miss refresh rather
// than the normal catalog invalidation path.
await fs.utimes(projectDir, fixedDirectoryTime, fixedDirectoryTime);
await expect(
readLocalClaudeTranscriptPage({ threadId: sessionId, limit: 1 }, home),
).resolves.toEqual(
expect.objectContaining({
items: [expect.objectContaining({ text: "New transcript" })],
}),
);
});
it("keys index and Desktop metadata parse caches by path, mtime, and size", async () => {
const home = await createHome();
const projectDir = path.join(home, ".claude", "projects", "-workspace");
const indexPath = path.join(projectDir, "sessions-index.json");
const desktopPath = path.join(
home,
"Library",
"Application Support",
"Claude",
"claude-code-sessions",
"account",
"workspace",
"local_metadata-cache.json",
);
const indexedPath = path.join(projectDir, "indexed-session.jsonl");
const desktopTranscriptPath = path.join(projectDir, "desktop-session.jsonl");
const entries = [
{
sessionId: "indexed-session",
fullPath: indexedPath,
summary: "Indexed before",
isSidechain: false,
},
{
sessionId: "desktop-session",
fullPath: desktopTranscriptPath,
summary: "Desktop index",
isSidechain: false,
},
];
await writeProject({
home,
entries,
transcripts: {
"indexed-session": [message("indexed-session", "user", "Indexed", 1)],
"desktop-session": [message("desktop-session", "user", "Desktop", 1)],
},
});
await writeDesktopMetadata(home, "metadata-cache", {
cliSessionId: "desktop-session",
title: "Desktop before",
});
const readFileSpy = vi.spyOn(fs, "readFile");
const metadataReads = () =>
readFileSpy.mock.calls
.map(([filePath]) => filePath)
.filter((filePath) => filePath === indexPath || filePath === desktopPath);
await listLocalClaudeSessionPage({}, home);
expect(metadataReads()).toEqual(expect.arrayContaining([indexPath, desktopPath]));
const firstRefreshTime = new Date(Date.now() + 2_000);
await fs.utimes(projectDir, firstRefreshTime, firstRefreshTime);
readFileSpy.mockClear();
await listLocalClaudeSessionPage({}, home);
expect(metadataReads()).toEqual([]);
await fs.writeFile(
indexPath,
JSON.stringify({
version: 1,
entries: [{ ...entries[0], summary: "Indexed after a longer title" }, entries[1]],
}),
);
await fs.writeFile(
desktopPath,
JSON.stringify({
cliSessionId: "desktop-session",
title: "Desktop after a longer title",
}),
);
const secondRefreshTime = new Date(Date.now() + 4_000);
await Promise.all([
fs.utimes(indexPath, secondRefreshTime, secondRefreshTime),
fs.utimes(desktopPath, secondRefreshTime, secondRefreshTime),
fs.utimes(projectDir, secondRefreshTime, secondRefreshTime),
]);
readFileSpy.mockClear();
const refreshed = await listLocalClaudeSessionPage({}, home);
expect(metadataReads()).toEqual(expect.arrayContaining([indexPath, desktopPath]));
expect(
Object.fromEntries(refreshed.sessions.map((record) => [record.threadId, record.name])),
).toEqual({
"desktop-session": "Desktop after a longer title",
"indexed-session": "Indexed after a longer title",
});
});
it("retries transient index reads without waiting for the file metadata to change", async () => {
const home = await createHome();
const projectDir = path.join(home, ".claude", "projects", "-workspace");
const indexPath = path.join(projectDir, "sessions-index.json");
const sessionId = "retry-index-session";
await writeProject({
home,
entries: [
{
sessionId,
fullPath: path.join(projectDir, `${sessionId}.jsonl`),
summary: "Recovered index",
isSidechain: false,
},
],
transcripts: { [sessionId]: [message(sessionId, "user", "Indexed only", 1)] },
});
const readFile = fs.readFile.bind(fs);
let failIndexRead = true;
vi.spyOn(fs, "readFile").mockImplementation(async (...args) => {
if (failIndexRead && args[0] === indexPath) {
failIndexRead = false;
throw new Error("transient index read failure");
}
return await readFile(...args);
});
expect((await listLocalClaudeSessionPage({}, home)).sessions).toEqual([]);
const refreshTime = new Date(Date.now() + 2_000);
await fs.utimes(projectDir, refreshTime, refreshTime);
await expect(listLocalClaudeSessionPage({}, home)).resolves.toMatchObject({
sessions: [{ threadId: sessionId, name: "Recovered index" }],
});
});
it("evicts a deleted transcript after a complete scan", async () => {
const home = await createHome();
const projectDir = path.join(home, ".claude", "projects", "-workspace");
@@ -1671,13 +1843,16 @@ describe("Claude session catalog", () => {
transcripts: { [sessionId]: [sdkCliMessage(sessionId, "Alpha")] },
});
await fs.utimes(transcriptPath, fixedTime, fixedTime);
await fs.utimes(projectDir, fixedTime, fixedTime);
const originalStat = await fs.stat(transcriptPath);
await listLocalClaudeSessionPage({}, home);
await fs.rm(transcriptPath);
await fs.utimes(projectDir, fixedTime, fixedTime);
expect((await listLocalClaudeSessionPage({}, home)).sessions).toEqual([]);
await fs.writeFile(transcriptPath, `${JSON.stringify(sdkCliMessage(sessionId, "Bravo"))}\n`);
await fs.utimes(transcriptPath, fixedTime, fixedTime);
await fs.utimes(projectDir, fixedTime, fixedTime);
const recreatedStat = await fs.stat(transcriptPath);
expect({ mtimeMs: recreatedStat.mtimeMs, size: recreatedStat.size }).toEqual({
mtimeMs: originalStat.mtimeMs,
+132 -25
View File
@@ -1,3 +1,4 @@
import type { Dirent, Stats } from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
@@ -62,6 +63,9 @@ const MAX_SEARCH_LENGTH = 500;
const MAX_SESSION_PULL_REQUESTS = 20;
const MAX_CATALOG_DISCOVERY_FILES = 10_000;
const MAX_CATALOG_DISCOVERY_CACHE_ENTRIES = 20_000;
const MAX_CATALOG_JSON_CACHE_ENTRIES = 4_000;
const MAX_CLAUDE_SESSION_SCAN_CACHE_ENTRIES = 8;
const CLAUDE_SESSION_SCAN_TTL_MS = 5_000;
const CLAUDE_METADATA_PREFIX_BYTES = 1024 * 1024;
const CLAUDE_METADATA_READ_CHUNK_BYTES = 16 * 1024;
const MAX_CATALOG_METADATA_SCAN_BYTES = 64 * 1024 * 1024;
@@ -137,20 +141,38 @@ type CatalogDiscoveryCacheEntry = {
sidechain: boolean;
};
const catalogDiscoveryCache = new Map<string, CatalogDiscoveryCacheEntry>();
type CatalogJsonCacheEntry = {
mtimeMs: number;
size: number;
value: unknown;
};
function cacheCatalogDiscovery(filePath: string, entry: CatalogDiscoveryCacheEntry): void {
catalogDiscoveryCache.delete(filePath);
catalogDiscoveryCache.set(filePath, entry);
while (catalogDiscoveryCache.size > MAX_CATALOG_DISCOVERY_CACHE_ENTRIES) {
const oldestPath = catalogDiscoveryCache.keys().next().value;
if (oldestPath === undefined) {
type ClaudeSessionScanCacheEntry = {
treeStamp: string;
expiresAt: number;
records: Promise<CatalogRecord[]>;
};
const catalogDiscoveryCache = new Map<string, CatalogDiscoveryCacheEntry>();
const catalogJsonCache = new Map<string, CatalogJsonCacheEntry>();
const claudeSessionScanCache = new Map<string, ClaudeSessionScanCacheEntry>();
function setBoundedCache<K, V>(cache: Map<K, V>, key: K, value: V, maxEntries: number): void {
cache.delete(key);
cache.set(key, value);
while (cache.size > maxEntries) {
const oldest = cache.keys().next();
if (oldest.done) {
break;
}
catalogDiscoveryCache.delete(oldestPath);
cache.delete(oldest.value);
}
}
function cacheCatalogDiscovery(filePath: string, entry: CatalogDiscoveryCacheEntry): void {
setBoundedCache(catalogDiscoveryCache, filePath, entry, MAX_CATALOG_DISCOVERY_CACHE_ENTRIES);
}
function optionalString(value: unknown, maxLength = MAX_STRING_LENGTH): string | undefined {
if (typeof value !== "string") {
return undefined;
@@ -273,7 +295,7 @@ async function safeSessionFile(
resolvedRoot: string,
candidate: string,
sessionId: string,
): Promise<string | undefined> {
): Promise<{ filePath: string; stat: Stats } | undefined> {
if (!isWithin(root, candidate) || path.basename(candidate) !== `${sessionId}.jsonl`) {
return undefined;
}
@@ -283,15 +305,32 @@ async function safeSessionFile(
return undefined;
}
const stat = await fs.stat(resolvedCandidate);
return stat.isFile() ? resolvedCandidate : undefined;
return stat.isFile() ? { filePath: resolvedCandidate, stat } : undefined;
} catch {
return undefined;
}
}
async function readJsonFile(filePath: string): Promise<unknown> {
const stat = await fs.stat(filePath).catch(() => undefined);
if (!stat?.isFile()) {
catalogJsonCache.delete(filePath);
return undefined;
}
const cached = catalogJsonCache.get(filePath);
if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
setBoundedCache(catalogJsonCache, filePath, cached, MAX_CATALOG_JSON_CACHE_ENTRIES);
return cached.value;
}
try {
return JSON.parse(await fs.readFile(filePath, "utf8")) as unknown;
const value = JSON.parse(await fs.readFile(filePath, "utf8")) as unknown;
setBoundedCache(
catalogJsonCache,
filePath,
{ mtimeMs: stat.mtimeMs, size: stat.size, value },
MAX_CATALOG_JSON_CACHE_ENTRIES,
);
return value;
} catch {
return undefined;
}
@@ -311,6 +350,34 @@ function projectsDir(homeDir: string): string {
return path.join(homeDir, ".claude", "projects");
}
async function readProjectsTreeStamp(root: string): Promise<string> {
let entries: Dirent[];
try {
entries = await fs.readdir(root, { withFileTypes: true });
} catch {
return "unavailable";
}
const directoryNames = entries
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.toSorted();
const directorySnapshots = await Promise.all(
directoryNames.map(async (name) => {
const directory = path.join(root, name);
const [stat, childNames] = await Promise.all([
fs.stat(directory).catch(() => undefined),
fs.readdir(directory).catch(() => undefined),
]);
return [
name,
stat?.isDirectory() === true ? stat.mtimeMs : null,
childNames?.toSorted() ?? null,
] as const;
}),
);
return JSON.stringify(directorySnapshots);
}
function desktopSessionsDir(homeDir: string): string {
return path.join(homeDir, "Library", "Application Support", "Claude", "claude-code-sessions");
}
@@ -394,13 +461,13 @@ async function readIndexRecords(homeDir: string): Promise<{
continue;
}
const indexedPath = optionalString(entry.fullPath, MAX_STRING_LENGTH);
const filePath = await safeSessionFile(
const safeFile = await safeSessionFile(
root,
resolvedRoot,
indexedPath ?? path.join(projectDir, `${sessionId}.jsonl`),
sessionId,
);
if (!filePath) {
if (!safeFile) {
continue;
}
const createdAt = timestampMs(entry.created);
@@ -420,7 +487,7 @@ async function readIndexRecords(homeDir: string): Promise<{
? { gitBranch: optionalString(entry.gitBranch, 500) }
: {}),
archived: false,
filePath,
filePath: safeFile.filePath,
});
}
}
@@ -435,9 +502,9 @@ async function locateSessionFile(homeDir: string, sessionId: string): Promise<st
}
for (const projectDir of await childDirectories(root)) {
const candidate = path.join(projectDir, `${sessionId}.jsonl`);
const filePath = await safeSessionFile(root, resolvedRoot, candidate, sessionId);
if (filePath) {
return filePath;
const safeFile = await safeSessionFile(root, resolvedRoot, candidate, sessionId);
if (safeFile) {
return safeFile.filePath;
}
}
return undefined;
@@ -484,20 +551,17 @@ async function discoverCliRecords(
if (!sessionId || records.has(sessionId) || sidechainIds.has(sessionId)) {
continue;
}
const filePath = await safeSessionFile(
const safeFile = await safeSessionFile(
root,
resolvedRoot,
path.join(projectDir, name),
sessionId,
);
if (!filePath) {
if (!safeFile) {
continue;
}
const { filePath, stat: fileStat } = safeFile;
seenFilePaths.add(filePath);
const fileStat = await fs.stat(filePath).catch(() => undefined);
if (!fileStat?.isFile()) {
continue;
}
const cached = catalogDiscoveryCache.get(filePath);
// Claude transcripts only append while active, then stay static, so mtime+size+ino identify
// the parsed content (ino also rejects an atomic replacement that reused the same mtime/size),
@@ -671,7 +735,7 @@ async function discoverCliRecords(
}
}
async function listClaudeSessions(homeDir = currentHomeDir()): Promise<CatalogRecord[]> {
async function scanClaudeSessions(homeDir: string): Promise<CatalogRecord[]> {
const [indexed, desktop] = await Promise.all([
readIndexRecords(homeDir),
readDesktopMetadata(homeDir),
@@ -718,6 +782,42 @@ async function listClaudeSessions(homeDir = currentHomeDir()): Promise<CatalogRe
});
}
async function listClaudeSessions(
homeDir = currentHomeDir(),
options: { forceRefresh?: boolean } = {},
): Promise<CatalogRecord[]> {
const root = projectsDir(homeDir);
const treeStamp = await readProjectsTreeStamp(root);
const cached = claudeSessionScanCache.get(root);
// This tree stamp is the catalog cache's cheap freshness owner: exact child membership makes
// same-tick adds, deletes, and renames visible even when a filesystem reuses the directory mtime.
// File appends and Desktop-only changes may stay stale for at most the five-second TTL.
if (
options.forceRefresh !== true &&
cached &&
cached.treeStamp === treeStamp &&
cached.expiresAt > Date.now()
) {
setBoundedCache(claudeSessionScanCache, root, cached, MAX_CLAUDE_SESSION_SCAN_CACHE_ENTRIES);
return await cached.records;
}
const records = scanClaudeSessions(homeDir);
const entry = {
treeStamp,
expiresAt: Date.now() + CLAUDE_SESSION_SCAN_TTL_MS,
records,
};
setBoundedCache(claudeSessionScanCache, root, entry, MAX_CLAUDE_SESSION_SCAN_CACHE_ENTRIES);
try {
return await records;
} catch (error) {
if (claudeSessionScanCache.get(root) === entry) {
claudeSessionScanCache.delete(root);
}
throw error;
}
}
function encodeOffset(offset: number): string {
return Buffer.from(JSON.stringify({ offset }), "utf8").toString("base64url");
}
@@ -852,9 +952,16 @@ export async function readLocalClaudeTranscriptPage(
homeDir = currentHomeDir(),
): Promise<Omit<ClaudeSessionTranscriptPage, "hostId" | "label">> {
const params = readTranscriptParams(value);
const filePath = (await listClaudeSessions(homeDir)).find(
let filePath = (await listClaudeSessions(homeDir)).find(
(record) => record.threadId === params.threadId,
)?.filePath;
if (!filePath) {
// A just-created session can predate the short catalog TTL. Specific reads must retry against
// disk so opening a new thread never fails only because the sidebar snapshot is still warm.
filePath = (await listClaudeSessions(homeDir, { forceRefresh: true })).find(
(record) => record.threadId === params.threadId,
)?.filePath;
}
if (!filePath) {
throw new ClaudeCatalogParamsError("Claude session is unavailable");
}