perf(ai-gateway): run the usage write in the primary's region - #5034
Draft
RSO wants to merge 2 commits into
Draft
Conversation
Deployment region facts are not discoverable from the repository: both Vercel projects share one vercel.json with no regions key, and there is no per-route region pinning anywhere, so the Frankfurt/SFO split only exists in the Vercel dashboard. Also record which observability fields actually name the compute region. proxy.region is documented as where the request is processed, i.e. the edge hop, while executionRegion and proxy.lambdaRegion name the function. x-vercel-id mixes PoP hops with the execution region, which is easy to misread on requests that pass through the rewrite to global-api.kilo.ai.
The AI gateway runs on kilocode-global-app, whose functions execute in both
Frankfurt and SFO, while the PostgreSQL primary is Frankfurt-only. The usage
write takes row locks on organization_user_usage and organizations (and
kilocode_users for personal usage) and holds them across the remaining
statements of the transaction until COMMIT. From SFO that hold is dominated by
transatlantic round trips rather than by database work.
pg_stat_statements on the primary shows the shape: UPDATE organizations has a
mean of 0.5ms and a max of 100,590ms, and the organization_user_usage upsert a
mean of 0.30ms and a max of 100,242ms. A sub-millisecond statement taking 100
seconds is queueing, not work. Arrival rate on a single hot counter row peaked
at 9 writes/second against a hold of a few hundred milliseconds, so the queue
grows until statements are killed at the database statement_timeout ceiling of
120s. Each waiter pins a pool connection while it waits, and with max: 10 per
instance ten of them take out every route on that instance, which is what
surfaces as "timeout exceeded when trying to connect" on unrelated endpoints.
SFO instances now hand the write to POST /api/internal/usage/record on
APP_URL, which is the Frankfurt-only kilocode-app deployment whose rewrites do
not divert /api/internal/*. Frankfurt instances keep writing directly, since a
Frankfurt-to-Frankfurt hop is pure overhead and a pointless failure mode.
The seam is saveUsageRelatedData: the caller keeps stream parsing, cost
computation and NUL sanitization, and ships the already-normalized
{ core, metadata } from toInsertableDbUsageRecord. That payload carries no
prompt or response bodies.
Correctness details:
- The endpoint is idempotent on core.id, which the sender generates and which is
the microdollar_usage primary key. Without that, a caller retry after a lost
response would collide on the primary key and report a billed request as
unbilled.
- Nullable contract fields use .nullable(), never .optional(), because
JSON.stringify drops undefined keys and null is load-bearing: org usage sets
the prompt prefixes to null precisely so prompt text is never persisted.
- created_at is validated as strict ISO 8601 so a PostgreSQL-shaped timestamp
can never enter the path, per packages/db/AGENTS.md. Covered by fixtures.
- On any non-retryable response or exhausted retries the client returns
unavailable and the caller falls back to the local write. A slow billing
record beats a lost one; the failure is reported to Sentry with the usageId.
No new environment variables: APP_URL and INTERNAL_API_SECRET already exist.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The AI gateway runs on
kilocode-global-app, whose functions execute in both Frankfurt and SFO, while the PostgreSQL primary is Frankfurt-only. The usage write takes row locks onorganization_user_usageandorganizations(andkilocode_usersfor personal usage) and holds them across the remaining statements of the transaction untilCOMMIT. From SFO that hold is dominated by transatlantic round trips rather than by database work.pg_stat_statementson the production primary shows the shape:UPDATE organizations SET microdollars_used …INSERT organization_user_usage … ON CONFLICT DO UPDATEmicrodollar_usage_insCTEA sub-millisecond statement taking 100 seconds is queueing, not work, and the CTE's max is clipped exactly at the database
statement_timeoutceiling of 120 s. Measured arrival rate on a single hot counter row peaked at 9 writes/second against a hold of a few hundred milliseconds, so the queue grows without bound. Each waiter pins a pool connection while it waits, and withmax: 10per instance ten of them take out every route on that instance — which is what surfaces in Sentry astimeout exceeded when trying to connecton unrelated endpoints like/api/profileand/api/webhooks/github.SFO instances now hand the write to
POST /api/internal/usage/recordonAPP_URL, the Frankfurt-onlykilocode-appdeployment whose rewrites do not divert/api/internal/*. Frankfurt instances keep writing directly, since a Frankfurt-to-Frankfurt hop is pure overhead and a pointless new failure mode.The seam is
saveUsageRelatedData. The caller keeps stream parsing, cost computation and NUL sanitization, and ships the already-normalized{ core, metadata }fromtoInsertableDbUsageRecord. That payload carries no prompt or response bodies.Correctness details worth knowing:
core.id, which the sender generates and which is themicrodollar_usageprimary key. Without it, a caller retry after a lost response would collide on the primary key and report a billed request as unbilled..nullable(), never.optional().JSON.stringifydropsundefinedkeys, andnullis load-bearing here: org usage sets the prompt prefixes tonullprecisely so prompt text is never persisted.created_atis validated as strict ISO 8601 so a PostgreSQL-shaped timestamp can never enter the path, perpackages/db/AGENTS.md. Covered by fixtures using production-shaped text.unavailableand the caller falls back. A slow billing record beats a lost one; the failure goes to Sentry with theusageIdso a genuine loss is reconcilable.No new environment variables —
APP_URLandINTERNAL_API_SECRETalready exist.The first commit is an unrelated documentation addition recording the Frankfurt/SFO function-region split and which observability fields actually name the compute region. It is separable if you'd rather it went on its own.
Verification
Automated:
tsgo --noEmit -p apps/web/tsconfig.jsonclean;./scripts/lint-all.sh0 warnings 0 errors; 29 new tests passing; regression runs ofsrc/lib/ai-gateway+src/lib/drizzle.test.ts(626 tests, 54 suites),src/lib/kilo-pass-org+organization-usage(127 tests), andspend-writer-audit. This is targeted verification, notpnpm validate.Manual: none. The behaviour that matters only exists on a multi-region production deployment — locally
VERCEL_REGIONis unset, soisUSRegion()is false and the code takes the pre-existing local path. I could not exercise the SFO branch, the HTTP hop, or the dedupe path by hand.INTERNAL_API_SECRETis present onkilocode-global-app. If it is missing, every SFO write silently takes the fallback path and nothing improves.app.kilo.airesolves tokilocode-app. If that domain ever moves to the global project this silently becomes a no-op hop.POST /api/internal/usage/recordagainst a preview deployment, including sending the samecore.idtwice to seestatus: "duplicate".recordUsageInPrimaryRegionfailures in Sentry and themax_exec_timeofUPDATE organizationsbefore/after.Visual Changes
N/A
Reviewer Notes
Please push back on the premise. I have not proven that the waiters are each other. The mean-vs-max evidence is statistical; I never captured
pg_locksor wait events during a burst. A single long-running transaction holding anorganizationsrow — an admin script, a cron, a batch job — would produce identical maxima with no convoy, and in that case this change does not help. Apg_stat_activity+pg_blocking_pids()sample during an incident would settle it and is cheap to add to the existingdb-pool-metricscron. I'd rather that ran before this is relied on.Known gaps in test coverage:
saveUsageRelatedDatais module-private and reaching it means going throughcountAndStoreUsage, which needs aResponse.isUSRegionand the client are tested separately, but the seam between them is not.Other things to look at:
apps/web(see the comments inlib/auto-fix/github/get-fix-config.ts). This is cross-deployment rather than same-instance, which is why the hop buys locality — but it is a fair objection and theisUSRegion()gate exists partly to answer it.maxDuration = 150on the new route is deliberately above the 120 s databasestatement_timeoutso a blocked write is reported rather than truncated into a lost billing row.duplicate.organization_user_usageis one row per (org, user, day) andorganizations.microdollars_usedis shared by every member. Batching in a single place is the natural follow-up, and the endpoint is now that place.