Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion src/lib/pricingSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───────────────────────────────────────────────
Expand Down Expand Up @@ -232,10 +232,27 @@ function toRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
}

// 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'")
Expand All @@ -252,6 +269,8 @@ export function getSyncedPricing(): PricingByProvider {
console.warn(`[PRICING_SYNC] Corrupted data for provider "${key}", skipping`);
}
}
pricingMemo = synced;
pricingMemoVersion = currentVersion;
return synced;
}

Expand Down
68 changes: 68 additions & 0 deletions tests/unit/pricing-sync-memoization.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading