From 74c4d1cc7e9b3ea09ec225a2d007e661e667fe86 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 2 Aug 2026 18:09:02 -0700 Subject: [PATCH] perf(plugins): memoize auto-enable fingerprints (#118334) --- src/config/plugin-auto-enable.apply.ts | 44 ++++++++++--- src/config/plugin-auto-enable.core.test.ts | 75 ++++++++++++++++++++-- src/plugins/runtime/load-context.test.ts | 58 +++++++++++------ src/plugins/runtime/load-context.ts | 40 ++++++++---- 4 files changed, 171 insertions(+), 46 deletions(-) diff --git a/src/config/plugin-auto-enable.apply.ts b/src/config/plugin-auto-enable.apply.ts index b0167eeb3940..ffc1c891bd6b 100644 --- a/src/config/plugin-auto-enable.apply.ts +++ b/src/config/plugin-auto-enable.apply.ts @@ -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; let sameTurnApplyCache: PluginAutoEnableConfigCache | undefined; let sameTurnApplyCacheClearScheduled = false; +let stableFingerprintMemo = new WeakMap(); +let configFingerprintMemo = new WeakMap(); + +// 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; - 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; + 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); } diff --git a/src/config/plugin-auto-enable.core.test.ts b/src/config/plugin-auto-enable.core.test.ts index d2050f2eee5a..584e47253734 100644 --- a/src/config/plugin-auto-enable.core.test.ts +++ b/src/config/plugin-auto-enable.core.test.ts @@ -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( + {}, + { + 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, diff --git a/src/plugins/runtime/load-context.test.ts b/src/plugins/runtime/load-context.test.ts index 8abcb7c03aec..dd2587baacfd 100644 --- a/src/plugins/runtime/load-context.test.ts +++ b/src/plugins/runtime/load-context.test.ts @@ -5,6 +5,10 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; const loadConfigMock = vi.fn(); const applyPluginAutoEnableMock = vi.fn(); +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", () => { diff --git a/src/plugins/runtime/load-context.ts b/src/plugins/runtime/load-context.ts index 4b25565ddc04..9e237cb903cc 100644 --- a/src/plugins/runtime/load-context.ts +++ b/src/plugins/runtime/load-context.ts @@ -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,