diff --git a/src/lib/pricingSync.ts b/src/lib/pricingSync.ts index d6faa3d060..e331439eb6 100644 --- a/src/lib/pricingSync.ts +++ b/src/lib/pricingSync.ts @@ -11,7 +11,7 @@ */ import { getDbInstance } from "./db/core"; -import { invalidateDbCache } from "./db/readCache"; +import { invalidateDbCache, getModelCatalogCacheVersion } from "./db/readCache"; import { backupDbFile } from "./db/backup"; // ─── Types ─────────────────────────────────────────────── @@ -232,10 +232,27 @@ function toRecord(value: unknown): Record { return value && typeof value === "object" ? (value as Record) : {}; } +// getSyncedPricing() re-ran the SELECT + JSON.parse of the pricing_synced +// blobs on every call — resolveCatalogPricing() calls it per model lookup, so +// each call rebuilt a fresh object and findInsensitive() (WeakMap keyed by +// object identity) rebuilt its lowercase index per lookup, emitting hundreds +// of 'case-insensitive key collision' warnings per second and pinning CPU. +// Memoized here, invalidated via the same modelCatalogCacheVersion signal +// saveSyncedPricing/clearSyncedPricing already bump through +// invalidateDbCache("pricing") — mirrors getModelsDevPricing() in +// modelsDevSync.ts. +let pricingMemo: PricingByProvider | null = null; +let pricingMemoVersion = -1; // -1: never equals a real cacheVersion (starts at 0), guarantees a miss on the first call + /** * Read synced pricing from `pricing_synced` namespace. */ export function getSyncedPricing(): PricingByProvider { + const currentVersion = getModelCatalogCacheVersion(); + if (pricingMemo !== null && pricingMemoVersion === currentVersion) { + return pricingMemo; + } + const db = getDbInstance(); const rows = db .prepare("SELECT key, value FROM key_value WHERE namespace = 'pricing_synced'") @@ -252,6 +269,8 @@ export function getSyncedPricing(): PricingByProvider { console.warn(`[PRICING_SYNC] Corrupted data for provider "${key}", skipping`); } } + pricingMemo = synced; + pricingMemoVersion = currentVersion; return synced; } diff --git a/tests/unit/pricing-sync-memoization.test.ts b/tests/unit/pricing-sync-memoization.test.ts new file mode 100644 index 0000000000..0554229795 --- /dev/null +++ b/tests/unit/pricing-sync-memoization.test.ts @@ -0,0 +1,68 @@ +import assert from "node:assert/strict"; +import { describe, it, before, after, mock } from "node:test"; +import { getDbInstance } from "../../src/lib/db/core.ts"; +import { + getSyncedPricing, + saveSyncedPricing, + clearSyncedPricing, +} from "../../src/lib/pricingSync.ts"; + +describe("getSyncedPricing memoization", () => { + before(() => { + saveSyncedPricing({ + openai: { + "gpt-4o": { input: 2.5, output: 10 }, + }, + }); + }); + + after(() => { + try { + clearSyncedPricing(); + } catch { + // ignore + } + }); + + it("returns the same object reference for repeated reads within the same cache version", () => { + const first = getSyncedPricing(); + const second = getSyncedPricing(); + const third = getSyncedPricing(); + // The saturation bug rebuilt a fresh object on every call; resolveCatalogPricing() + // calls this per model, so a fresh object per call re-ran the SELECT + JSON.parse + // and rebuilt the findInsensitive() lowercase index per lookup (~400 warnings/s). + assert.equal(second, first); + assert.equal(third, first); + }); + + it("hits the DB once for repeated reads within the same cache version", () => { + const db = getDbInstance(); + const prepareSpy = mock.method(db, "prepare"); + const callsBefore = prepareSpy.mock.calls.length; + + getSyncedPricing(); + getSyncedPricing(); + getSyncedPricing(); + + const callsAfter = prepareSpy.mock.calls.length; + prepareSpy.mock.restore(); + + // Memoized, 3 calls should cost at most 1 real DB round-trip (0 if a prior + // test already warmed the cache at the same version). + assert.ok( + callsAfter - callsBefore <= 1, + `expected at most 1 db.prepare() call across 3 reads, got ${callsAfter - callsBefore}` + ); + }); + + it("returns a new reference with fresh data after a pricing write invalidates the cache", () => { + const warm = getSyncedPricing(); // warm the memo at the current cache version + saveSyncedPricing({ + anthropic: { "claude-x": { input: 1, output: 2 } }, + }); + const pricing = getSyncedPricing(); + assert.notEqual(pricing, warm, "invalidation must rebuild, not reuse the stale object"); + assert.ok(pricing.anthropic, "cache should reflect the write, not a stale snapshot"); + assert.equal(pricing.anthropic["claude-x"].input, 1); + }); +});