refactor: centralize plugin cache primitives

This commit is contained in:
Peter Steinberger
2026-05-02 05:01:33 +01:00
parent 20333bd58d
commit f43a184103
19 changed files with 255 additions and 171 deletions

View File

@@ -13,6 +13,7 @@ import type { PluginLoadOptions } from "./loader.js";
import { loadPluginManifestRegistry } from "./manifest-registry.js";
import { unwrapDefaultModuleExport } from "./module-export.js";
import {
createPluginModuleLoaderCache,
getCachedPluginModuleLoader,
type PluginModuleLoaderCache,
} from "./plugin-module-loader-cache.js";
@@ -196,7 +197,7 @@ export function loadBundledCapabilityRuntimeRegistry(params: {
const env = params.env ?? process.env;
const pluginIds = new Set(params.pluginIds);
const registry = createEmptyPluginRegistry();
const moduleLoaders: PluginModuleLoaderCache = new Map();
const moduleLoaders: PluginModuleLoaderCache = createPluginModuleLoaderCache();
const getModuleLoader = (modulePath: string) => {
const tryNative =

View File

@@ -14,6 +14,7 @@ import type {
PluginManifestChannelConfig,
} from "./manifest.js";
import {
createPluginModuleLoaderCache,
getCachedPluginModuleLoader,
type PluginModuleLoaderCache,
} from "./plugin-module-loader-cache.js";
@@ -35,7 +36,7 @@ type ChannelConfigSurface = {
runtime?: ChannelConfigRuntimeSchema;
};
const moduleLoaders: PluginModuleLoaderCache = new Map();
const moduleLoaders: PluginModuleLoaderCache = createPluginModuleLoaderCache();
function isBuiltChannelConfigSchema(value: unknown): value is ChannelConfigSurface {
if (!value || typeof value !== "object") {

View File

@@ -28,4 +28,18 @@ describe("resolveConfigScopedRuntimeCacheValue", () => {
expect(resolveConfigScopedRuntimeCacheValue({ cache, key: "demo", load })).toBe("loaded");
expect(load).toHaveBeenCalledTimes(2);
});
it("caches undefined values by key", () => {
const cache: ConfigScopedRuntimeCache<string | undefined> = new WeakMap();
const config = {} as OpenClawConfig;
const load = vi.fn(() => undefined);
expect(resolveConfigScopedRuntimeCacheValue({ cache, config, key: "missing", load })).toBe(
undefined,
);
expect(resolveConfigScopedRuntimeCacheValue({ cache, config, key: "missing", load })).toBe(
undefined,
);
expect(load).toHaveBeenCalledOnce();
});
});

View File

@@ -1,26 +1,4 @@
import type { OpenClawConfig } from "../config/types.openclaw.js";
export type ConfigScopedRuntimeCache<T> = WeakMap<OpenClawConfig, Map<string, T>>;
export function resolveConfigScopedRuntimeCacheValue<T>(params: {
cache: ConfigScopedRuntimeCache<T>;
config?: OpenClawConfig;
key: string;
load: () => T;
}): T {
if (!params.config) {
return params.load();
}
let configCache = params.cache.get(params.config);
if (!configCache) {
configCache = new Map();
params.cache.set(params.config, configCache);
}
const cached = configCache.get(params.key);
if (cached !== undefined) {
return cached;
}
const loaded = params.load();
configCache.set(params.key, loaded);
return loaded;
}
export {
resolveConfigScopedRuntimeCacheValue,
type ConfigScopedRuntimeCache,
} from "./plugin-cache-primitives.js";

View File

@@ -6,6 +6,7 @@ import type { OpenClawConfig } from "../config/types.js";
import { asNullableRecord } from "../shared/record-coerce.js";
import type { PluginManifestRegistry } from "./manifest-registry.js";
import {
createPluginModuleLoaderCache,
getCachedPluginModuleLoader,
type PluginModuleLoaderCache,
} from "./plugin-module-loader-cache.js";
@@ -39,7 +40,7 @@ type PluginDoctorContractEntry = {
type PluginManifestRegistryRecord = PluginManifestRegistry["plugins"][number];
const moduleLoaders: PluginModuleLoaderCache = new Map();
const moduleLoaders: PluginModuleLoaderCache = createPluginModuleLoaderCache();
function loadPluginDoctorContractModule(modulePath: string): PluginDoctorContractModule {
return getCachedPluginModuleLoader({

View File

@@ -1,4 +1,4 @@
import { PluginLruCache } from "./plugin-lru-cache.js";
import { PluginLruCache } from "./plugin-cache-primitives.js";
export class PluginLoadReentryError extends Error {
readonly cacheKey: string;

View File

@@ -108,6 +108,7 @@ import {
} from "./plugin-control-plane-context.js";
import { withProfile } from "./plugin-load-profile.js";
import {
createPluginModuleLoaderCache,
getCachedPluginSourceModuleLoader,
type PluginModuleLoaderCache,
} from "./plugin-module-loader-cache.js";
@@ -463,7 +464,7 @@ function runPluginRegisterSync(
}
function createPluginModuleLoader(options: Pick<PluginLoadOptions, "pluginSdkResolution">) {
const moduleLoaders: PluginModuleLoaderCache = new Map();
const moduleLoaders: PluginModuleLoaderCache = createPluginModuleLoaderCache();
const loadSourceModule = (modulePath: string) => {
return getCachedPluginSourceModuleLoader({
cache: moduleLoaders,

View File

@@ -28,6 +28,7 @@ import {
type PluginManifestCommandAlias,
} from "./manifest-command-aliases.js";
import type { PluginConfigUiHint } from "./manifest-types.js";
import { createPluginCacheKey, PluginLruCache } from "./plugin-cache-primitives.js";
import type { PluginKind } from "./plugin-kind.types.js";
export const PLUGIN_MANIFEST_FILENAME = "openclaw.plugin.json";
@@ -42,7 +43,9 @@ type PluginManifestLoadCacheEntry = {
ctimeMs: number;
};
const pluginManifestLoadCache = new Map<string, PluginManifestLoadCacheEntry>();
const pluginManifestLoadCache = new PluginLruCache<PluginManifestLoadCacheEntry>(
MAX_PLUGIN_MANIFEST_LOAD_CACHE_ENTRIES,
);
export function clearPluginManifestLoadCache(): void {
pluginManifestLoadCache.clear();
@@ -1227,12 +1230,14 @@ function buildPluginManifestLoadCacheKey(params: {
rootRealPath?: string;
stats: fs.Stats;
}): string {
return JSON.stringify([
path.resolve(params.manifestPath),
params.rejectHardlinks,
params.rootRealPath ?? "",
params.stats.dev,
params.stats.ino,
return createPluginCacheKey([
[
path.resolve(params.manifestPath),
params.rejectHardlinks,
params.rootRealPath ?? "",
params.stats.dev,
params.stats.ino,
],
params.stats.size,
params.stats.mtimeMs,
params.stats.ctimeMs,
@@ -1252,8 +1257,6 @@ function getCachedPluginManifestLoadResult(
) {
return undefined;
}
pluginManifestLoadCache.delete(key);
pluginManifestLoadCache.set(key, entry);
return entry.result;
}
@@ -1268,13 +1271,6 @@ function setCachedPluginManifestLoadResult(
mtimeMs: stats.mtimeMs,
ctimeMs: stats.ctimeMs,
});
if (pluginManifestLoadCache.size <= MAX_PLUGIN_MANIFEST_LOAD_CACHE_ENTRIES) {
return;
}
const oldestKey = pluginManifestLoadCache.keys().next().value;
if (typeof oldestKey === "string") {
pluginManifestLoadCache.delete(oldestKey);
}
}
function parsePluginKind(raw: unknown): PluginKind | PluginKind[] | undefined {

View File

@@ -0,0 +1,118 @@
import type { OpenClawConfig } from "../config/types.openclaw.js";
export type PluginLruCacheResult<T> = { hit: true; value: T } | { hit: false };
export class PluginLruCache<T> {
readonly #defaultMaxEntries: number;
#maxEntries: number;
readonly #entries = new Map<string, T>();
constructor(defaultMaxEntries: number) {
this.#defaultMaxEntries = normalizeMaxEntries(defaultMaxEntries, 1);
this.#maxEntries = this.#defaultMaxEntries;
}
get maxEntries(): number {
return this.#maxEntries;
}
get size(): number {
return this.#entries.size;
}
setMaxEntriesForTest(value?: number): void {
this.#maxEntries =
typeof value === "number"
? normalizeMaxEntries(value, this.#defaultMaxEntries)
: this.#defaultMaxEntries;
this.#evictOldestEntries();
}
clear(): void {
this.#entries.clear();
}
get(cacheKey: string): T | undefined {
const cached = this.getResult(cacheKey);
return cached.hit ? cached.value : undefined;
}
getResult(cacheKey: string): PluginLruCacheResult<T> {
if (!this.#entries.has(cacheKey)) {
return { hit: false };
}
const cached = this.#entries.get(cacheKey) as T;
this.#entries.delete(cacheKey);
this.#entries.set(cacheKey, cached);
return { hit: true, value: cached };
}
set(cacheKey: string, value: T): void {
if (this.#entries.has(cacheKey)) {
this.#entries.delete(cacheKey);
}
this.#entries.set(cacheKey, value);
this.#evictOldestEntries();
}
#evictOldestEntries(): void {
while (this.#entries.size > this.#maxEntries) {
const oldestEntry = this.#entries.keys().next();
if (oldestEntry.done) {
break;
}
this.#entries.delete(oldestEntry.value);
}
}
}
export type ConfigScopedRuntimeCache<T> = WeakMap<OpenClawConfig, Map<string, T>>;
export function resolveConfigScopedRuntimeCacheValue<T>(params: {
cache: ConfigScopedRuntimeCache<T>;
config?: OpenClawConfig;
key: string;
load: () => T;
}): T {
if (!params.config) {
return params.load();
}
let configCache = params.cache.get(params.config);
if (!configCache) {
configCache = new Map();
params.cache.set(params.config, configCache);
}
if (configCache.has(params.key)) {
return configCache.get(params.key) as T;
}
const loaded = params.load();
configCache.set(params.key, loaded);
return loaded;
}
export function createPluginCacheKey(parts: readonly unknown[]): string {
return JSON.stringify(parts);
}
export type FileSystemIdentity = {
path: string;
size: number;
mtimeMs: number;
ctimeMs?: number;
};
export function createFileSystemIdentityCacheKey(identity: FileSystemIdentity): string {
return createPluginCacheKey([
identity.path,
identity.size,
identity.mtimeMs,
identity.ctimeMs ?? null,
]);
}
function normalizeMaxEntries(value: number, fallback: number): number {
if (!Number.isFinite(value) || value <= 0) {
return fallback;
}
return Math.max(1, Math.floor(value));
}

View File

@@ -1,72 +1 @@
export type PluginLruCacheResult<T> = { hit: true; value: T } | { hit: false };
export class PluginLruCache<T> {
readonly #defaultMaxEntries: number;
#maxEntries: number;
readonly #entries = new Map<string, T>();
constructor(defaultMaxEntries: number) {
this.#defaultMaxEntries = normalizeMaxEntries(defaultMaxEntries, 1);
this.#maxEntries = this.#defaultMaxEntries;
}
get maxEntries(): number {
return this.#maxEntries;
}
get size(): number {
return this.#entries.size;
}
setMaxEntriesForTest(value?: number): void {
this.#maxEntries =
typeof value === "number"
? normalizeMaxEntries(value, this.#defaultMaxEntries)
: this.#defaultMaxEntries;
this.#evictOldestEntries();
}
clear(): void {
this.#entries.clear();
}
get(cacheKey: string): T | undefined {
const cached = this.getResult(cacheKey);
return cached.hit ? cached.value : undefined;
}
getResult(cacheKey: string): PluginLruCacheResult<T> {
if (!this.#entries.has(cacheKey)) {
return { hit: false };
}
const cached = this.#entries.get(cacheKey) as T;
this.#entries.delete(cacheKey);
this.#entries.set(cacheKey, cached);
return { hit: true, value: cached };
}
set(cacheKey: string, value: T): void {
if (this.#entries.has(cacheKey)) {
this.#entries.delete(cacheKey);
}
this.#entries.set(cacheKey, value);
this.#evictOldestEntries();
}
#evictOldestEntries(): void {
while (this.#entries.size > this.#maxEntries) {
const oldestEntry = this.#entries.keys().next();
if (oldestEntry.done) {
break;
}
this.#entries.delete(oldestEntry.value);
}
}
}
function normalizeMaxEntries(value: number, fallback: number): number {
if (!Number.isFinite(value) || value <= 0) {
return fallback;
}
return Math.max(1, Math.floor(value));
}
export { PluginLruCache, type PluginLruCacheResult } from "./plugin-cache-primitives.js";

View File

@@ -48,6 +48,39 @@ describe("getCachedPluginModuleLoader", () => {
expect(cache.size).toBe(1);
});
it("creates bounded loader caches", async () => {
const { createJiti, getCachedPluginModuleLoader } =
await loadCachedPluginModuleLoader("bounded-loader-cache");
const { createPluginModuleLoaderCache } = await importFreshModule<
typeof import("./plugin-module-loader-cache.js")
>(import.meta.url, "./plugin-module-loader-cache.js?scope=bounded-loader-cache-factory");
const cache = createPluginModuleLoaderCache(1);
const first = getCachedPluginModuleLoader({
cache,
modulePath: "/repo/extensions/demo-a/index.ts",
importerUrl: "file:///repo/src/plugins/loader.ts",
loaderFilename: "/repo/extensions/demo-a/index.ts",
});
getCachedPluginModuleLoader({
cache,
modulePath: "/repo/extensions/demo-b/index.ts",
importerUrl: "file:///repo/src/plugins/loader.ts",
loaderFilename: "/repo/extensions/demo-b/index.ts",
});
const reloadedFirst = getCachedPluginModuleLoader({
cache,
modulePath: "/repo/extensions/demo-a/index.ts",
importerUrl: "file:///repo/src/plugins/loader.ts",
loaderFilename: "/repo/extensions/demo-a/index.ts",
});
expect(cache.size).toBe(1);
expect(reloadedFirst).not.toBe(first);
reloadedFirst("/repo/extensions/demo-a/index.ts");
expect(createJiti).toHaveBeenCalledOnce();
});
it("keeps loader caches scoped by loader filename and dist preference", async () => {
const { createJiti, getCachedPluginModuleLoader } =
await loadCachedPluginModuleLoader("filename-scope");

View File

@@ -1,6 +1,7 @@
import { createJiti } from "jiti";
import { toSafeImportPath } from "../shared/import-specifier.js";
import { tryNativeRequireJavaScriptModule } from "./native-module-require.js";
import { PluginLruCache } from "./plugin-cache-primitives.js";
import {
buildPluginLoaderJitiOptions,
createPluginLoaderModuleCacheKey,
@@ -10,7 +11,18 @@ import {
export type PluginModuleLoader = ReturnType<typeof createJiti>;
export type PluginModuleLoaderFactory = typeof createJiti;
export type PluginModuleLoaderCache = Map<string, PluginModuleLoader>;
export type PluginModuleLoaderCache = Pick<
PluginLruCache<PluginModuleLoader>,
"clear" | "get" | "set" | "size"
>;
const DEFAULT_PLUGIN_MODULE_LOADER_CACHE_ENTRIES = 128;
export function createPluginModuleLoaderCache(
maxEntries = DEFAULT_PLUGIN_MODULE_LOADER_CACHE_ENTRIES,
): PluginModuleLoaderCache {
return new PluginLruCache<PluginModuleLoader>(maxEntries);
}
export function getCachedPluginModuleLoader(params: {
cache: PluginModuleLoaderCache;

View File

@@ -1,6 +1,10 @@
import { normalizeProviderId } from "../agents/provider-id.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
import {
resolveConfigScopedRuntimeCacheValue,
type ConfigScopedRuntimeCache,
} from "./config-scoped-runtime-cache.js";
import { resolvePluginControlPlaneFingerprint } from "./plugin-control-plane-context.js";
import { resolveProviderConfigApiOwnerHint } from "./provider-config-owner.js";
import { isPluginProvidersLoadInFlight, resolvePluginProviders } from "./providers.runtime.js";
@@ -15,10 +19,7 @@ import type {
ProviderWrapStreamFnContext,
} from "./types.js";
const providerRuntimePluginCache = new WeakMap<
OpenClawConfig,
Map<string, ProviderPlugin | null>
>();
const providerRuntimePluginCache: ConfigScopedRuntimeCache<ProviderPlugin | null> = new WeakMap();
type ProviderRuntimePluginLookupParams = {
provider: string;
@@ -60,20 +61,6 @@ function resolveProviderRuntimePluginCacheKey(params: ProviderRuntimePluginLooku
});
}
function resolveProviderRuntimePluginCache(
params: ProviderRuntimePluginLookupParams,
): Map<string, ProviderPlugin | null> | undefined {
if (!params.config || (params.env && params.env !== process.env)) {
return undefined;
}
let cache = providerRuntimePluginCache.get(params.config);
if (!cache) {
cache = new Map();
providerRuntimePluginCache.set(params.config, cache);
}
return cache;
}
function matchesProviderLiteralId(provider: ProviderPlugin, providerId: string): boolean {
const normalized = normalizeLowercaseStringOrEmpty(providerId);
return !!normalized && normalizeLowercaseStringOrEmpty(provider.id) === normalized;
@@ -119,33 +106,38 @@ export function resolveProviderPluginsForHooks(params: {
export function resolveProviderRuntimePlugin(
params: ProviderRuntimePluginLookupParams,
): ProviderPlugin | undefined {
const cache = resolveProviderRuntimePluginCache(params);
const cacheKey = cache ? resolveProviderRuntimePluginCacheKey(params) : "";
if (cache?.has(cacheKey)) {
return cache.get(cacheKey) ?? undefined;
}
const apiOwnerHint = resolveProviderConfigApiOwnerHint({
provider: params.provider,
config: params.config,
});
const plugin = resolveProviderPluginsForHooks({
config: params.config,
workspaceDir: params.workspaceDir ?? getActivePluginRegistryWorkspaceDirFromState(),
env: params.env,
providerRefs: apiOwnerHint ? [params.provider, apiOwnerHint] : [params.provider],
applyAutoEnable: params.applyAutoEnable,
bundledProviderAllowlistCompat: params.bundledProviderAllowlistCompat,
bundledProviderVitestCompat: params.bundledProviderVitestCompat,
}).find((plugin) => {
if (apiOwnerHint) {
const cacheConfig = params.env && params.env !== process.env ? undefined : params.config;
const plugin = resolveConfigScopedRuntimeCacheValue({
cache: providerRuntimePluginCache,
config: cacheConfig,
key: resolveProviderRuntimePluginCacheKey(params),
load: () => {
const apiOwnerHint = resolveProviderConfigApiOwnerHint({
provider: params.provider,
config: params.config,
});
return (
matchesProviderLiteralId(plugin, params.provider) || matchesProviderId(plugin, apiOwnerHint)
resolveProviderPluginsForHooks({
config: params.config,
workspaceDir: params.workspaceDir ?? getActivePluginRegistryWorkspaceDirFromState(),
env: params.env,
providerRefs: apiOwnerHint ? [params.provider, apiOwnerHint] : [params.provider],
applyAutoEnable: params.applyAutoEnable,
bundledProviderAllowlistCompat: params.bundledProviderAllowlistCompat,
bundledProviderVitestCompat: params.bundledProviderVitestCompat,
}).find((plugin) => {
if (apiOwnerHint) {
return (
matchesProviderLiteralId(plugin, params.provider) ||
matchesProviderId(plugin, apiOwnerHint)
);
}
return matchesProviderId(plugin, params.provider);
}) ?? null
);
}
return matchesProviderId(plugin, params.provider);
},
});
cache?.set(cacheKey, plugin ?? null);
return plugin;
return plugin ?? undefined;
}
export function resolveProviderHookPlugin(params: {

View File

@@ -6,6 +6,7 @@ import { openBoundaryFileSync } from "../infra/boundary-file-read.js";
import { sameFileIdentity } from "../infra/file-identity.js";
import { resolveBundledPluginsDir } from "./bundled-dir.js";
import {
createPluginModuleLoaderCache,
getCachedPluginModuleLoader,
type PluginModuleLoaderCache,
} from "./plugin-module-loader-cache.js";
@@ -26,7 +27,7 @@ const publicSurfaceLocationCache = new Map<
boundaryRoot: string;
} | null
>();
const moduleLoaders: PluginModuleLoaderCache = new Map();
const moduleLoaders: PluginModuleLoaderCache = createPluginModuleLoaderCache();
function isSourceArtifactPath(modulePath: string): boolean {
switch (path.extname(modulePath).toLowerCase()) {

View File

@@ -8,7 +8,10 @@ import {
optimizeImageToJpeg as optimizeImageToJpegImpl,
} from "../../media/web-media.js";
import type { PollInput } from "../../polls.js";
import type { PluginModuleLoaderCache } from "../plugin-module-loader-cache.js";
import {
createPluginModuleLoaderCache,
type PluginModuleLoaderCache,
} from "../plugin-module-loader-cache.js";
import type { PluginOrigin } from "../plugin-origin.types.js";
import {
loadPluginBoundaryModule,
@@ -115,7 +118,7 @@ const webChannelRuntimeModuleCache = new Map<
CachedWebChannelRuntimeModule
>();
const moduleLoaders: PluginModuleLoaderCache = new Map();
const moduleLoaders: PluginModuleLoaderCache = createPluginModuleLoaderCache();
function resolveWebChannelPluginRecord(): WebChannelPluginRecord {
return resolvePluginRuntimeRecordByEntryBaseNames(["light-runtime-api", "runtime-api"], () => {

View File

@@ -3,6 +3,7 @@ import type { ErrorObject, ValidateFunction } from "ajv";
import { appendAllowedValuesHint, summarizeAllowedValues } from "../config/allowed-values.js";
import type { JsonSchemaObject } from "../shared/json-schema.types.js";
import { sanitizeTerminalText } from "../terminal/safe-text.js";
import { PluginLruCache } from "./plugin-cache-primitives.js";
const require = createRequire(import.meta.url);
type AjvLike = {
@@ -52,7 +53,7 @@ type CachedValidator = {
schema: JsonSchemaObject;
};
const schemaCache = new Map<string, CachedValidator>();
const schemaCache = new PluginLruCache<CachedValidator>(512);
function cloneValidationValue<T>(value: T): T {
if (value === undefined || value === null) {

View File

@@ -3,7 +3,7 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js";
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
import { PluginLruCache } from "./plugin-lru-cache.js";
import { PluginLruCache } from "./plugin-cache-primitives.js";
type PluginSdkAliasCandidateKind = "dist" | "src";
export type PluginSdkResolutionPreference = "auto" | "dist" | "src";

View File

@@ -7,6 +7,7 @@ import { buildPluginApi } from "./api-builder.js";
import { collectPluginConfigContractMatches } from "./config-contracts.js";
import type { PluginManifestRecord, PluginManifestRegistry } from "./manifest-registry.js";
import {
createPluginModuleLoaderCache,
getCachedPluginModuleLoader,
type PluginModuleLoaderCache,
} from "./plugin-module-loader-cache.js";
@@ -85,7 +86,7 @@ const NOOP_LOGGER: PluginLogger = {
error() {},
};
const moduleLoaders: PluginModuleLoaderCache = new Map();
const moduleLoaders: PluginModuleLoaderCache = createPluginModuleLoaderCache();
export function clearPluginSetupRegistryCache(): void {
moduleLoaders.clear();

View File

@@ -1,11 +1,13 @@
import { withProfile } from "./plugin-load-profile.js";
import type { PluginModuleLoaderCache } from "./plugin-module-loader-cache.js";
import { getCachedPluginSourceModuleLoader } from "./plugin-module-loader-cache.js";
import {
createPluginModuleLoaderCache,
getCachedPluginSourceModuleLoader,
} from "./plugin-module-loader-cache.js";
export type PluginSourceLoader = (modulePath: string) => unknown;
export function createPluginSourceLoader(): PluginSourceLoader {
const loaders: PluginModuleLoaderCache = new Map();
const loaders = createPluginModuleLoaderCache();
return (modulePath) => {
const sourceLoader = getCachedPluginSourceModuleLoader({
cache: loaders,