diff --git a/src/plugins/loader-cache-state.ts b/src/plugins/loader-cache-state.ts index 9cd85f94e6d..4714f8ea114 100644 --- a/src/plugins/loader-cache-state.ts +++ b/src/plugins/loader-cache-state.ts @@ -1,3 +1,5 @@ +import { PluginLruCache } from "./plugin-lru-cache.js"; + export class PluginLoadReentryError extends Error { readonly cacheKey: string; @@ -9,27 +11,20 @@ export class PluginLoadReentryError extends Error { } export class PluginLoaderCacheState { - readonly #defaultMaxEntries: number; - #maxEntries: number; - readonly #registryCache = new Map(); + readonly #registryCache: PluginLruCache; readonly #inFlightLoads = new Set(); readonly #openAllowlistWarningCache = new Set(); constructor(defaultMaxEntries: number) { - this.#defaultMaxEntries = Math.max(1, Math.floor(defaultMaxEntries)); - this.#maxEntries = this.#defaultMaxEntries; + this.#registryCache = new PluginLruCache(defaultMaxEntries); } get maxEntries(): number { - return this.#maxEntries; + return this.#registryCache.maxEntries; } setMaxEntriesForTest(value?: number): void { - this.#maxEntries = - typeof value === "number" && Number.isFinite(value) && value > 0 - ? Math.max(1, Math.floor(value)) - : this.#defaultMaxEntries; - this.#evictOldestEntries(); + this.#registryCache.setMaxEntriesForTest(value); } clear(): void { @@ -39,21 +34,11 @@ export class PluginLoaderCacheState { } get(cacheKey: string): T | undefined { - const cached = this.#registryCache.get(cacheKey); - if (!cached) { - return undefined; - } - this.#registryCache.delete(cacheKey); - this.#registryCache.set(cacheKey, cached); - return cached; + return this.#registryCache.get(cacheKey); } set(cacheKey: string, state: T): void { - if (this.#registryCache.has(cacheKey)) { - this.#registryCache.delete(cacheKey); - } this.#registryCache.set(cacheKey, state); - this.#evictOldestEntries(); } isLoadInFlight(cacheKey: string): boolean { @@ -78,14 +63,4 @@ export class PluginLoaderCacheState { recordOpenAllowlistWarning(cacheKey: string): void { this.#openAllowlistWarningCache.add(cacheKey); } - - #evictOldestEntries(): void { - while (this.#registryCache.size > this.#maxEntries) { - const oldestEntry = this.#registryCache.keys().next(); - if (oldestEntry.done) { - break; - } - this.#registryCache.delete(oldestEntry.value); - } - } } diff --git a/src/plugins/plugin-lru-cache.test.ts b/src/plugins/plugin-lru-cache.test.ts new file mode 100644 index 00000000000..e514dcb3dd7 --- /dev/null +++ b/src/plugins/plugin-lru-cache.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { PluginLruCache } from "./plugin-lru-cache.js"; + +describe("PluginLruCache", () => { + it("evicts the least recently used entry", () => { + const cache = new PluginLruCache(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(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(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); + }); +}); diff --git a/src/plugins/plugin-lru-cache.ts b/src/plugins/plugin-lru-cache.ts new file mode 100644 index 00000000000..9037735cb34 --- /dev/null +++ b/src/plugins/plugin-lru-cache.ts @@ -0,0 +1,72 @@ +export type PluginLruCacheResult = { hit: true; value: T } | { hit: false }; + +export class PluginLruCache { + readonly #defaultMaxEntries: number; + #maxEntries: number; + readonly #entries = new Map(); + + 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 { + 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)); +} diff --git a/src/plugins/setup-registry.ts b/src/plugins/setup-registry.ts index ac9ed08ff32..9e4626a60c2 100644 --- a/src/plugins/setup-registry.ts +++ b/src/plugins/setup-registry.ts @@ -7,6 +7,7 @@ import { buildPluginApi } from "./api-builder.js"; import { collectPluginConfigContractMatches } from "./config-contracts.js"; import { getCachedPluginJitiLoader, type PluginJitiLoaderCache } from "./jiti-loader-cache.js"; import type { PluginManifestRecord } from "./manifest-registry.js"; +import { PluginLruCache, type PluginLruCacheResult } from "./plugin-lru-cache.js"; import { loadPluginManifestRegistryForPluginRegistry } from "./plugin-registry.js"; import { resolvePluginCacheInputs } from "./roots.js"; import type { PluginRuntime } from "./runtime/types.js"; @@ -86,20 +87,22 @@ const NOOP_LOGGER: PluginLogger = { const MAX_SETUP_LOOKUP_CACHE_ENTRIES = 128; const jitiLoaders: PluginJitiLoaderCache = new Map(); -const setupRegistryCache = new Map(); -const setupProviderCache = new Map(); -const setupCliBackendCache = new Map(); -let setupLookupCacheEntryCap = MAX_SETUP_LOOKUP_CACHE_ENTRIES; +const setupRegistryCache = new PluginLruCache(MAX_SETUP_LOOKUP_CACHE_ENTRIES); +const setupProviderCache = new PluginLruCache( + MAX_SETUP_LOOKUP_CACHE_ENTRIES, +); +const setupCliBackendCache = new PluginLruCache( + MAX_SETUP_LOOKUP_CACHE_ENTRIES, +); export const __testing = { get maxSetupLookupCacheEntries() { - return setupLookupCacheEntryCap; + return setupRegistryCache.maxEntries; }, setMaxSetupLookupCacheEntriesForTest(value?: number) { - setupLookupCacheEntryCap = - typeof value === "number" && Number.isFinite(value) && value > 0 - ? Math.max(1, Math.floor(value)) - : MAX_SETUP_LOOKUP_CACHE_ENTRIES; + setupRegistryCache.setMaxEntriesForTest(value); + setupProviderCache.setMaxEntriesForTest(value); + setupCliBackendCache.setMaxEntriesForTest(value); }, getCacheSizes() { return { @@ -125,31 +128,12 @@ function getJiti(modulePath: string) { }); } -function getCachedSetupValue( - cache: Map, - key: string, -): { hit: true; value: T } | { hit: false } { - if (!cache.has(key)) { - return { hit: false }; - } - const cached = cache.get(key) as T; - cache.delete(key); - cache.set(key, cached); - return { hit: true, value: cached }; +function getCachedSetupValue(cache: PluginLruCache, key: string): PluginLruCacheResult { + return cache.getResult(key); } -function setCachedSetupValue(cache: Map, key: string, value: T): void { - if (cache.has(key)) { - cache.delete(key); - } +function setCachedSetupValue(cache: PluginLruCache, key: string, value: T): void { cache.set(key, value); - while (cache.size > setupLookupCacheEntryCap) { - const oldestKey = cache.keys().next().value; - if (typeof oldestKey !== "string") { - break; - } - cache.delete(oldestKey); - } } function buildSetupRegistryCacheKey(params: {