perf(gateway): cache stable plugin index fingerprints

This commit is contained in:
Peter Steinberger
2026-05-27 10:01:57 +01:00
parent 1d4537add3
commit 2babe03bf5
8 changed files with 296 additions and 121 deletions

View File

@@ -1,3 +1,4 @@
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import type { PluginInstallRecord } from "../config/types.plugins.js";
@@ -239,8 +240,9 @@ const installRecordsCache = new Map<string, InstallRecordsCacheEntry>();
function readFileSignature(filePath: string): string {
try {
const stat = fs.statSync(filePath);
return `${stat.mtimeMs}:${stat.size}`;
const stat = fs.statSync(filePath, { bigint: true });
const hash = crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("base64url");
return `${stat.mtimeNs}:${stat.ctimeNs}:${stat.size}:${hash}`;
} catch {
return "missing";
}

View File

@@ -6,7 +6,10 @@ import {
writePersistedInstalledPluginIndex,
} from "./installed-plugin-index-store.js";
import type { InstalledPluginIndex } from "./installed-plugin-index.js";
import { loadPluginManifestRegistryForInstalledIndex } from "./manifest-registry-installed.js";
import {
loadPluginManifestRegistryForInstalledIndex,
resolveInstalledManifestRegistryIndexFingerprint,
} from "./manifest-registry-installed.js";
import { cleanupTrackedTempDirs, makeTrackedTempDir } from "./test-helpers/fs-fixtures.js";
const tempDirs: string[] = [];
@@ -70,7 +73,106 @@ function createIndex(rootDir: string): InstalledPluginIndex {
};
}
function fileSignature(filePath: string) {
const stat = fs.statSync(filePath);
return {
size: stat.size,
mtimeMs: stat.mtimeMs,
ctimeMs: stat.ctimeMs,
};
}
function createIndexWithFileSignatures(rootDir: string): InstalledPluginIndex {
const index = createIndex(rootDir);
return {
...index,
plugins: index.plugins.map((record) => ({
...record,
manifestFile: fileSignature(record.manifestPath),
})),
};
}
function deepFreeze<T>(value: T, seen = new WeakSet<object>()): T {
if (!value || typeof value !== "object") {
return value;
}
const object = value as object;
if (seen.has(object)) {
return value;
}
seen.add(object);
for (const child of Object.values(value)) {
deepFreeze(child, seen);
}
return Object.freeze(value);
}
describe("loadPluginManifestRegistryForInstalledIndex", () => {
it("reuses frozen installed-index fingerprints when file signatures are persisted", () => {
const rootDir = makeTempDir();
writePlugin(rootDir, "installed", "installed-");
const index = deepFreeze(createIndexWithFileSignatures(rootDir));
const first = resolveInstalledManifestRegistryIndexFingerprint(index);
const manifestPath = path.join(rootDir, "openclaw.plugin.json");
const nextMtime = new Date(Date.now() + 5000);
fs.utimesSync(manifestPath, nextMtime, nextMtime);
const second = resolveInstalledManifestRegistryIndexFingerprint(index);
expect(second).toBe(first);
});
it("recomputes installed-index fingerprints for mutable index objects", () => {
const rootDir = makeTempDir();
writePlugin(rootDir, "installed", "installed-");
const index = createIndexWithFileSignatures(rootDir);
const first = resolveInstalledManifestRegistryIndexFingerprint(index);
const record = index.plugins[0];
if (!record) {
throw new Error("expected index record");
}
record.manifestHash = "changed";
const second = resolveInstalledManifestRegistryIndexFingerprint(index);
expect(second).not.toBe(first);
});
it("does not cache shallow-frozen installed-index fingerprints with mutable nested records", () => {
const rootDir = makeTempDir();
writePlugin(rootDir, "installed", "installed-");
const index = createIndexWithFileSignatures(rootDir);
const record = index.plugins[0];
if (!record) {
throw new Error("expected index record");
}
Object.freeze(index.installRecords);
Object.freeze(index.diagnostics);
Object.freeze(record);
Object.freeze(index.plugins);
Object.freeze(index);
const first = resolveInstalledManifestRegistryIndexFingerprint(index);
const agentHarnesses = record.startup.agentHarnesses as string[];
agentHarnesses.push("changed");
const second = resolveInstalledManifestRegistryIndexFingerprint(index);
expect(second).not.toBe(first);
});
it("does not cache frozen installed-index fingerprints that depend on live file state", () => {
const rootDir = makeTempDir();
writePlugin(rootDir, "installed", "installed-");
const index = deepFreeze(createIndex(rootDir));
const first = resolveInstalledManifestRegistryIndexFingerprint(index);
const manifestPath = path.join(rootDir, "openclaw.plugin.json");
const nextMtime = new Date(Date.now() + 5000);
fs.utimesSync(manifestPath, nextMtime, nextMtime);
const second = resolveInstalledManifestRegistryIndexFingerprint(index);
expect(second).not.toBe(first);
});
it("reconstructs installed-index manifest registries when manifest files change", () => {
const rootDir = makeTempDir();
const manifestPath = path.join(rootDir, "openclaw.plugin.json");

View File

@@ -7,6 +7,7 @@ import { normalizeOptionalString } from "../shared/string-coerce.js";
import { normalizeOptionalTrimmedStringList } from "../shared/string-normalization.js";
import type { PluginCandidate } from "./discovery.js";
import { hashJson } from "./installed-plugin-index-hash.js";
import type { InstalledPluginFileSignature } from "./installed-plugin-index-hash.js";
import type { InstalledPluginIndex, InstalledPluginIndexRecord } from "./installed-plugin-index.js";
import { extractPluginInstallRecordsFromInstalledPluginIndex } from "./installed-plugin-index.js";
import { loadPluginManifestRegistry, type PluginManifestRegistry } from "./manifest-registry.js";
@@ -25,6 +26,37 @@ import {
type PluginDependencySpecMap,
} from "./status-dependencies.js";
const installedManifestRegistryIndexFingerprintCache = new WeakMap<InstalledPluginIndex, string>();
function isDeepFrozenJsonLike(value: unknown, seen = new WeakSet<object>()): boolean {
if (!value || typeof value !== "object") {
return true;
}
const object = value as object;
if (seen.has(object)) {
return true;
}
if (!Object.isFrozen(object)) {
return false;
}
seen.add(object);
return Object.values(value).every((entry) => isDeepFrozenJsonLike(entry, seen));
}
function hasPersistedFileSignatures(index: InstalledPluginIndex): boolean {
return index.plugins.every(
(record) =>
record.manifestFile !== undefined &&
(record.packageJson === undefined || record.packageJson.fileSignature !== undefined),
);
}
function isInstalledManifestRegistryIndexFingerprintCacheable(
index: InstalledPluginIndex,
): boolean {
return hasPersistedFileSignatures(index) && isDeepFrozenJsonLike(index);
}
function isRelativePathInsideOrEqual(relativePath: string): boolean {
return (
relativePath === "" ||
@@ -61,12 +93,19 @@ function safeFileSignature(filePath: string | undefined): string | undefined {
}
try {
const stat = fs.statSync(filePath);
return `${filePath}:${stat.size}:${stat.mtimeMs}`;
return formatFileSignature(filePath, stat);
} catch {
return `${filePath}:missing`;
}
}
function formatFileSignature(
filePath: string,
signature: Pick<InstalledPluginFileSignature, "size" | "mtimeMs">,
): string {
return `${filePath}:${signature.size}:${signature.mtimeMs}`;
}
function buildInstalledManifestRegistryIndexKey(index: InstalledPluginIndex) {
const realpathCache = new Map<string, string>();
return {
@@ -78,9 +117,12 @@ function buildInstalledManifestRegistryIndexKey(index: InstalledPluginIndex) {
installRecords: index.installRecords,
diagnostics: index.diagnostics,
plugins: index.plugins.map((record) => {
const packageJsonFile =
record.packageJson?.fileSignature ??
safeFileSignature(resolvePackageJsonPath(record, realpathCache));
const packageJsonPath = resolvePackageJsonPath(record, realpathCache);
const packageJsonFile = record.packageJson?.fileSignature
? packageJsonPath
? formatFileSignature(packageJsonPath, record.packageJson.fileSignature)
: undefined
: safeFileSignature(packageJsonPath);
return {
pluginId: record.pluginId,
packageName: record.packageName,
@@ -91,7 +133,9 @@ function buildInstalledManifestRegistryIndexKey(index: InstalledPluginIndex) {
packageChannel: record.packageChannel,
manifestPath: record.manifestPath,
manifestHash: record.manifestHash,
manifestFile: safeFileSignature(record.manifestPath),
manifestFile: record.manifestFile
? formatFileSignature(record.manifestPath, record.manifestFile)
: safeFileSignature(record.manifestPath),
format: record.format,
bundleFormat: record.bundleFormat,
source: record.source,
@@ -116,7 +160,15 @@ function buildInstalledManifestRegistryIndexKey(index: InstalledPluginIndex) {
export function resolveInstalledManifestRegistryIndexFingerprint(
index: InstalledPluginIndex,
): string {
return hashJson(buildInstalledManifestRegistryIndexKey(index));
const cached = installedManifestRegistryIndexFingerprintCache.get(index);
if (cached) {
return cached;
}
const fingerprint = hashJson(buildInstalledManifestRegistryIndexKey(index));
if (isInstalledManifestRegistryIndexFingerprintCacheable(index)) {
installedManifestRegistryIndexFingerprintCache.set(index, fingerprint);
}
return fingerprint;
}
function resolveInstalledPluginRootDir(record: InstalledPluginIndexRecord): string {