Repository navigation
[AISAGE-707] perf(ai-gateway): serve gateway config from one Redis snapshot - #8107
chrarnoldus wants to merge 5 commits into
Conversation
Code Review SummaryStatus: No Issues Found | Recommendation: Merge Executive SummaryRe-reviewed the hardening commit Files Reviewed (18 files)
Previous Review Summary (commit bead522)Current summary above is authoritative. Previous snapshots are kept for context only. Previous review (commit bead522)Status: 4 Issues Found | Recommendation: Address before merge Executive SummaryThe snapshot design is sound, but the new single-flight helper has a latent logic bug that can permanently disable refreshes, and the publish fallback deletes the shared key on a read-back failure, which can extend staleness to roughly 6 minutes. Overview
Issue Details (click to expand)WARNING
SUGGESTION
Files Reviewed (18 files)
Reviewed by deepseek-v4.1-flash-1017 · Input: 101.4K · Output: 22.3K · Cached: 2.2M Review guidance: REVIEW.md from base branch |
Summary
Production query statistics showed four separate single-column reads of the singleton
ai_gateway_configrow (id = 1) among the most frequent gateway queries, despite 60 s in-memory caches:unavailable_modelsai-gateway/unavailable-models.tsauto_freeai-gateway/auto-model/auto-free-config.tspreferred_modelsai-gateway/preferred-models.tsconfigai-gateway/providers/routing-config.tsVercel function instances are short-lived, so each instance refreshes its in-memory caches often, and each refresh was a separate blocking query.
This PR:
ai-gateway-config-snapshot.ts, one cached snapshot of all four raw columns:createCachedFetch, with last-known-good on failure;GET ai-gateway-config:v1from Upstash (us-west read replica, so local fromsfo1), validated with zod. An invalid value is removed with a compare-and-delete script, so the populate can replace it without deleting a valid value a writer stored in between;readDbselect of all four columns, then a backgroundSET NX EX 300registered withwaitUntil, which never blocks the response and never throws.createAiGatewayConfigSelector, which re-parses only when the snapshot object changes and keeps each getter's last valid value when its column is invalid. Each module keeps its parsing, validation, defaults, warnings, and exported API (readUnavailableModelIds,readPreferredModels, andreadConfiguredAutoFreeModelsstill do an uncached replica read).publishAiGatewayConfigSnapshot(), which every admin write calls after its upsert commits: it reads the full row back from the primary andSETs it withEX 300(no NX). If the read-back fails, Redis is left untouched. If theSETfails, it deletes the key and logs; the TTL bounds the rest. It never throws.Single-flight for concurrent in-memory misses is not part of this PR: #7881 (merged) added it, with a 30 s failure backoff, to
createCachedFetch, which this snapshot cache uses.Expected effect: an instance memory miss usually becomes one Redis read. The replica is only queried by instances that miss Redis during the short window after the key expires (300 s), or while Redis is unavailable. Each of those instances runs one query, where before every instance ran four per minute, multiplied by concurrent requests.
Verification
NODE_ENV=test pnpm exec jest -i --forceExit(fromapps/web, against this worktree's own Postgres container on port 10832 viaapps/web/.env.test.local) on the new and directly changed suites:ai-gateway-config-snapshot.test.ts,cached-fetch.test.ts,unavailable-models.test.ts,preferred-models.test.ts,auto-free-config.test.ts,auto-free-config-router.test.ts,gateway-model-lists-router.test.ts,gateway-config-router.test.ts: all passSame command with
--runTestsByPathon 90 suites: every test file that importscreateCachedFetchor one of its users, the four gateway config getters, or their consumers (gateway handlers, providers, auto-model resolution, sync crons, models routers,apps/ai-gatewayroute tests). 90 suites, 2323 tests passed, no unhandled rejections in the logpnpm --filter @kilocode/web-shared --filter web --filter ai-gateway typecheck: passespnpm exec oxlint --config .oxlintrc.json <18 changed files>: 0 warnings, 0 errorspnpm format:changedandgit diff --check: cleanAfter merging
mainand addressing review: snapshot, getter, and admin router suites (8 suites, 99 tests), thenjest -ionpackages/web-shared/src/lib/ai-gateway,apps/ai-gateway, andapps/web/src/routers/admin(147 suites, 2967 tests): all pass; typecheck and oxlint cleanAfter merging
mainagain (including fix(web-shared): share in-flight fetches and back off failures in createCachedFetch #7881):jest -ionpackages/web-shared/src/lib/ai-gateway,apps/ai-gateway,apps/web/src/routers/admin, andcached-fetch.test.ts(157 suites, 3105 tests): all pass; typecheck cleanVisual Changes
N/A
Reviewer Notes
Writers. These are all the writes to
ai_gateway_configin the repository (searched apps, packages, services, scripts, dev seed data):apps/web/src/routers/admin/gateway-config-router.ts,set(config)apps/web/src/routers/admin/auto-free-config-router.ts,set(auto_free)apps/web/src/routers/admin/gateway-model-lists-router.ts,set(preferred_models,unavailable_models)All three now await
publishAiGatewayConfigSnapshot()after the upsert. No Cloudflare Worker, script, or other service writes this table, so no writer is left to rely on the TTL alone. Manual SQL edits in production are only picked up when the Redis TTL expires (up to 300 s), plus up to 60 s in each instance's memory.Race reasoning.
SET NXcannot overwrite a writer's value. If the writer'sSETlands first, the reader's NX write is a no-op. If the reader's lands first, the writer's plainSEToverwrites it.DELfor invalidation, because a reader could repopulate from a lagging replica right after it.DELis only the fallback when the writer'sSETfails.SETout of order, leaving the older full row in Redis for up to 300 s. Admin edits to this row are rare and done by hand, so I did not add versioning.sfo1reader can briefly see the previous value after a successful writerSET.Staleness bound.
SETfails butDELsucceeds: a reader can repopulate from a lagging replica. That stale value lasts at most 300 s in Redis plus 60 s in memory, and in practice only as long as the replica lag.Other notes.
nulland is not written to Redis. The getters return their existing defaults, as before.auto_freestill warns and falls back tonull. It does not make the whole snapshot uncacheable.SET NXuseswaitUntilfrom@vercel/functionsrather thanafter(), because the snapshot load is shared across requests and can also run outside a request scope (crons, scripts), wherewaitUntilis a no-op and the write still runs.db.