From a96c8381f7763bb3d963e2d874005ec0534cf225 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Fri, 18 Sep 2026 11:57:00 -0300 Subject: [PATCH] fix(db): serve getPricingForModel() from the pricing cache (#13891) (#14040) getPricingForModel() called the uncached getPricing() on every invocation instead of the existing getCachedPricing() helper (30s TTL, readCache.ts), so usageStats.getUsageStats() re-ran a 3-SELECT + JSON.parse + merge cycle against key_value once per GROUP BY row -- up to 531 times on a large usage_history table -- blocking the event loop for several seconds on /api/usage/history. Every known pricing writer (updatePricing, LiteLLM/models.dev sync) already invalidates this cache via touchPricing()/invalidateDbCache, so a write remains immediately visible; added a regression test that proves both the cache hit path and the invalidation path. --- .../fixes/13891-cache-pricing-lookups.md | 1 + src/lib/db/settings/pricing.ts | 4 +- .../pricing-for-model-cached-13891.test.ts | 62 +++++++++++++++++++ 3 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 changelog.d/fixes/13891-cache-pricing-lookups.md create mode 100644 tests/unit/pricing-for-model-cached-13891.test.ts diff --git a/changelog.d/fixes/13891-cache-pricing-lookups.md b/changelog.d/fixes/13891-cache-pricing-lookups.md new file mode 100644 index 0000000000..68583069f2 --- /dev/null +++ b/changelog.d/fixes/13891-cache-pricing-lookups.md @@ -0,0 +1 @@ +- **fix(db):** `getPricingForModel()` now reads through the existing 30s TTL `getCachedPricing()` helper instead of rebuilding pricing from scratch (3 SELECTs + JSON.parse + merge) on every call, eliminating the multi-second event-loop stall `/api/usage/history` hit when `calculateAggregateCost()` invoked it once per GROUP BY row (up to 531 times per request) (#13891). Every known pricing writer already invalidates this cache via `touchPricing()`, so writes remain immediately visible. diff --git a/src/lib/db/settings/pricing.ts b/src/lib/db/settings/pricing.ts index e2496550a2..0cbc12fc9f 100644 --- a/src/lib/db/settings/pricing.ts +++ b/src/lib/db/settings/pricing.ts @@ -4,7 +4,7 @@ import { getDbInstance } from "../core"; import { backupDbFile } from "../backup"; -import { invalidateDbCache } from "../readCache"; +import { getCachedPricing, invalidateDbCache } from "../readCache"; import { PROVIDER_ID_TO_ALIAS } from "@omniroute/open-sse/config/providerModels.ts"; import { type JsonRecord, toRecord } from "./shared"; @@ -131,7 +131,7 @@ export async function getPricingWithSources(): Promise<{ } export async function getPricingForModel(provider: string, model: string) { - const pricing = await getPricing(); + const pricing = (await getCachedPricing()) as PricingByProvider; const findKeyInsensitive = ( obj: Record | undefined | null, diff --git a/tests/unit/pricing-for-model-cached-13891.test.ts b/tests/unit/pricing-for-model-cached-13891.test.ts new file mode 100644 index 0000000000..5e9629d05a --- /dev/null +++ b/tests/unit/pricing-for-model-cached-13891.test.ts @@ -0,0 +1,62 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, it, before, after, mock } from "node:test"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-pricing-13891-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const { getPricingForModel, updatePricing } = await import("../../src/lib/db/settings/pricing.ts"); + +describe("getPricingForModel — issue #13891 (uncached getPricing rebuild per call)", () => { + before(() => { + core.resetDbInstance(); + core.getDbInstance(); + }); + + after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + }); + + it("hits the DB at most once across many repeated lookups (simulating usageStats' 531 groups/request)", async () => { + const db = core.getDbInstance(); + const prepareSpy = mock.method(db, "prepare"); + const callsBefore = prepareSpy.mock.calls.length; + + for (let i = 0; i < 20; i++) { + await getPricingForModel("openai", "gpt-4o"); + } + + const callsAfter = prepareSpy.mock.calls.length; + prepareSpy.mock.restore(); + + const dbCallsMade = callsAfter - callsBefore; + assert.ok( + dbCallsMade <= 3, + `expected getPricingForModel() to be served from the pricing cache after the ` + + `first call (<=3 db.prepare() calls across 20 lookups), but it re-queried the ` + + `DB ${dbCallsMade} times` + ); + }); + + it("picks up fresh pricing immediately after a write invalidates the cache", async () => { + // Warm the cache with the pre-write price. + const before = await getPricingForModel("acme-test-provider", "acme-test-model"); + assert.equal(before, null); + + // A pricing write (updatePricing -> touchPricing -> invalidateDbCache("pricing")) + // must invalidate the 30s TTL cache so the very next lookup is not stale. + await updatePricing({ + "acme-test-provider": { + "acme-test-model": { inputCostPerToken: 0.000123, outputCostPerToken: 0.000456 }, + }, + }); + + const after = await getPricingForModel("acme-test-provider", "acme-test-model"); + assert.ok(after, "expected fresh pricing to be visible right after updatePricing()"); + assert.equal((after as { inputCostPerToken?: number }).inputCostPerToken, 0.000123); + }); +});