mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-16 12:40:44 +00:00
perf(plugins): cache manifest metadata loads
This commit is contained in:
@@ -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 = `{
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user