Skip to content

Commit 9a21e4e

Browse files
committed
perf: cache plugin registry snapshots
1 parent b5d90ae commit 9a21e4e

5 files changed

Lines changed: 273 additions & 8 deletions

File tree

src/config/sessions/skill-prompt-blobs.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const PROMPT_BLOB_ALGORITHM: SessionSkillPromptRef["algorithm"] = "sha256";
99
const PROMPT_BLOB_VERSION: SessionSkillPromptRef["version"] = 1;
1010
const MIN_PROMPT_BLOB_CHARS = 512;
1111
const MAX_PROMPT_BLOB_BYTES = 512 * 1024;
12+
const PROMPT_REF_CACHE_MAX_ENTRIES = 256;
1213

1314
type PersistedSessionStore = {
1415
store: Record<string, SessionEntry>;
@@ -25,10 +26,26 @@ export type SessionStorePersistenceProjection = PersistedSessionStore & {
2526
promptBlobs: Map<string, SessionSkillPromptBlobProjection>;
2627
};
2728

29+
const promptRefCache = new Map<string, SessionSkillPromptRef>();
30+
2831
function hashPrompt(prompt: string): string {
2932
return crypto.createHash(PROMPT_BLOB_ALGORITHM).update(prompt).digest("hex");
3033
}
3134

35+
export function clearSessionSkillPromptRefCache(): void {
36+
promptRefCache.clear();
37+
}
38+
39+
export function getSessionSkillPromptRefCacheStatsForTest(): {
40+
entries: number;
41+
maxEntries: number;
42+
} {
43+
return {
44+
entries: promptRefCache.size,
45+
maxEntries: PROMPT_REF_CACHE_MAX_ENTRIES,
46+
};
47+
}
48+
3249
function isSha256Hex(value: string): boolean {
3350
return /^[a-f0-9]{64}$/u.test(value);
3451
}
@@ -47,12 +64,25 @@ export function resolveSessionSkillPromptBlobPath(storePath: string, hash: strin
4764
}
4865

4966
function buildPromptRef(prompt: string): SessionSkillPromptRef {
50-
return {
67+
const cached = promptRefCache.get(prompt);
68+
if (cached) {
69+
return cached;
70+
}
71+
const ref = {
5172
version: PROMPT_BLOB_VERSION,
5273
algorithm: PROMPT_BLOB_ALGORITHM,
5374
hash: hashPrompt(prompt),
5475
bytes: Buffer.byteLength(prompt, "utf8"),
5576
};
77+
promptRefCache.set(prompt, ref);
78+
while (promptRefCache.size > PROMPT_REF_CACHE_MAX_ENTRIES) {
79+
const oldest = promptRefCache.keys().next().value;
80+
if (typeof oldest !== "string") {
81+
break;
82+
}
83+
promptRefCache.delete(oldest);
84+
}
85+
return ref;
5686
}
5787

5888
function shouldStorePromptAsBlob(prompt: string): boolean {

src/config/sessions/store-cache.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { parseStrictNonNegativeInteger } from "../../infra/parse-finite-number.js";
22
import { createExpiringMapCache, isCacheEnabled, resolveCacheTtlMs } from "../cache-utils.js";
3+
import { clearSessionSkillPromptRefCache } from "./skill-prompt-blobs.js";
34
import type { SessionEntry } from "./types.js";
45

56
export type DeepReadonly<T> = T extends (...args: never[]) => unknown
@@ -259,6 +260,7 @@ export function clearSessionStoreCaches(): void {
259260
SESSION_STORE_SERIALIZED_CACHE.clear();
260261
sessionStoreSerializedCacheBytes = 0;
261262
SESSION_STORE_STRING_INTERN_POOL.clear();
263+
clearSessionSkillPromptRefCache();
262264
resetSessionStoreStringInternStats();
263265
}
264266

src/config/sessions/store.skills-stripping.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ vi.mock("../config.js", async () => ({
1717
getRuntimeConfig: vi.fn().mockReturnValue({}),
1818
}));
1919

20+
import { getSessionSkillPromptRefCacheStatsForTest } from "./skill-prompt-blobs.js";
2021
import {
2122
clearSessionStoreCacheForTest,
2223
loadSessionStore,
@@ -215,6 +216,40 @@ describe("session store strips resolvedSkills from persistence", () => {
215216
`${firstRef.hash}.txt`,
216217
);
217218
expect(await fs.readFile(blobPath, "utf-8")).toBe(prompt);
219+
expect(getSessionSkillPromptRefCacheStatsForTest().entries).toBe(1);
220+
});
221+
222+
it("clears cached prompt refs with the session store caches", async () => {
223+
const prompt = `<available_skills>\n${"clear cache prompt\n".repeat(200)}</available_skills>`;
224+
await saveSessionStore(
225+
storePath,
226+
{
227+
"agent:main:test:1": makeEntry("session-1", makeSnapshotWithPrompt(prompt)),
228+
},
229+
{ skipMaintenance: true },
230+
);
231+
expect(getSessionSkillPromptRefCacheStatsForTest().entries).toBe(1);
232+
233+
clearSessionStoreCacheForTest();
234+
235+
expect(getSessionSkillPromptRefCacheStatsForTest().entries).toBe(0);
236+
});
237+
238+
it("bounds cached prompt refs for distinct large skills prompts", async () => {
239+
const entries = Object.fromEntries(
240+
Array.from({ length: 260 }, (_, index) => {
241+
const prompt = `<available_skills>\n${`bounded prompt ${index}\n`.repeat(200)}</available_skills>`;
242+
return [
243+
`agent:main:test:${index}`,
244+
makeEntry(`session-${index}`, makeSnapshotWithPrompt(prompt)),
245+
];
246+
}),
247+
);
248+
249+
await saveSessionStore(storePath, entries, { skipMaintenance: true });
250+
251+
const stats = getSessionSkillPromptRefCacheStatsForTest();
252+
expect(stats.entries).toBe(stats.maxEntries);
218253
});
219254

220255
it("hydrates content-addressed skills prompt blobs on load", async () => {

src/plugins/plugin-registry-snapshot.ts

Lines changed: 131 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@ import crypto from "node:crypto";
22
import fs from "node:fs";
33
import path from "node:path";
44
import { resolveUserPath } from "../utils.js";
5+
import { resolveCompatibilityHostVersion } from "../version.js";
56
import { resolveBundledPluginsDir } from "./bundled-dir.js";
67
import { getCurrentPluginMetadataSnapshot } from "./current-plugin-metadata-snapshot.js";
78
import type { PluginDiscoveryResult } from "./discovery.js";
8-
import { fileSignatureMatches } from "./installed-plugin-index-hash.js";
9+
import { fileSignatureMatches, hashJson } from "./installed-plugin-index-hash.js";
910
import { hasOptionalMissingPluginManifestFile } from "./installed-plugin-index-manifest.js";
1011
import { loadInstalledPluginIndexInstallRecordsSync } from "./installed-plugin-index-record-reader.js";
12+
import { resolveInstalledPluginIndexStorePath } from "./installed-plugin-index-store-path.js";
1113
import {
1214
inspectPersistedInstalledPluginIndex,
1315
readPersistedInstalledPluginIndexSync,
@@ -27,6 +29,7 @@ import {
2729
type LoadInstalledPluginIndexParams,
2830
type RefreshInstalledPluginIndexParams,
2931
} from "./installed-plugin-index.js";
32+
import { registerPluginMetadataProcessMemoLifecycleClear } from "./plugin-metadata-lifecycle.js";
3033
import type { PluginRegistrySnapshotSource } from "./plugin-registry-snapshot.types.js";
3134

3235
export type PluginRegistrySnapshot = InstalledPluginIndex;
@@ -53,6 +56,35 @@ export type PluginRegistrySnapshotResult = {
5356
};
5457

5558
export const DISABLE_PERSISTED_PLUGIN_REGISTRY_ENV = "OPENCLAW_DISABLE_PERSISTED_PLUGIN_REGISTRY";
59+
const MAX_PLUGIN_REGISTRY_SNAPSHOT_MEMOS = 8;
60+
const REGISTRY_SNAPSHOT_MEMO_ENV_KEYS = [
61+
"APPDATA",
62+
"HOME",
63+
"OPENCLAW_BUNDLED_PLUGINS_DIR",
64+
"OPENCLAW_COMPATIBILITY_HOST_VERSION",
65+
"OPENCLAW_CONFIG_PATH",
66+
"OPENCLAW_DISABLE_BUNDLED_PLUGINS",
67+
"OPENCLAW_DISABLE_BUNDLED_SOURCE_OVERLAYS",
68+
DISABLE_PERSISTED_PLUGIN_REGISTRY_ENV,
69+
"OPENCLAW_HOME",
70+
"OPENCLAW_NIX_MODE",
71+
"OPENCLAW_STATE_DIR",
72+
"USERPROFILE",
73+
"XDG_CONFIG_HOME",
74+
] as const;
75+
76+
type PluginRegistrySnapshotMemo = {
77+
key: string;
78+
result: PluginRegistrySnapshotResult;
79+
};
80+
81+
let pluginRegistrySnapshotMemos: PluginRegistrySnapshotMemo[] = [];
82+
83+
function clearLoadPluginRegistrySnapshotMemo(): void {
84+
pluginRegistrySnapshotMemos = [];
85+
}
86+
87+
registerPluginMetadataProcessMemoLifecycleClear(clearLoadPluginRegistrySnapshotMemo);
5688

5789
function formatDeprecatedPersistedRegistryDisableWarning(): string {
5890
return `${DISABLE_PERSISTED_PLUGIN_REGISTRY_ENV} is a deprecated break-glass compatibility switch; use \`openclaw plugins registry --refresh\` or \`openclaw doctor --fix\` to repair registry state.`;
@@ -73,6 +105,96 @@ function hasEnvFlag(env: NodeJS.ProcessEnv, name: string): boolean {
73105
return Boolean(value && value !== "0" && value !== "false" && value !== "no");
74106
}
75107

108+
function pickRegistrySnapshotMemoEnv(env: NodeJS.ProcessEnv): Record<string, string> {
109+
return Object.fromEntries(
110+
REGISTRY_SNAPSHOT_MEMO_ENV_KEYS.flatMap((key) => {
111+
const value = env[key];
112+
return value === undefined ? [] : [[key, value]];
113+
}),
114+
);
115+
}
116+
117+
function canMemoizePluginRegistrySnapshot(params: LoadPluginRegistryParams): boolean {
118+
return (
119+
params.index === undefined &&
120+
params.candidates === undefined &&
121+
params.diagnostics === undefined &&
122+
params.discovery === undefined &&
123+
params.installRecords === undefined &&
124+
params.now === undefined &&
125+
params.filePath === undefined &&
126+
params.pluginIndexFilePath === undefined
127+
);
128+
}
129+
130+
function resolvePluginRegistrySnapshotMemoKey(
131+
params: LoadPluginRegistryParams,
132+
env: NodeJS.ProcessEnv,
133+
): string | undefined {
134+
if (!canMemoizePluginRegistrySnapshot(params)) {
135+
return undefined;
136+
}
137+
return hashJson({
138+
config: params.config ?? null,
139+
cwd: process.cwd(),
140+
env: pickRegistrySnapshotMemoEnv(env),
141+
hostContractVersion: resolveCompatibilityHostVersion(env),
142+
preferPersisted: params.preferPersisted ?? null,
143+
// Plugin manifests are process-stable inside the Gateway, while the persisted
144+
// registry envelope can change through explicit refresh/install flows.
145+
registryFile: fileFingerprint(
146+
resolveInstalledPluginIndexStorePath({
147+
env,
148+
...(params.stateDir ? { stateDir: params.stateDir } : {}),
149+
}),
150+
),
151+
stateDir: params.stateDir ? resolveUserPath(params.stateDir, env) : null,
152+
workspaceDir: params.workspaceDir ? resolveUserPath(params.workspaceDir, env) : null,
153+
});
154+
}
155+
156+
function fileFingerprint(filePath: string): unknown {
157+
try {
158+
const stat = fs.statSync(filePath, { bigint: true });
159+
const kind = stat.isFile() ? "file" : stat.isDirectory() ? "dir" : "other";
160+
return [filePath, kind, stat.size.toString(), stat.mtimeNs.toString(), stat.ctimeNs.toString()];
161+
} catch {
162+
return [filePath, "missing"];
163+
}
164+
}
165+
166+
function findPluginRegistrySnapshotMemo(
167+
key: string | undefined,
168+
): PluginRegistrySnapshotResult | undefined {
169+
if (!key) {
170+
return undefined;
171+
}
172+
const index = pluginRegistrySnapshotMemos.findIndex((memo) => memo.key === key);
173+
if (index === -1) {
174+
return undefined;
175+
}
176+
const [memo] = pluginRegistrySnapshotMemos.splice(index, 1);
177+
if (!memo) {
178+
return undefined;
179+
}
180+
pluginRegistrySnapshotMemos.unshift(memo);
181+
return memo.result;
182+
}
183+
184+
function rememberPluginRegistrySnapshotMemo(
185+
key: string | undefined,
186+
result: PluginRegistrySnapshotResult,
187+
): PluginRegistrySnapshotResult {
188+
if (!key) {
189+
return result;
190+
}
191+
pluginRegistrySnapshotMemos = [
192+
{ key, result },
193+
...pluginRegistrySnapshotMemos.filter((memo) => memo.key !== key),
194+
].slice(0, MAX_PLUGIN_REGISTRY_SNAPSHOT_MEMOS);
195+
return result;
196+
}
197+
76198
function canReuseCurrentPluginMetadataSnapshot(params: LoadPluginRegistryParams): boolean {
77199
return (
78200
params.preferPersisted !== false &&
@@ -288,6 +410,11 @@ export function loadPluginRegistrySnapshotWithMetadata(
288410
}
289411

290412
const env = params.env ?? process.env;
413+
const memoKey = resolvePluginRegistrySnapshotMemoKey(params, env);
414+
const memo = findPluginRegistrySnapshotMemo(memoKey);
415+
if (memo) {
416+
return memo;
417+
}
291418
const diagnostics: PluginRegistrySnapshotDiagnostic[] = [];
292419
const disabledByCaller = params.preferPersisted === false;
293420
const disabledByEnv = hasEnvFlag(env, DISABLE_PERSISTED_PLUGIN_REGISTRY_ENV);
@@ -354,7 +481,7 @@ export function loadPluginRegistrySnapshotWithMetadata(
354481
source: "persisted",
355482
diagnostics,
356483
};
357-
return persistedResult;
484+
return rememberPluginRegistrySnapshotMemo(memoKey, persistedResult);
358485
}
359486
} else if (persistedReadsEnabled) {
360487
diagnostics.push({
@@ -379,12 +506,12 @@ export function loadPluginRegistrySnapshotWithMetadata(
379506
? params.installRecords
380507
: (params.installRecords ?? {}),
381508
});
382-
return {
509+
return rememberPluginRegistrySnapshotMemo(memoKey, {
383510
snapshot: derived.index,
384511
source: "derived",
385512
diagnostics,
386513
discovery: derived.discovery,
387-
};
514+
});
388515
}
389516

390517
function resolveSnapshot(params: LoadPluginRegistryParams = {}): PluginRegistrySnapshot {

0 commit comments

Comments
 (0)