mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
perf(plugins): memoize channel catalog discovery (#114324)
This commit is contained in:
committed by
GitHub
parent
885121b1f3
commit
ccd6845e4d
@@ -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();
|
||||
|
||||
@@ -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({});
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user