perf(plugins): cache manifest metadata loads

This commit is contained in:
Peter Steinberger
2026-04-28 20:34:06 +01:00
parent 98f5fd12df
commit 0608c1015b
4 changed files with 124 additions and 9 deletions

View File

@@ -2,7 +2,11 @@ import fs from "node:fs";
import path from "node:path";
import JSON5 from "json5";
import { afterEach, describe, expect, it, vi } from "vitest";
import { loadPluginManifest, MAX_PLUGIN_MANIFEST_BYTES } from "./manifest.js";
import {
clearPluginManifestLoadCache,
loadPluginManifest,
MAX_PLUGIN_MANIFEST_BYTES,
} from "./manifest.js";
import { cleanupTrackedTempDirs, makeTrackedTempDir } from "./test-helpers/fs-fixtures.js";
const tempDirs: string[] = [];
@@ -13,6 +17,7 @@ function makeTempDir() {
afterEach(() => {
vi.restoreAllMocks();
clearPluginManifestLoadCache();
cleanupTrackedTempDirs(tempDirs);
});
@@ -53,6 +58,26 @@ describe("loadPluginManifest JSON5 tolerance", () => {
expect(json5Parse).not.toHaveBeenCalled();
});
it("reuses unchanged manifest loads by file signature", () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, "openclaw.plugin.json"),
JSON.stringify({
id: "cached-json",
configSchema: { type: "object" },
}),
"utf-8",
);
const readFileSync = vi.spyOn(fs, "readFileSync");
const first = loadPluginManifest(dir, false);
const second = loadPluginManifest(dir, false);
expect(first.ok).toBe(true);
expect(second.ok).toBe(true);
expect(readFileSync).toHaveBeenCalledTimes(1);
});
it("parses a manifest with trailing commas", () => {
const dir = makeTempDir();
const json5Content = `{

View File

@@ -33,6 +33,20 @@ import type { PluginKind } from "./plugin-kind.types.js";
export const PLUGIN_MANIFEST_FILENAME = "openclaw.plugin.json";
export const PLUGIN_MANIFEST_FILENAMES = [PLUGIN_MANIFEST_FILENAME] as const;
export const MAX_PLUGIN_MANIFEST_BYTES = 256 * 1024;
const MAX_PLUGIN_MANIFEST_LOAD_CACHE_ENTRIES = 512;
type PluginManifestLoadCacheEntry = {
result: PluginManifestLoadResult;
size: number;
mtimeMs: number;
ctimeMs: number;
};
const pluginManifestLoadCache = new Map<string, PluginManifestLoadCacheEntry>();
export function clearPluginManifestLoadCache(): void {
pluginManifestLoadCache.clear();
}
export type PluginManifestChannelConfig = {
schema: JsonSchemaObject;
@@ -1148,6 +1162,62 @@ export function resolvePluginManifestPath(rootDir: string): string {
return path.join(rootDir, PLUGIN_MANIFEST_FILENAME);
}
function buildPluginManifestLoadCacheKey(params: {
manifestPath: string;
rejectHardlinks: boolean;
rootRealPath?: string;
stats: fs.Stats;
}): string {
return JSON.stringify([
path.resolve(params.manifestPath),
params.rejectHardlinks,
params.rootRealPath ?? "",
params.stats.dev,
params.stats.ino,
params.stats.size,
params.stats.mtimeMs,
params.stats.ctimeMs,
]);
}
function getCachedPluginManifestLoadResult(
key: string,
stats: fs.Stats,
): PluginManifestLoadResult | undefined {
const entry = pluginManifestLoadCache.get(key);
if (
!entry ||
entry.size !== stats.size ||
entry.mtimeMs !== stats.mtimeMs ||
entry.ctimeMs !== stats.ctimeMs
) {
return undefined;
}
pluginManifestLoadCache.delete(key);
pluginManifestLoadCache.set(key, entry);
return entry.result;
}
function setCachedPluginManifestLoadResult(
key: string,
stats: fs.Stats,
result: PluginManifestLoadResult,
): void {
pluginManifestLoadCache.set(key, {
result,
size: stats.size,
mtimeMs: stats.mtimeMs,
ctimeMs: stats.ctimeMs,
});
if (pluginManifestLoadCache.size <= MAX_PLUGIN_MANIFEST_LOAD_CACHE_ENTRIES) {
return;
}
const oldestKey = pluginManifestLoadCache.keys().next().value;
if (typeof oldestKey === "string") {
pluginManifestLoadCache.delete(oldestKey);
}
}
function parsePluginKind(raw: unknown): PluginKind | PluginKind[] | undefined {
if (typeof raw === "string") {
return raw as PluginKind;
@@ -1186,28 +1256,44 @@ export function loadPluginManifest(
}),
});
}
const stats = opened.stat;
const cacheKey = buildPluginManifestLoadCacheKey({
manifestPath,
rejectHardlinks,
...(rootRealPath !== undefined ? { rootRealPath } : {}),
stats,
});
const cached = getCachedPluginManifestLoadResult(cacheKey, stats);
if (cached) {
fs.closeSync(opened.fd);
return cached;
}
const cacheResult = (result: PluginManifestLoadResult): PluginManifestLoadResult => {
setCachedPluginManifestLoadResult(cacheKey, stats, result);
return result;
};
let raw: unknown;
try {
raw = parseJsonWithJson5Fallback(fs.readFileSync(opened.fd, "utf-8"));
} catch (err) {
return {
return cacheResult({
ok: false,
error: `failed to parse plugin manifest: ${String(err)}`,
manifestPath,
};
});
} finally {
fs.closeSync(opened.fd);
}
if (!isRecord(raw)) {
return { ok: false, error: "plugin manifest must be an object", manifestPath };
return cacheResult({ ok: false, error: "plugin manifest must be an object", manifestPath });
}
const id = normalizeOptionalString(raw.id) ?? "";
if (!id) {
return { ok: false, error: "plugin manifest requires id", manifestPath };
return cacheResult({ ok: false, error: "plugin manifest requires id", manifestPath });
}
const configSchema = isRecord(raw.configSchema) ? raw.configSchema : null;
if (!configSchema) {
return { ok: false, error: "plugin manifest requires configSchema", manifestPath };
return cacheResult({ ok: false, error: "plugin manifest requires configSchema", manifestPath });
}
const kind = parsePluginKind(raw.kind);
@@ -1260,7 +1346,7 @@ export function loadPluginManifest(
uiHints = raw.uiHints as Record<string, PluginConfigUiHint>;
}
return {
return cacheResult({
ok: true,
manifest: {
id,
@@ -1302,7 +1388,7 @@ export function loadPluginManifest(
channelConfigs,
},
manifestPath,
};
});
}
// package.json "openclaw" metadata (used for setup/catalog)