perf(plugins): memoize channel catalog discovery (#114324)

This commit is contained in:
Peter Steinberger
2026-07-27 01:29:21 -04:00
committed by GitHub
parent 885121b1f3
commit ccd6845e4d
3 changed files with 188 additions and 32 deletions
+2
View File
@@ -28,6 +28,7 @@ import {
loadInstalledPluginIndexInstallRecords,
loadInstalledPluginIndexInstallRecordsSync,
} from "../plugins/installed-plugin-index-records.js";
import { clearPluginMetadataLifecycleCaches } from "../plugins/plugin-metadata-lifecycle.js";
import { bumpSkillsSnapshotVersion } from "../skills/runtime/refresh-state.js";
import { createConfigAppliedRevisionTracker } from "./config-applied-revision.js";
import { diffConfigPaths, diffGatewayReloadPaths } from "./config-diff.js";
@@ -1249,6 +1250,7 @@ export function startGatewayConfigReloader(opts: {
// The signal carries a metadata change while config bytes stay identical.
// Clear both metadata and config-echo caches before scheduling the shared diff path.
clearLoadInstalledPluginIndexInstallRecordsCache();
clearPluginMetadataLifecycleCaches();
startupInternalWriteHash = null;
lastAppliedWriteHash = null;
scheduleExternalRefresh();
+52 -1
View File
@@ -34,6 +34,7 @@ async function loadWithMocks(params: {
loadRecords?: (env: NodeJS.ProcessEnv | undefined) => Record<string, PluginInstallRecord>;
}): Promise<{
module: typeof import("./channel-catalog-registry.js");
lifecycle: typeof import("./plugin-metadata-lifecycle.js");
discoverSpy: ReturnType<typeof vi.fn>;
loadRecordsSpy: ReturnType<typeof vi.fn>;
}> {
@@ -51,7 +52,8 @@ async function loadWithMocks(params: {
import.meta.url,
`./channel-catalog-registry.js?case=${++loadCase}`,
);
return { module, discoverSpy, loadRecordsSpy };
const lifecycle = await import("./plugin-metadata-lifecycle.js");
return { module, lifecycle, discoverSpy, loadRecordsSpy };
}
function firstDiscoverOptions(discoverSpy: ReturnType<typeof vi.fn>): Record<string, unknown> {
@@ -91,6 +93,55 @@ function createChannelCandidate(params: {
}
describe("listChannelCatalogEntries", () => {
it("reuses one discovery result for repeated calls in the same scope", async () => {
const { module, discoverSpy, loadRecordsSpy } = await loadWithMocks({});
module.listChannelCatalogEntries({ env: ENV });
module.listChannelCatalogEntries({ env: ENV });
expect(discoverSpy).toHaveBeenCalledTimes(1);
expect(loadRecordsSpy).toHaveBeenCalledTimes(1);
});
it("clears the discovery memo through the plugin metadata lifecycle owner", async () => {
const { module, lifecycle, discoverSpy } = await loadWithMocks({});
module.listChannelCatalogEntries({ origin: "bundled", env: ENV });
lifecycle.clearPluginMetadataLifecycleCaches();
module.listChannelCatalogEntries({ origin: "bundled", env: ENV });
expect(discoverSpy).toHaveBeenCalledTimes(2);
});
it("does not share discovery across differing input scopes", async () => {
const { module, discoverSpy } = await loadWithMocks({});
const installRecords = {};
const baseline = {
env: ENV,
workspaceDir: "/tmp/workspace-a",
extraPaths: ["/tmp/plugins/a"],
installRecords,
};
const otherInstallRecords: Record<string, PluginInstallRecord> = {};
module.listChannelCatalogEntries(baseline);
module.listChannelCatalogEntries({ ...baseline, workspaceDir: "/tmp/workspace-b" });
module.listChannelCatalogEntries({
...baseline,
env: { HOME: "/tmp/openclaw-other-home" },
});
module.listChannelCatalogEntries({ ...baseline, extraPaths: ["/tmp/plugins/b"] });
module.listChannelCatalogEntries({ ...baseline, installRecords: otherInstallRecords });
module.listChannelCatalogEntries({ ...baseline, installRecords: otherInstallRecords });
otherInstallRecords.telegram = {
source: "npm",
spec: "@openclaw/telegram@1.0.0",
} as PluginInstallRecord;
module.listChannelCatalogEntries({ ...baseline, installRecords: otherInstallRecords });
expect(discoverSpy).toHaveBeenCalledTimes(6);
});
it("forwards lazily loaded install records to discovery when origin is unspecified", async () => {
const { module, discoverSpy, loadRecordsSpy } = await loadWithMocks({});
+134 -31
View File
@@ -1,9 +1,13 @@
// Maintains channel catalog entries advertised by plugins.
import { normalizeOptionalString as resolveOptionalString } from "@openclaw/normalization-core/string-coerce";
import { resolveIsNixMode } from "../config/paths.js";
import type { PluginInstallRecord } from "../config/types.plugins.js";
import { resolveCompatibilityHostVersion } from "../version.js";
import { discoverOpenClawPlugins, type PluginDiscoveryResult } from "./discovery.js";
import { loadInstalledPluginIndexInstallRecordsSync } from "./installed-plugin-index-record-reader.js";
import type { PluginPackageChannel, PluginPackageInstall } from "./manifest.js";
import { resolvePluginDiscoveryContext } from "./plugin-control-plane-context.js";
import { registerPluginMetadataProcessMemoLifecycleClear } from "./plugin-metadata-lifecycle.js";
import type { PluginOrigin } from "./plugin-origin.types.js";
export type PluginChannelCatalogEntry = {
@@ -16,31 +20,53 @@ export type PluginChannelCatalogEntry = {
install?: PluginPackageInstall;
};
type ChannelCatalogParams = {
origin?: PluginOrigin;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
extraPaths?: string[];
/**
* Optional override. When omitted and `origin !== "bundled"`, the persisted
* plugin install ledger is loaded synchronously so that npm-installed
* channels stored outside the discovery roots are visible to the catalog.
* Bundled-only callers skip the load to avoid the disk read.
*/
installRecords?: Record<string, PluginInstallRecord>;
discovery?: PluginDiscoveryResult;
};
const defaultInstallRecordsIdentity = Symbol("default-install-records");
const noInstallRecordsIdentity = Symbol("no-install-records");
type ChannelCatalogDiscoveryMemo = {
scopeKey: string;
installRecordsIdentity:
| Record<string, PluginInstallRecord>
| typeof defaultInstallRecordsIdentity
| typeof noInstallRecordsIdentity;
installRecordsFingerprint?: string;
discovery: PluginDiscoveryResult;
};
let channelCatalogDiscoveryMemo: ChannelCatalogDiscoveryMemo | undefined;
function clearChannelCatalogDiscoveryMemo(): void {
channelCatalogDiscoveryMemo = undefined;
}
// Catalog discovery is process-stable. Same-process registry writes and Gateway
// refreshes clear this hook; external install/uninstall/doctor flows are restart-backed,
// so request-time ledger freshness polling is intentionally absent.
registerPluginMetadataProcessMemoLifecycleClear(clearChannelCatalogDiscoveryMemo);
export function listChannelCatalogEntries(
params: {
origin?: PluginOrigin;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
extraPaths?: string[];
/**
* Optional override. When omitted and `origin !== "bundled"`, the persisted
* plugin install ledger is loaded synchronously so that npm-installed
* channels stored outside the discovery roots are visible to the catalog.
* Bundled-only callers skip the load to avoid the disk read.
*/
installRecords?: Record<string, PluginInstallRecord>;
discovery?: PluginDiscoveryResult;
} = {},
params: ChannelCatalogParams = {},
): PluginChannelCatalogEntry[] {
const installRecords = resolveInstallRecords(params);
const discovery =
params.discovery ??
discoverOpenClawPlugins({
workspaceDir: params.workspaceDir,
env: params.env,
extraPaths: params.extraPaths,
...(installRecords && Object.keys(installRecords).length > 0 ? { installRecords } : {}),
});
// Preserve the ledger-read behavior for callers supplying an exact discovery.
if (params.discovery) {
resolveInstallRecords(params);
}
const discovery = params.discovery ?? resolveMemoizedChannelCatalogDiscovery(params);
return discovery.candidates.flatMap((candidate) => {
if (params.origin && candidate.origin !== params.origin) {
return [];
@@ -69,6 +95,78 @@ export function listChannelCatalogEntries(
});
}
function resolveMemoizedChannelCatalogDiscovery(params: ChannelCatalogParams) {
const installRecordsKey = resolveInstallRecordsKey(params);
const scopeKey = resolveChannelCatalogDiscoveryScopeKey(params);
if (
installRecordsKey.cacheable &&
channelCatalogDiscoveryMemo?.scopeKey === scopeKey &&
channelCatalogDiscoveryMemo.installRecordsIdentity === installRecordsKey.identity &&
channelCatalogDiscoveryMemo.installRecordsFingerprint === installRecordsKey.fingerprint
) {
return channelCatalogDiscoveryMemo.discovery;
}
const resolvedInstallRecords = resolveInstallRecords(params);
const discovery = discoverOpenClawPlugins({
workspaceDir: params.workspaceDir,
env: params.env,
extraPaths: params.extraPaths,
...(resolvedInstallRecords.installRecords &&
Object.keys(resolvedInstallRecords.installRecords).length > 0
? { installRecords: resolvedInstallRecords.installRecords }
: {}),
});
if (resolvedInstallRecords.cacheable && installRecordsKey.cacheable) {
channelCatalogDiscoveryMemo = {
scopeKey,
installRecordsIdentity: installRecordsKey.identity,
...(installRecordsKey.fingerprint !== undefined
? { installRecordsFingerprint: installRecordsKey.fingerprint }
: {}),
discovery,
};
}
return discovery;
}
function resolveChannelCatalogDiscoveryScopeKey(params: ChannelCatalogParams): string {
const env = params.env ?? process.env;
return JSON.stringify({
workspaceDir: resolveOptionalString(params.workspaceDir) ?? null,
discovery: resolvePluginDiscoveryContext({
workspaceDir: params.workspaceDir,
env,
loadPaths: params.extraPaths,
}),
compatibilityHostVersion: resolveCompatibilityHostVersion(env),
bundledSourceOverlaysDisabled: env.OPENCLAW_DISABLE_BUNDLED_SOURCE_OVERLAYS ?? "",
nixMode: resolveIsNixMode(env),
});
}
function resolveInstallRecordsKey(params: ChannelCatalogParams): {
identity: ChannelCatalogDiscoveryMemo["installRecordsIdentity"];
fingerprint?: string;
cacheable: boolean;
} {
if (params.installRecords) {
try {
const fingerprint = JSON.stringify(params.installRecords);
return fingerprint === undefined
? { identity: params.installRecords, cacheable: false }
: { identity: params.installRecords, fingerprint, cacheable: true };
} catch {
return { identity: params.installRecords, cacheable: false };
}
}
return {
identity:
params.origin === "bundled" ? noInstallRecordsIdentity : defaultInstallRecordsIdentity,
cacheable: true,
};
}
function resolveChannelCatalogPluginId(
candidate: PluginDiscoveryResult["candidates"][number],
): string | undefined {
@@ -80,20 +178,25 @@ function resolveChannelCatalogPluginId(
);
}
function resolveInstallRecords(params: {
origin?: PluginOrigin;
env?: NodeJS.ProcessEnv;
function resolveInstallRecords(params: ChannelCatalogParams): {
installRecords?: Record<string, PluginInstallRecord>;
}): Record<string, PluginInstallRecord> | undefined {
cacheable: boolean;
} {
if (params.installRecords) {
return params.installRecords;
return { installRecords: params.installRecords, cacheable: true };
}
if (params.origin === "bundled") {
return undefined;
return { cacheable: true };
}
try {
return loadInstalledPluginIndexInstallRecordsSync(params.env ? { env: params.env } : {});
return {
installRecords: loadInstalledPluginIndexInstallRecordsSync(
params.env ? { env: params.env } : {},
),
cacheable: true,
};
} catch {
return undefined;
// Retry transient ledger failures instead of memoizing an incomplete catalog.
return { cacheable: false };
}
}