refactor: simplify plugin cache boundaries

This commit is contained in:
Peter Steinberger
2026-05-02 06:00:43 +01:00
parent 9e9df8f2c5
commit 9989512a37
10 changed files with 219 additions and 135 deletions

View File

@@ -5,15 +5,15 @@ import {
withBundledPluginEnablementCompat,
withBundledPluginVitestCompat,
} from "./bundled-compat.js";
import {
resolveConfigScopedRuntimeCacheValue,
type ConfigScopedRuntimeCache,
} from "./config-scoped-runtime-cache.js";
import {
resolvePluginRegistryLoadCacheKey,
resolveRuntimePluginRegistry,
type PluginLoadOptions,
} from "./loader.js";
import {
resolveConfigScopedRuntimeCacheValue,
type ConfigScopedRuntimeCache,
} from "./plugin-cache-primitives.js";
import { loadPluginManifestRegistryForPluginRegistry } from "./plugin-registry.js";
import type { PluginRegistry } from "./registry-types.js";

View File

@@ -1,4 +0,0 @@
export {
resolveConfigScopedRuntimeCacheValue,
type ConfigScopedRuntimeCache,
} from "./plugin-cache-primitives.js";

View File

@@ -5,9 +5,21 @@ import {
setCurrentPluginMetadataSnapshotState,
} from "./current-plugin-metadata-state.js";
import { resolveInstalledPluginIndexPolicyHash } from "./installed-plugin-index-policy.js";
import { resolvePluginMetadataSnapshotConfigFingerprint } from "./plugin-metadata-config-fingerprint.js";
import {
resolvePluginControlPlaneFingerprint,
type ResolvePluginControlPlaneContextParams,
} from "./plugin-control-plane-context.js";
import type { PluginMetadataSnapshot } from "./plugin-metadata-snapshot.types.js";
export { resolvePluginMetadataSnapshotConfigFingerprint } from "./plugin-metadata-config-fingerprint.js";
export function resolvePluginMetadataSnapshotConfigFingerprint(
config?: OpenClawConfig,
options: Omit<ResolvePluginControlPlaneContextParams, "config"> = {},
): string {
return resolvePluginControlPlaneFingerprint({
config,
...options,
});
}
// Single-slot Gateway-owned handoff. Replace or clear it at lifecycle boundaries;
// never accumulate historical metadata snapshots here.

View File

@@ -1,9 +1,50 @@
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
PluginLruCache,
resolveConfigScopedRuntimeCacheValue,
type ConfigScopedRuntimeCache,
} from "./config-scoped-runtime-cache.js";
} from "./plugin-cache-primitives.js";
describe("PluginLruCache", () => {
it("evicts the least recently used entry", () => {
const cache = new PluginLruCache<string>(2);
cache.set("", "empty");
cache.set("a", "alpha");
cache.set("b", "bravo");
expect(cache.get("a")).toBe("alpha");
cache.set("c", "charlie");
expect(cache.get("b")).toBeUndefined();
expect(cache.get("a")).toBe("alpha");
expect(cache.get("c")).toBe("charlie");
});
it("returns hit state for cached null values", () => {
const cache = new PluginLruCache<string | null>(2);
cache.set("missing", null);
expect(cache.getResult("missing")).toEqual({ hit: true, value: null });
expect(cache.getResult("unknown")).toEqual({ hit: false });
});
it("resizes and falls back to the default max entry count", () => {
const cache = new PluginLruCache<string>(2);
cache.setMaxEntriesForTest(1.9);
cache.set("a", "alpha");
cache.set("b", "bravo");
expect(cache.maxEntries).toBe(1);
expect(cache.size).toBe(1);
expect(cache.get("a")).toBeUndefined();
cache.setMaxEntriesForTest();
expect(cache.maxEntries).toBe(2);
});
});
describe("resolveConfigScopedRuntimeCacheValue", () => {
it("caches values by config object and key", () => {

View File

@@ -1,42 +0,0 @@
import { describe, expect, it } from "vitest";
import { PluginLruCache } from "./plugin-cache-primitives.js";
describe("PluginLruCache", () => {
it("evicts the least recently used entry", () => {
const cache = new PluginLruCache<string>(2);
cache.set("", "empty");
cache.set("a", "alpha");
cache.set("b", "bravo");
expect(cache.get("a")).toBe("alpha");
cache.set("c", "charlie");
expect(cache.get("b")).toBeUndefined();
expect(cache.get("a")).toBe("alpha");
expect(cache.get("c")).toBe("charlie");
});
it("returns hit state for cached null values", () => {
const cache = new PluginLruCache<string | null>(2);
cache.set("missing", null);
expect(cache.getResult("missing")).toEqual({ hit: true, value: null });
expect(cache.getResult("unknown")).toEqual({ hit: false });
});
it("resizes and falls back to the default max entry count", () => {
const cache = new PluginLruCache<string>(2);
cache.setMaxEntriesForTest(1.9);
cache.set("a", "alpha");
cache.set("b", "bravo");
expect(cache.maxEntries).toBe(1);
expect(cache.size).toBe(1);
expect(cache.get("a")).toBeUndefined();
cache.setMaxEntriesForTest();
expect(cache.maxEntries).toBe(2);
});
});

View File

@@ -1,34 +0,0 @@
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { InstalledPluginIndex } from "./installed-plugin-index.js";
import { resolvePluginControlPlaneFingerprint } from "./plugin-control-plane-context.js";
export {
fingerprintPluginControlPlaneContext,
fingerprintPluginDiscoveryContext,
resolvePluginControlPlaneContext,
resolvePluginControlPlaneFingerprint,
resolvePluginDiscoveryContext,
resolvePluginDiscoveryFingerprint,
} from "./plugin-control-plane-context.js";
export function resolvePluginMetadataSnapshotConfigFingerprint(
config: OpenClawConfig | undefined,
options: {
activationFingerprint?: string;
env?: NodeJS.ProcessEnv;
index?: InstalledPluginIndex;
inventoryFingerprint?: string;
policyHash?: string;
workspaceDir?: string;
} = {},
): string {
return resolvePluginControlPlaneFingerprint({
config,
activationFingerprint: options.activationFingerprint,
env: options.env,
index: options.index,
inventoryFingerprint: options.inventoryFingerprint,
policyHash: options.policyHash,
workspaceDir: options.workspaceDir,
});
}

View File

@@ -7,7 +7,7 @@ import {
resolveInstalledManifestRegistryIndexFingerprint,
} from "./manifest-registry-installed.js";
import type { PluginManifestRecord } from "./manifest-registry.js";
import { resolvePluginMetadataSnapshotConfigFingerprint } from "./plugin-metadata-config-fingerprint.js";
import { resolvePluginControlPlaneFingerprint } from "./plugin-control-plane-context.js";
import type {
LoadPluginMetadataSnapshotParams,
PluginMetadataSnapshot,
@@ -23,6 +23,15 @@ export type {
PluginMetadataSnapshotRegistryDiagnostic,
} from "./plugin-metadata-snapshot.types.js";
function resolvePluginMetadataSnapshotConfigFingerprint(
params: Pick<LoadPluginMetadataSnapshotParams, "config" | "env" | "workspaceDir"> & {
index?: InstalledPluginIndex;
policyHash?: string;
},
): string {
return resolvePluginControlPlaneFingerprint(params);
}
function indexesMatch(
left: InstalledPluginIndex | undefined,
right: InstalledPluginIndex | undefined,
@@ -51,7 +60,8 @@ export function isPluginMetadataSnapshotCompatible(params: {
params.snapshot.policyHash === resolveInstalledPluginIndexPolicyHash(params.config) &&
(!params.snapshot.configFingerprint ||
params.snapshot.configFingerprint ===
resolvePluginMetadataSnapshotConfigFingerprint(params.config, {
resolvePluginMetadataSnapshotConfigFingerprint({
config: params.config,
env,
index: params.index ?? params.snapshot.index,
policyHash: params.snapshot.policyHash,
@@ -185,7 +195,8 @@ function loadPluginMetadataSnapshotImpl(
return {
policyHash: index.policyHash,
configFingerprint: resolvePluginMetadataSnapshotConfigFingerprint(params.config, {
configFingerprint: resolvePluginMetadataSnapshotConfigFingerprint({
config: params.config,
env: params.env,
index,
policyHash: index.policyHash,

View File

@@ -26,6 +26,63 @@ async function loadCachedPluginModuleLoader(scope: string) {
}
describe("getCachedPluginModuleLoader", () => {
it("resolves deterministic cache entries for equivalent alias maps", async () => {
const { resolvePluginModuleLoaderCacheEntry } = await importFreshModule<
typeof import("./plugin-module-loader-cache.js")
>(import.meta.url, "./plugin-module-loader-cache.js?scope=cache-entry-alias-order");
const first = resolvePluginModuleLoaderCacheEntry({
modulePath: "/repo/extensions/demo/index.ts",
importerUrl: "file:///repo/src/plugins/loader.ts",
loaderFilename: "/repo/src/plugins/loader.ts",
aliasMap: {
alpha: "/repo/alpha.js",
zeta: "/repo/zeta.js",
},
tryNative: false,
});
const second = resolvePluginModuleLoaderCacheEntry({
modulePath: "/repo/extensions/demo/index.ts",
importerUrl: "file:///repo/src/plugins/loader.ts",
loaderFilename: "/repo/src/plugins/loader.ts",
aliasMap: {
zeta: "/repo/zeta.js",
alpha: "/repo/alpha.js",
},
tryNative: false,
});
expect(second.cacheKey).toBe(first.cacheKey);
expect(second.scopedCacheKey).toBe(first.scopedCacheKey);
expect(first.loaderFilename).toBe("/repo/src/plugins/loader.ts");
});
it("keeps explicit shared cache scope keys independent of loader options", async () => {
const { resolvePluginModuleLoaderCacheEntry } = await importFreshModule<
typeof import("./plugin-module-loader-cache.js")
>(import.meta.url, "./plugin-module-loader-cache.js?scope=cache-entry-shared-scope");
const first = resolvePluginModuleLoaderCacheEntry({
modulePath: "/repo/dist/extensions/demo-a/api.js",
importerUrl: "file:///repo/src/plugins/public-surface-loader.ts",
loaderFilename: "/repo/src/plugins/public-surface-loader.ts",
aliasMap: { demo: "/repo/demo-a.js" },
tryNative: true,
sharedCacheScopeKey: "bundled:native",
});
const second = resolvePluginModuleLoaderCacheEntry({
modulePath: "/repo/dist/extensions/demo-b/api.js",
importerUrl: "file:///repo/src/plugins/public-surface-loader.ts",
loaderFilename: "/repo/src/plugins/public-surface-loader.ts",
aliasMap: { demo: "/repo/demo-b.js" },
tryNative: false,
sharedCacheScopeKey: "bundled:native",
});
expect(first.cacheKey).not.toBe(second.cacheKey);
expect(first.scopedCacheKey).toBe(second.scopedCacheKey);
});
it("reuses cached loaders for the same module config and filename", async () => {
const { createJiti, getCachedPluginModuleLoader } =
await loadCachedPluginModuleLoader("cached-loader");

View File

@@ -15,6 +15,25 @@ export type PluginModuleLoaderCache = Pick<
PluginLruCache<PluginModuleLoader>,
"clear" | "get" | "set" | "size"
>;
export type ResolvePluginModuleLoaderCacheEntryParams = {
modulePath: string;
importerUrl: string;
argvEntry?: string;
preferBuiltDist?: boolean;
loaderFilename?: string;
aliasMap?: Record<string, string>;
tryNative?: boolean;
pluginSdkResolution?: PluginSdkResolutionPreference;
cacheScopeKey?: string;
sharedCacheScopeKey?: string;
};
export type PluginModuleLoaderCacheEntry = {
loaderFilename: string;
aliasMap: Record<string, string>;
tryNative: boolean;
cacheKey: string;
scopedCacheKey: string;
};
const DEFAULT_PLUGIN_MODULE_LOADER_CACHE_ENTRIES = 128;
@@ -24,34 +43,27 @@ export function createPluginModuleLoaderCache(
return new PluginLruCache<PluginModuleLoader>(maxEntries);
}
export function getCachedPluginModuleLoader(params: {
cache: PluginModuleLoaderCache;
modulePath: string;
importerUrl: string;
argvEntry?: string;
preferBuiltDist?: boolean;
loaderFilename?: string;
createLoader?: PluginModuleLoaderFactory;
aliasMap?: Record<string, string>;
tryNative?: boolean;
pluginSdkResolution?: PluginSdkResolutionPreference;
cacheScopeKey?: string;
sharedCacheScopeKey?: string;
}): PluginModuleLoader {
function resolveDefaultPluginModuleLoaderConfig(
params: ResolvePluginModuleLoaderCacheEntryParams,
): ReturnType<typeof resolvePluginLoaderModuleConfig> {
return resolvePluginLoaderModuleConfig({
modulePath: params.modulePath,
argv1: params.argvEntry ?? process.argv[1],
moduleUrl: params.importerUrl,
...(params.preferBuiltDist ? { preferBuiltDist: true } : {}),
...(params.pluginSdkResolution ? { pluginSdkResolution: params.pluginSdkResolution } : {}),
});
}
export function resolvePluginModuleLoaderCacheEntry(
params: ResolvePluginModuleLoaderCacheEntryParams,
): PluginModuleLoaderCacheEntry {
const loaderFilename = toSafeImportPath(params.loaderFilename ?? params.modulePath);
const hasAliasOverride = Boolean(params.aliasMap);
const hasTryNativeOverride = typeof params.tryNative === "boolean";
const defaultConfig =
hasAliasOverride || hasTryNativeOverride
? resolvePluginLoaderModuleConfig({
modulePath: params.modulePath,
argv1: params.argvEntry ?? process.argv[1],
moduleUrl: params.importerUrl,
...(params.preferBuiltDist ? { preferBuiltDist: true } : {}),
...(params.pluginSdkResolution
? { pluginSdkResolution: params.pluginSdkResolution }
: {}),
})
? resolveDefaultPluginModuleLoaderConfig(params)
: null;
const canReuseDefaultCacheKey =
defaultConfig !== null &&
@@ -63,13 +75,7 @@ export function getCachedPluginModuleLoader(params: {
aliasMap: params.aliasMap ?? defaultConfig.aliasMap,
cacheKey: canReuseDefaultCacheKey ? defaultConfig.cacheKey : undefined,
}
: resolvePluginLoaderModuleConfig({
modulePath: params.modulePath,
argv1: params.argvEntry ?? process.argv[1],
moduleUrl: params.importerUrl,
...(params.preferBuiltDist ? { preferBuiltDist: true } : {}),
...(params.pluginSdkResolution ? { pluginSdkResolution: params.pluginSdkResolution } : {}),
});
: resolveDefaultPluginModuleLoaderConfig(params);
const { tryNative, aliasMap } = resolved;
const cacheKey =
resolved.cacheKey ??
@@ -81,18 +87,29 @@ export function getCachedPluginModuleLoader(params: {
params.sharedCacheScopeKey ??
(params.cacheScopeKey ? `${params.cacheScopeKey}::${cacheKey}` : cacheKey)
}`;
const cached = params.cache.get(scopedCacheKey);
if (cached) {
return cached;
}
return {
loaderFilename,
aliasMap,
tryNative,
cacheKey,
scopedCacheKey,
};
}
function createLazySourceTransformLoader(params: {
loaderFilename: string;
aliasMap: Record<string, string>;
tryNative: boolean;
createLoader?: PluginModuleLoaderFactory;
}): () => PluginModuleLoader {
let loadWithSourceTransform: PluginModuleLoader | undefined;
const getLoadWithSourceTransform = (): PluginModuleLoader => {
return () => {
if (loadWithSourceTransform) {
return loadWithSourceTransform;
}
const jitiLoader = (params.createLoader ?? createJiti)(loaderFilename, {
...buildPluginLoaderJitiOptions(aliasMap),
tryNative,
const jitiLoader = (params.createLoader ?? createJiti)(params.loaderFilename, {
...buildPluginLoaderJitiOptions(params.aliasMap),
tryNative: params.tryNative,
});
loadWithSourceTransform = new Proxy(jitiLoader, {
apply(target, thisArg, argArray) {
@@ -108,18 +125,25 @@ export function getCachedPluginModuleLoader(params: {
});
return loadWithSourceTransform;
};
}
function createPluginModuleLoader(params: {
loaderFilename: string;
aliasMap: Record<string, string>;
tryNative: boolean;
createLoader?: PluginModuleLoaderFactory;
}): PluginModuleLoader {
const getLoadWithSourceTransform = createLazySourceTransformLoader(params);
// When the caller has explicitly opted out of native loading (for example
// `bundled-capability-runtime` in Vitest+dist mode, which depends on
// jiti's alias rewriting to surface a narrow SDK slice), route every
// target through jiti so those alias rewrites still apply.
if (!tryNative) {
const loader = ((target: string, ...rest: unknown[]) =>
if (!params.tryNative) {
return ((target: string, ...rest: unknown[]) =>
(getLoadWithSourceTransform() as (t: string, ...a: unknown[]) => unknown)(
target,
...rest,
)) as PluginModuleLoader;
params.cache.set(scopedCacheKey, loader);
return loader;
}
// Otherwise prefer native require() for already-compiled JS artifacts
// (the bundled plugin public surfaces shipped in dist/). jiti's transform
@@ -128,7 +152,7 @@ export function getCachedPluginModuleLoader(params: {
// for TS / TSX sources and for the small set of require(esm) /
// async-module fallbacks `tryNativeRequireJavaScriptModule` declines to
// handle.
const loader = ((target: string, ...rest: unknown[]) => {
return ((target: string, ...rest: unknown[]) => {
const native = tryNativeRequireJavaScriptModule(target, { allowWindows: true });
if (native.ok) {
return native.moduleExport;
@@ -138,7 +162,26 @@ export function getCachedPluginModuleLoader(params: {
...rest,
);
}) as PluginModuleLoader;
params.cache.set(scopedCacheKey, loader);
}
export function getCachedPluginModuleLoader(
params: ResolvePluginModuleLoaderCacheEntryParams & {
cache: PluginModuleLoaderCache;
createLoader?: PluginModuleLoaderFactory;
},
): PluginModuleLoader {
const cacheEntry = resolvePluginModuleLoaderCacheEntry(params);
const cached = params.cache.get(cacheEntry.scopedCacheKey);
if (cached) {
return cached;
}
const loader = createPluginModuleLoader({
loaderFilename: cacheEntry.loaderFilename,
aliasMap: cacheEntry.aliasMap,
tryNative: cacheEntry.tryNative,
...(params.createLoader ? { createLoader: params.createLoader } : {}),
});
params.cache.set(cacheEntry.scopedCacheKey, loader);
return loader;
}

View File

@@ -4,7 +4,7 @@ import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
import {
resolveConfigScopedRuntimeCacheValue,
type ConfigScopedRuntimeCache,
} from "./config-scoped-runtime-cache.js";
} from "./plugin-cache-primitives.js";
import { resolvePluginControlPlaneFingerprint } from "./plugin-control-plane-context.js";
import { resolveProviderConfigApiOwnerHint } from "./provider-config-owner.js";
import { isPluginProvidersLoadInFlight, resolvePluginProviders } from "./providers.runtime.js";