mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
perf(plugins): memoize auto-enable fingerprints (#118334)
This commit is contained in:
committed by
GitHub
parent
357087b9c7
commit
74c4d1cc7e
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user