From acb15fefba022b527d9d247191a11caf9826b825 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Mon, 24 Aug 2026 04:39:14 -0300 Subject: [PATCH] fix(catalog): keep large builds event-loop responsive --- .../fixes/pending-catalog-eventloop-9147.md | 1 + open-sse/services/autoCombo/builtinCatalog.ts | 14 ++++++--- open-sse/services/autoCombo/virtualFactory.ts | 19 +++++++++--- src/app/api/v1/models/catalog.ts | 30 +++++++++---------- src/app/api/v1/models/catalogResponse.ts | 3 +- src/lib/modelMetadataRegistry.ts | 1 - .../unit/9147-catalog-eventloop-yield.test.ts | 10 ++++++- 7 files changed, 52 insertions(+), 26 deletions(-) create mode 100644 changelog.d/fixes/pending-catalog-eventloop-9147.md diff --git a/changelog.d/fixes/pending-catalog-eventloop-9147.md b/changelog.d/fixes/pending-catalog-eventloop-9147.md new file mode 100644 index 0000000000..5eac0099e3 --- /dev/null +++ b/changelog.d/fixes/pending-catalog-eventloop-9147.md @@ -0,0 +1 @@ +- **fix(catalog):** keep large `/v1/models` builds responsive by reusing the build-local capability snapshot throughout enrichment and Auto-Combo preparation, yielding cooperatively while constructing virtual candidate pools, and avoiding unrelated synchronous database diagnostics on the cache-TTL read path. diff --git a/open-sse/services/autoCombo/builtinCatalog.ts b/open-sse/services/autoCombo/builtinCatalog.ts index 1f759d5c28..dc1b7f0462 100644 --- a/open-sse/services/autoCombo/builtinCatalog.ts +++ b/open-sse/services/autoCombo/builtinCatalog.ts @@ -1,3 +1,5 @@ +import type { ModelCapabilityResolutionSnapshot } from "@/lib/modelCapabilities"; + import type { AutoVariant } from "./autoPrefix"; import { VALID_VARIANTS } from "./autoPrefix"; import type { PreparedVirtualAutoComboInputs } from "./virtualFactory"; @@ -119,8 +121,7 @@ export function isPaidTierAutoId(autoId: string): boolean { * a candidate filter so the virtual combo only scores vision-capable models. */ export type BuiltinAutoSpec = - | { variant: AutoVariant | undefined } - | { category: AutoCategory; tier?: AutoTier }; + { variant: AutoVariant | undefined } | { category: AutoCategory; tier?: AutoTier }; /** * Vision-flavored flat ids that MUST resolve to the `vision` category (candidate @@ -159,9 +160,14 @@ export function resolveBuiltinAutoSpec(modelStr: string, suffix: string): Builti return { variant: undefined }; } -export async function prepareBuiltinAutoComboInputs(): Promise { +export async function prepareBuiltinAutoComboInputs( + resolutionSnapshot?: ModelCapabilityResolutionSnapshot +): Promise { const { prepareVirtualAutoComboInputs } = await import("./virtualFactory.ts"); - return prepareVirtualAutoComboInputs({ includeResolvedCapabilities: true }); + return prepareVirtualAutoComboInputs({ + includeResolvedCapabilities: true, + resolutionSnapshot, + }); } export async function createBuiltinAutoCombo( diff --git a/open-sse/services/autoCombo/virtualFactory.ts b/open-sse/services/autoCombo/virtualFactory.ts index e3dbac4d73..ccadba9956 100644 --- a/open-sse/services/autoCombo/virtualFactory.ts +++ b/open-sse/services/autoCombo/virtualFactory.ts @@ -404,7 +404,9 @@ export function computeAdvertisedLimits(candidates: AdvertisedLimitCandidate[]): return { contextLength, maxOutputTokens }; } -const PREPARED_CAPABILITY_YIELD_INTERVAL = 16; +// Catalog-scale pools can contain hundreds of models. Keep both candidate construction +// and capability preparation cooperative instead of monopolising one event-loop turn. +const VIRTUAL_AUTO_PREPARATION_YIELD_INTERVAL = 4; type PreparedCapabilityValues = { resolvedContextLength: number | null; @@ -468,7 +470,7 @@ async function attachPreparedCapabilityValues( }; byModel.set(candidate.model, values); state.resolvedSinceYield++; - if (state.resolvedSinceYield >= PREPARED_CAPABILITY_YIELD_INTERVAL) { + if (state.resolvedSinceYield >= VIRTUAL_AUTO_PREPARATION_YIELD_INTERVAL) { state.resolvedSinceYield = 0; await yieldVirtualAutoPreparationTurn(); } @@ -479,7 +481,10 @@ async function attachPreparedCapabilityValues( } export async function prepareVirtualAutoComboInputs( - options: { includeResolvedCapabilities?: boolean } = {} + options: { + includeResolvedCapabilities?: boolean; + resolutionSnapshot?: ModelCapabilityResolutionSnapshot; + } = {} ): Promise { const [connections, disabledNoAuthConnections, settings] = await Promise.all([ getCachedProviderConnections({ isActive: true }) as Promise, @@ -524,6 +529,7 @@ export async function prepareVirtualAutoComboInputs( // Build one logical candidate per provider/model and keep account fallback as an // allowlist on that candidate. This avoids both the old "first registry model per // connection" blind spot and a connections × models Cartesian candidate pool. + let candidateModelsSinceYield = 0; for (const [providerId, providerConnections] of connectionsByProvider) { const providerInfo = registry[providerId]; const registryModelIds = Array.isArray(providerInfo?.models) @@ -557,6 +563,11 @@ export async function prepareVirtualAutoComboInputs( : Array.from(new Set([...registryModelIds, ...defaultModelIds])); for (const modelId of modelIds) { + candidateModelsSinceYield++; + if (candidateModelsSinceYield >= VIRTUAL_AUTO_PREPARATION_YIELD_INTERVAL) { + candidateModelsSinceYield = 0; + await yieldVirtualAutoPreparationTurn(); + } if (hiddenModels?.has(modelId)) continue; const allowedConnectionIds = providerConnections @@ -655,7 +666,7 @@ export async function prepareVirtualAutoComboInputs( const capabilityState: PreparedCapabilityState = { byTarget: new Map(), resolvedSinceYield: 0, - resolutionSnapshot: createModelCapabilityResolutionSnapshot(), + resolutionSnapshot: options.resolutionSnapshot ?? createModelCapabilityResolutionSnapshot(), }; return { regularCandidates: await attachPreparedCapabilityValues(regularCandidates, capabilityState), diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts index ee01af6098..04bd7bcc49 100644 --- a/src/app/api/v1/models/catalog.ts +++ b/src/app/api/v1/models/catalog.ts @@ -7,9 +7,9 @@ import { getSettings, getCachedProviderNodes, getModelAliases, - getDatabaseSettings, getHiddenModelsByProvider, } from "@/lib/localDb"; +import { getUserDatabaseSettings } from "@/lib/db/databaseSettings"; import { createLazyConnectionView } from "@/lib/db/providers/lazyConnectionView"; import { extractAliasBackedModels } from "./aliasBackedModels"; import { @@ -229,7 +229,10 @@ async function buildCatalogPayload( // Falls back to the hardcoded default if not set or on error. let cacheTTL = CATALOG_CACHE_TTL_MS_DEFAULT; try { - const dbSettings = await getDatabaseSettings(); + // Only the persisted cache section is needed here. The full database-settings + // view also calculates dbstat, WAL, schema and integrity diagnostics, which are + // synchronous and can pin the event loop after an otherwise cooperative build. + const dbSettings = getUserDatabaseSettings(); cacheTTL = dbSettings.cache?.modelCatalogCacheTtlMs ?? CATALOG_CACHE_TTL_MS_DEFAULT; } catch { // Swallow — use default TTL on DB error @@ -249,7 +252,7 @@ async function buildUnifiedModelsResponseCore( // event-loop yield, so a large deployment pins the single Node.js thread for the // whole build (reporter: 183 connections / 2000+ models → 10.1s stall that blocks the // dashboard WS heartbeat). Yield every `catYIELD_EVERY` items across the hot loops. - const catYIELD_EVERY = 20; + const catYIELD_EVERY = 5; let catYieldCount = 0; const maybeYieldCatalogBuild = async (): Promise => { catYieldCount++; @@ -393,11 +396,10 @@ async function buildUnifiedModelsResponseCore( ): boolean => { if (!providerKey || !modelId) return false; const canonical = canonicalProviderId || resolveCanonicalProviderId(providerKey); - const alias = - providerIdToAlias[canonical] || providerIdToAlias[providerKey] || undefined; + const alias = providerIdToAlias[canonical] || providerIdToAlias[providerKey] || undefined; const nodePrefix = providerIdToPrefix[providerKey] || providerIdToPrefix[canonical]; - const keysToCheck = [providerKey, canonical, alias, nodePrefix].filter( - (k): k is string => Boolean(k) + const keysToCheck = [providerKey, canonical, alias, nodePrefix].filter((k): k is string => + Boolean(k) ); for (const key of keysToCheck) { const hiddenSet = hiddenModelsByProvider.get(key); @@ -830,7 +832,7 @@ async function buildUnifiedModelsResponseCore( try { const suffix = autoId.replace(/^auto\/?/, ""); if (!preparedAutoInputs) { - preparedAutoInputs = await prepareBuiltinAutoComboInputs(); + preparedAutoInputs = await prepareBuiltinAutoComboInputs(capabilityResolutionSnapshot); await yieldCatalogBuildTurn(); } const virtualCombo = await createBuiltinAutoCombo(autoId, suffix, preparedAutoInputs); @@ -1053,11 +1055,7 @@ async function buildUnifiedModelsResponseCore( // `openai` provider page (codex runs on the openai-compatible connection) // or via the `cx` alias — check all three so a hide from any of them // suppresses the bare model id here. - if ( - isModelHiddenBulk("codex", modelId) || - isModelHiddenBulk("openai", modelId) - ) - continue; + if (isModelHiddenBulk("codex", modelId) || isModelHiddenBulk("openai", modelId)) continue; const alias = providerIdToAlias.codex || "cx"; const aliasId = `${alias}/${modelId}`; @@ -1892,7 +1890,9 @@ async function buildUnifiedModelsResponseCore( const modelId = model.root || (typeof model.id === "string" ? model.id.split("/").pop() : undefined); - return modelId ? getTokenLimit(canonicalId, modelId) : getTokenLimit(canonicalId); + return modelId + ? getTokenLimit(canonicalId, modelId, capabilityResolutionSnapshot) + : getTokenLimit(canonicalId, null, capabilityResolutionSnapshot); }; let enrichmentSnapshot: CatalogEnrichmentSnapshot | undefined; @@ -1905,7 +1905,7 @@ async function buildUnifiedModelsResponseCore( } enrichmentSnapshot = { modelsDevPricing, - capabilityResolution: capabilityResolutionSnapshot, + capabilityResolutionSnapshot, providerNodeIdsByPrefix: providerNodeIdByPrefix, }; // The production profile identified pricing snapshot construction as the last diff --git a/src/app/api/v1/models/catalogResponse.ts b/src/app/api/v1/models/catalogResponse.ts index 198a5a6d30..4005bd4e40 100644 --- a/src/app/api/v1/models/catalogResponse.ts +++ b/src/app/api/v1/models/catalogResponse.ts @@ -227,7 +227,8 @@ export async function finalizeCatalogResponse( // per-entry work is interleaved with other callers / the dashboard WS. const yieldTurn = (): Promise => new Promise((resolve) => setImmediate(resolve)); await yieldTurn(); - const capabilityResolutionSnapshot = createModelCapabilityResolutionSnapshot(); + const capabilityResolutionSnapshot = + enrichmentSnapshot?.capabilityResolutionSnapshot ?? createModelCapabilityResolutionSnapshot(); const enriched: Array> = []; const catYIELD_EVERY = 5; let catEnrichCount = 0; diff --git a/src/lib/modelMetadataRegistry.ts b/src/lib/modelMetadataRegistry.ts index b7aa2aa216..fa86128a3e 100644 --- a/src/lib/modelMetadataRegistry.ts +++ b/src/lib/modelMetadataRegistry.ts @@ -40,7 +40,6 @@ type JsonRecord = Record; export interface CatalogEnrichmentSnapshot { modelsDevPricing: PricingByProvider | null; - capabilityResolution?: ModelCapabilityResolutionSnapshot; providerNodeIdsByPrefix?: Readonly>; /** #9147: build-local bulk load of synced capabilities + token/context overrides * so per-entry enrichment never hits SQLite again (see catalogResponse.ts). */ diff --git a/tests/unit/9147-catalog-eventloop-yield.test.ts b/tests/unit/9147-catalog-eventloop-yield.test.ts index 056b7d1e32..91068f367e 100644 --- a/tests/unit/9147-catalog-eventloop-yield.test.ts +++ b/tests/unit/9147-catalog-eventloop-yield.test.ts @@ -58,7 +58,7 @@ test.after(async () => { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); }); -test("#9147 — catalog build at catalog-scale must not pin the event loop for a long stretch", async () => { +test("#9147 — catalog build at catalog-scale must not pin the event loop for a long stretch", async (t) => { await seedCatalogScaleDataset(); const req = new Request("http://localhost/v1/models"); let settled = false; @@ -79,6 +79,9 @@ test("#9147 — catalog build at catalog-scale must not pin the event loop for a } const res = await buildPromise; assert.equal(res.status, 200); + t.diagnostic( + `maximum event-loop gap: ${maxGapMs.toFixed(1)}ms across ${ticks} interleaved ticks` + ); // 150ms is tight on GitHub-hosted unit shards (`--test-concurrency=4`): // sibling tests share the event loop, so a healthy yielding builder still // records 200–260ms gaps. 400ms still fails a true pin (seconds) while @@ -89,4 +92,9 @@ test("#9147 — catalog build at catalog-scale must not pin the event loop for a `catalog for ${CONNECTION_COUNT} connections / ${CONNECTION_COUNT * MODELS_PER_CONNECTION} models ` + `(${ticks} interleaved ticks observed) — the builder is not yielding to the event loop` ); + const body = (await res.json()) as { data?: Array<{ root?: string }> }; + assert.ok( + body.data?.some((model) => model.root === "probe-model-59-11"), + "the responsiveness probe must still traverse and return the last seeded catalog model" + ); });