perf(plugins): memoize auto-enable fingerprints (#118334)

This commit is contained in:
Peter Steinberger
2026-08-02 18:09:02 -07:00
committed by GitHub
parent 357087b9c7
commit 74c4d1cc7e
4 changed files with 171 additions and 46 deletions
+34 -10
View File
@@ -2,6 +2,7 @@
import type { AmbientEnvTriggerPolicy } from "../channels/config-presence.js";
import type { PluginDiscoveryResult } from "../plugins/discovery.js";
import type { PluginManifestRegistry } from "../plugins/manifest-registry.js";
import { registerPluginMetadataProcessMemoLifecycleClear } from "../plugins/plugin-metadata-lifecycle.js";
import { detectPluginAutoEnableCandidates } from "./plugin-auto-enable.detect.js";
import {
materializePluginAutoEnableCandidatesInternal,
@@ -29,6 +30,16 @@ type PluginAutoEnableConfigCache = WeakMap<object, PluginAutoEnableEnvCache>;
let sameTurnApplyCache: PluginAutoEnableConfigCache | undefined;
let sameTurnApplyCacheClearScheduled = false;
let stableFingerprintMemo = new WeakMap<object, string>();
let configFingerprintMemo = new WeakMap<object, string>();
// Gateway metadata/config use replacement snapshots, and process.env selection is generation-fixed.
// The plugin metadata lifecycle clear is the freshness boundary for these identity memos.
registerPluginMetadataProcessMemoLifecycleClear(() => {
stableFingerprintMemo = new WeakMap();
configFingerprintMemo = new WeakMap();
sameTurnApplyCache = undefined;
});
function scheduleSameTurnApplyCacheClear(): void {
if (sameTurnApplyCacheClearScheduled) {
@@ -61,22 +72,35 @@ function stableFingerprintValue(value: unknown): string {
if (value === null || typeof value !== "object") {
return JSON.stringify(value) ?? "null";
}
if (Array.isArray(value)) {
return `[${value.map((entry) => stableFingerprintValue(entry)).join(",")}]`;
const cached = stableFingerprintMemo.get(value);
if (cached !== undefined) {
return cached;
}
const record = value as Record<string, unknown>;
return `{${Object.keys(record)
.toSorted((left, right) => left.localeCompare(right))
.map((key) => `${JSON.stringify(key)}:${stableFingerprintValue(record[key])}`)
.join(",")}}`;
const fingerprint = Array.isArray(value)
? `[${value.map((entry) => stableFingerprintValue(entry)).join(",")}]`
: (() => {
const record = value as Record<string, unknown>;
return `{${Object.keys(record)
.toSorted((left, right) => left.localeCompare(right))
.map((key) => `${JSON.stringify(key)}:${stableFingerprintValue(record[key])}`)
.join(",")}}`;
})();
stableFingerprintMemo.set(value, fingerprint);
return fingerprint;
}
/** Fingerprints mutable config inputs used by plugin auto-enable detection. */
/** Fingerprints config snapshots used by plugin auto-enable detection. */
export function fingerprintPluginAutoEnableConfig(config: OpenClawConfig): string {
return hashRuntimeConfigValue(config);
const cached = configFingerprintMemo.get(config);
if (cached !== undefined) {
return cached;
}
const fingerprint = hashRuntimeConfigValue(config);
configFingerprintMemo.set(config, fingerprint);
return fingerprint;
}
/** Fingerprints mutable environment inputs used by plugin auto-enable detection. */
/** Fingerprints environment snapshots used by plugin auto-enable detection. */
export function fingerprintPluginAutoEnableEnv(env: NodeJS.ProcessEnv): string {
return stableFingerprintValue(env);
}
+71 -4
View File
@@ -4,6 +4,7 @@ import path from "node:path";
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
import { setCurrentPluginMetadataSnapshot } from "../plugins/current-plugin-metadata-snapshot.js";
import type { PluginCandidate, PluginDiscoveryResult } from "../plugins/discovery.js";
import { clearPluginMetadataLifecycleCaches } from "../plugins/plugin-metadata-lifecycle.js";
import {
applyPluginAutoEnable,
detectPluginAutoEnableCandidates,
@@ -1119,6 +1120,68 @@ describe("applyPluginAutoEnable core", () => {
expect(setupRegistryMock.resolvePluginSetupAutoEnableReasons).toHaveBeenCalledTimes(2);
});
it("fingerprints identical snapshots once per plugin metadata lifecycle", () => {
const traversals = { candidates: 0, config: 0, env: 0, plugins: 0 };
const config = new Proxy<OpenClawConfig>(
{},
{
ownKeys: (target) => {
traversals.config += 1;
return Reflect.ownKeys(target);
},
},
);
const envSnapshot = new Proxy(makeIsolatedEnv(), {
ownKeys: (target) => {
traversals.env += 1;
return Reflect.ownKeys(target);
},
});
const discovery: PluginDiscoveryResult = {
candidates: new Proxy([], {
get: (target, property, receiver) => {
if (property === "map") {
traversals.candidates += 1;
}
return Reflect.get(target, property, receiver);
},
}),
diagnostics: [],
};
const manifestRegistry = makeRegistry([]);
manifestRegistry.plugins = new Proxy(manifestRegistry.plugins, {
get: (target, property, receiver) => {
if (property === "map") {
traversals.plugins += 1;
}
return Reflect.get(target, property, receiver);
},
});
const first = applyPluginAutoEnable({
config,
discovery,
env: envSnapshot,
manifestRegistry,
});
const firstTraversalCounts = { ...traversals };
for (let index = 0; index < 20; index += 1) {
expect(applyPluginAutoEnable({ config, discovery, env: envSnapshot, manifestRegistry })).toBe(
first,
);
}
expect(traversals).toEqual(firstTraversalCounts);
clearPluginMetadataLifecycleCaches();
applyPluginAutoEnable({ config, discovery, env: envSnapshot, manifestRegistry });
expect(traversals.config).toBeGreaterThan(firstTraversalCounts.config);
expect(traversals.env).toBeGreaterThan(firstTraversalCounts.env);
expect(traversals.candidates).toBeGreaterThan(firstTraversalCounts.candidates);
expect(traversals.plugins).toBeGreaterThan(firstTraversalCounts.plugins);
});
it("does not reuse same-turn results for omitted metadata after current snapshot replacement", () => {
const config: OpenClawConfig = {
channels: { apn: { someKey: "value" } },
@@ -1196,7 +1259,7 @@ describe("applyPluginAutoEnable core", () => {
expect(setupRegistryMock.resolvePluginSetupAutoEnableReasons).toHaveBeenCalledTimes(2);
});
it("does not reuse same-turn auto-enable results after config mutates in place", () => {
it("refreshes auto-enable results after config mutates at a lifecycle boundary", () => {
const config: OpenClawConfig = {};
const manifestRegistry = makeRegistry([{ id: "apn-channel", channels: ["apn"] }]);
@@ -1207,6 +1270,7 @@ describe("applyPluginAutoEnable core", () => {
manifestRegistry,
});
config.channels = { apn: { someKey: "value" } };
clearPluginMetadataLifecycleCaches();
const second = applyPluginAutoEnable({
config,
discovery: emptyDiscovery,
@@ -1219,7 +1283,7 @@ describe("applyPluginAutoEnable core", () => {
expect(second).not.toBe(first);
});
it("does not reuse same-turn auto-enable results after registry mutates in place", () => {
it("refreshes auto-enable results after registry mutates at a lifecycle boundary", () => {
const config: OpenClawConfig = {
channels: { apn: { someKey: "value" } },
};
@@ -1236,6 +1300,7 @@ describe("applyPluginAutoEnable core", () => {
registry.plugins.length,
...makeRegistry([{ id: "apn-channel", channels: ["apn"] }]).plugins,
);
clearPluginMetadataLifecycleCaches();
const second = applyPluginAutoEnable({
config,
discovery: emptyDiscovery,
@@ -1248,7 +1313,7 @@ describe("applyPluginAutoEnable core", () => {
expect(second).not.toBe(first);
});
it("does not reuse same-turn auto-enable results after discovery mutates in place", () => {
it("refreshes auto-enable results after discovery mutates at a lifecycle boundary", () => {
const config: OpenClawConfig = {};
const mutableDiscovery: PluginDiscoveryResult = { candidates: [], diagnostics: [] };
const manifestRegistry = makeRegistry([
@@ -1270,6 +1335,7 @@ describe("applyPluginAutoEnable core", () => {
channelId: "cache-channel",
}),
);
clearPluginMetadataLifecycleCaches();
const second = applyPluginAutoEnable({
config,
discovery: mutableDiscovery,
@@ -1282,7 +1348,7 @@ describe("applyPluginAutoEnable core", () => {
expect(second).not.toBe(first);
});
it("does not reuse same-turn auto-enable results after env mutates in place", () => {
it("refreshes auto-enable results after env mutates at a lifecycle boundary", () => {
const config: OpenClawConfig = {
plugins: {
entries: {
@@ -1303,6 +1369,7 @@ describe("applyPluginAutoEnable core", () => {
manifestRegistry,
});
mutableEnv.OPENCLAW_TEST_CACHE_INPUT = "changed";
clearPluginMetadataLifecycleCaches();
const second = applyPluginAutoEnable({
config,
discovery: emptyDiscovery,
+38 -20
View File
@@ -5,6 +5,10 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js";
const loadConfigMock = vi.fn<typeof import("../../config/config.js").loadConfig>();
const applyPluginAutoEnableMock =
vi.fn<typeof import("../../config/plugin-auto-enable.js").applyPluginAutoEnable>();
const fingerprintPluginAutoEnableConfigMock = vi.fn((config: OpenClawConfig) =>
JSON.stringify(config),
);
const fingerprintPluginAutoEnableEnvMock = vi.fn((env: NodeJS.ProcessEnv) => JSON.stringify(env));
const resolveAgentWorkspaceDirMock = vi.fn<
typeof import("../../agents/agent-scope.js").resolveAgentWorkspaceDir
>(() => "/resolved-workspace");
@@ -31,6 +35,7 @@ let resolvePluginRuntimeLoadContext: typeof import("./load-context.js").resolveP
let buildPluginRuntimeLoadOptions: typeof import("./load-context.js").buildPluginRuntimeLoadOptions;
let clearRuntimeConfigSnapshot: typeof import("../../config/runtime-snapshot.js").clearRuntimeConfigSnapshot;
let setRuntimeConfigSnapshot: typeof import("../../config/runtime-snapshot.js").setRuntimeConfigSnapshot;
let clearPluginMetadataLifecycleCaches: typeof import("../plugin-metadata-lifecycle.js").clearPluginMetadataLifecycleCaches;
vi.mock("../../config/config.js", () => ({
getRuntimeConfig: loadConfigMock,
@@ -41,6 +46,11 @@ vi.mock("../../config/plugin-auto-enable.js", () => ({
applyPluginAutoEnable: applyPluginAutoEnableMock,
}));
vi.mock("../../config/plugin-auto-enable.apply.js", () => ({
fingerprintPluginAutoEnableConfig: fingerprintPluginAutoEnableConfigMock,
fingerprintPluginAutoEnableEnv: fingerprintPluginAutoEnableEnvMock,
}));
vi.mock("../../agents/agent-scope.js", () => ({
resolveAgentWorkspaceDir: resolveAgentWorkspaceDirMock,
resolveDefaultAgentId: resolveDefaultAgentIdMock,
@@ -62,10 +72,13 @@ describe("resolvePluginRuntimeLoadContext", () => {
vi.resetModules();
({ clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } =
await import("../../config/runtime-snapshot.js"));
({ clearPluginMetadataLifecycleCaches } = await import("../plugin-metadata-lifecycle.js"));
({ resolvePluginRuntimeLoadContext, buildPluginRuntimeLoadOptions } =
await import("./load-context.js"));
loadConfigMock.mockReset();
applyPluginAutoEnableMock.mockReset();
fingerprintPluginAutoEnableConfigMock.mockClear();
fingerprintPluginAutoEnableEnvMock.mockClear();
getCurrentPluginMetadataSnapshotMock.mockReset();
getCurrentPluginMetadataSnapshotMock.mockReturnValue(undefined);
isPluginMetadataSnapshotCompatibleMock.mockReset();
@@ -204,29 +217,34 @@ describe("resolvePluginRuntimeLoadContext", () => {
expect(applyPluginAutoEnableMock).toHaveBeenCalledTimes(3);
});
it("invalidates auto-enable results when config or process env mutates in place", () => {
const rawConfig: OpenClawConfig = { plugins: {} };
it("uses reference fast paths, content fingerprints, and lifecycle invalidation", () => {
const firstConfig: OpenClawConfig = { plugins: {} };
const env = process.env;
const envKey = "OPENCLAW_TEST_PLUGIN_AUTO_ENABLE_FINGERPRINT";
const previousEnvValue = env[envKey];
delete env[envKey];
const first = resolvePluginRuntimeLoadContext({ config: firstConfig, env });
try {
resolvePluginRuntimeLoadContext({ config: rawConfig, env });
resolvePluginRuntimeLoadContext({ config: rawConfig, env });
rawConfig.plugins = { entries: { demo: { enabled: true } } };
resolvePluginRuntimeLoadContext({ config: rawConfig, env });
env[envKey] = "changed";
resolvePluginRuntimeLoadContext({ config: rawConfig, env });
expect(applyPluginAutoEnableMock).toHaveBeenCalledTimes(3);
} finally {
if (previousEnvValue === undefined) {
delete env[envKey];
} else {
env[envKey] = previousEnvValue;
}
for (let index = 0; index < 20; index += 1) {
expect(resolvePluginRuntimeLoadContext({ config: firstConfig, env }).config).toBe(
first.config,
);
}
expect(applyPluginAutoEnableMock).toHaveBeenCalledTimes(1);
expect(fingerprintPluginAutoEnableConfigMock).toHaveBeenCalledTimes(1);
expect(fingerprintPluginAutoEnableEnvMock).toHaveBeenCalledTimes(1);
const replacementConfig: OpenClawConfig = { plugins: {} };
expect(resolvePluginRuntimeLoadContext({ config: replacementConfig, env }).config).toBe(
first.config,
);
expect(applyPluginAutoEnableMock).toHaveBeenCalledTimes(1);
expect(fingerprintPluginAutoEnableConfigMock).toHaveBeenCalledTimes(2);
expect(fingerprintPluginAutoEnableEnvMock).toHaveBeenCalledTimes(1);
clearPluginMetadataLifecycleCaches();
resolvePluginRuntimeLoadContext({ config: replacementConfig, env });
expect(applyPluginAutoEnableMock).toHaveBeenCalledTimes(2);
expect(fingerprintPluginAutoEnableConfigMock).toHaveBeenCalledTimes(3);
expect(fingerprintPluginAutoEnableEnvMock).toHaveBeenCalledTimes(2);
});
it("threads install records from the metadata snapshot into the context and load options", () => {
+28 -12
View File
@@ -69,23 +69,37 @@ function applyCurrentPluginAutoEnable(params: {
discovery: params.snapshot?.discovery,
});
}
// Gateway plugin metadata and config are replacement snapshots. Reuse only while
// mutable config/env content still matches; reload/close lifecycle clears the slot.
const workspaceDir = params.snapshot.workspaceDir ?? params.workspaceDir;
const autoEnableConfigFingerprint = fingerprintPluginAutoEnableConfig(params.config);
const autoEnableEnvFingerprint = fingerprintPluginAutoEnableEnv(params.env);
const cached = currentAutoEnableCache;
if (
cached?.config === params.config &&
cached.env === params.env &&
cached.autoEnableConfigFingerprint === autoEnableConfigFingerprint &&
cached.autoEnableEnvFingerprint === autoEnableEnvFingerprint &&
const metadataMatches =
cached !== undefined &&
cached.metadataConfigFingerprint === params.snapshot.configFingerprint &&
cached.policyHash === params.snapshot.policyHash &&
cached.workspaceDir === workspaceDir &&
samePluginIds(cached.pluginIds, params.snapshot.pluginIds)
) {
return cached.result;
samePluginIds(cached.pluginIds, params.snapshot.pluginIds);
if (metadataMatches) {
if (cached.config === params.config && cached.env === params.env) {
return cached.result;
}
const autoEnableConfigFingerprint =
cached.config === params.config
? cached.autoEnableConfigFingerprint
: fingerprintPluginAutoEnableConfig(params.config);
const autoEnableEnvFingerprint =
cached.env === params.env
? cached.autoEnableEnvFingerprint
: fingerprintPluginAutoEnableEnv(params.env);
if (
cached.autoEnableConfigFingerprint === autoEnableConfigFingerprint &&
cached.autoEnableEnvFingerprint === autoEnableEnvFingerprint
) {
currentAutoEnableCache = {
...cached,
config: params.config,
env: params.env,
};
return cached.result;
}
}
const result = applyPluginAutoEnable({
config: params.config,
@@ -93,6 +107,8 @@ function applyCurrentPluginAutoEnable(params: {
manifestRegistry: params.manifestRegistry,
discovery: params.snapshot.discovery,
});
const autoEnableConfigFingerprint = fingerprintPluginAutoEnableConfig(params.config);
const autoEnableEnvFingerprint = fingerprintPluginAutoEnableEnv(params.env);
currentAutoEnableCache = {
config: params.config,
env: params.env,