Skip to content

perf(ai-gateway): run the usage write in the primary's region - #5034

Merged
RSO merged 6 commits into
mainfrom
database-connection-timeo
Aug 6, 2026
Merged

RSO merged 6 commits into
mainfrom
database-connection-timeo

Conversation

@RSO

@RSO RSO commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

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 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 production primary shows the shape:

statement calls mean max
UPDATE organizations SET microdollars_used … 5.0M 0.5 ms 100,590 ms
INSERT organization_user_usage … ON CONFLICT DO UPDATE 6.0M 0.30 ms 100,242 ms
the microdollar_usage_ins CTE 338M 2–10 ms ~119,900 ms

A sub-millisecond statement taking 100 seconds is queueing, not work, and the CTE's max is clipped exactly at the database statement_timeout ceiling 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 with max: 10 per instance ten of them take out every route on that instance — which is what surfaces in Sentry as timeout exceeded when trying to connect on unrelated endpoints like /api/profile and /api/webhooks/github.

SFO instances now hand the write to POST /api/internal/usage/record on APP_URL, 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 new 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 worth knowing:

  • Idempotent on core.id, which the sender generates and which is the microdollar_usage primary key. Without it, 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(). JSON.stringify drops undefined keys, and null is load-bearing here: 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 using production-shaped text.
  • Failure degrades to the local write. On any non-retryable response or exhausted retries the client returns unavailable and the caller falls back. A slow billing record beats a lost one; the failure goes to Sentry with the usageId so a genuine loss is reconcilable.

No new environment variables — APP_URL and INTERNAL_API_SECRET already 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.json clean; ./scripts/lint-all.sh 0 warnings 0 errors; 29 new tests passing; regression runs of src/lib/ai-gateway + src/lib/drizzle.test.ts (626 tests, 54 suites), src/lib/kilo-pass-org + organization-usage (127 tests), and spend-writer-audit. This is targeted verification, not pnpm validate.

Manual: none. The behaviour that matters only exists on a multi-region production deployment — locally VERCEL_REGION is unset, so isUSRegion() 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.

  • Confirm INTERNAL_API_SECRET is present on kilocode-global-app. If it is missing, every SFO write silently takes the fallback path and nothing improves.
  • Confirm app.kilo.ai resolves to kilocode-app. If that domain ever moves to the global project this silently becomes a no-op hop.
  • Exercise POST /api/internal/usage/record against a preview deployment, including sending the same core.id twice to see status: "duplicate".
  • After deploy, watch recordUsageInPrimaryRegion failures in Sentry and the max_exec_time of UPDATE organizations before/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_locks or wait events during a burst. A single long-running transaction holding an organizations row — an admin script, a cron, a batch job — would produce identical maxima with no convoy, and in that case this change does not help. A pg_stat_activity + pg_blocking_pids() sample during an incident would settle it and is cheap to add to the existing db-pool-metrics cron. I'd rather that ran before this is relied on.

Known gaps in test coverage:

  • No test for the region-gating wiring. saveUsageRelatedData is module-private and reaching it means going through countAndStoreUsage, which needs a Response. isUSRegion and the client are tested separately, but the seam between them is not.
  • No integration test for the endpoint, including the dedupe branch. That is the riskiest new logic and deserves a route test against the test database.

Other things to look at:

  • There is precedent in this repo against self-referencing HTTP fetches within apps/web (see the comments in lib/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 the isUSRegion() gate exists partly to answer it.
  • maxDuration = 150 on the new route is deliberately above the 120 s database statement_timeout so a blocked write is reported rather than truncated into a lost billing row.
  • Retries can double-deliver while the server is mid-write. The dedupe check handles the committed case; the uncommitted-but-in-flight case relies on the primary key, where the loser 500s and the caller's next attempt sees duplicate.
  • This shortens the hot-row hold but does not remove the hot row. organization_user_usage is one row per (org, user, day) and organizations.microdollars_used is shared by every member. Batching in a single place is the natural follow-up, and the endpoint is now that place.

RSO added 5 commits August 6, 2026 10:36
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.
A retry of the Frankfurt usage write can arrive while the first attempt is
still open: the client retries its 10s attempt timeout, and after exhausting
its retries it falls back to writing locally with the same core.id. The
route's pre-flight lookup only catches a redelivery that arrives after the
first one committed, so the in-flight case collided on the microdollar_usage
primary key and was reported as a failed write even though the usage was
billed, which made callers skip dependent writes such as experiment
attribution.

insertUsageRecord now looks the row up when the write fails and returns its
identity when it is there. The row's presence under the same user is the
proof rather than the PostgreSQL error code, so a deadlock victim retried
into the collision recovers the same way.
`usage-record-client.ts` imported `UsageRecordInsertResult` from
`processUsage.ts`, which imports that client to hand the write to Frankfurt. The
two modules were therefore mutually dependent, and `dependency-cycle-check`
rejects that even though the import is type-only and erased at runtime.

Move the type to `processUsage.types.ts`, which is already the shared home for
the usage types the write path and the wire contract both need.
… once

`isFirstUsage` can only be answered from committed rows, so a redelivery that
arrives while the first delivery's transaction is still open also classified the
record as the user's first usage and emitted `first_usage` a second time.
`recordUsageInPrimaryRegion` produces exactly that: it retries after a 10s
attempt timeout while the write it started may still be committing. Row-level
idempotency covers the billing row, not this pre-insert side effect.

`insertUsageRecord` now reports whether the identity was recovered from an
already-committed row, and `saveUsageRelatedDataLocally` emits the first-usage
events only after an insert of its own commits. `isFirst` is still computed
before the insert, because afterwards the record is its own prior usage.

The route maps a recovered write to the existing `duplicate` status and builds
the response `result` explicitly, so the new flag does not leak onto a wire
contract that does not document it.

The regression test lives in its own file because `processUsage` resolves its
PostHog client at module scope, so the mock has to be registered before that
import.
@RSO
RSO force-pushed the database-connection-timeo branch from 07bd889 to b9ce59b Compare August 6, 2026 09:05
@RSO
RSO marked this pull request as ready for review August 6, 2026 09:05
@kilo-code-bot

kilo-code-bot Bot commented Aug 6, 2026 •

Copy link
Copy Markdown
Contributor

Code Review Summary

Status: No Issues Found | Recommendation: Merge

Executive Summary

Incremental review of commit 142444f20 confirms the sole prior finding is fixed: the recorded branch of /api/internal/usage/record now returns the request's strict ISO 8601 core.created_at — schema-validated via z.string().datetime(), the same instant that was inserted — matching the duplicate branch and the wire contract's documented invariant.

Files Reviewed (1 file)
  • apps/web/src/app/api/internal/usage/record/route.ts — previous WARNING resolved; createdAt: core.created_at verified correct for both the recorded and redelivery-recovered duplicate paths
Previous Review Summary (commit b9ce59b)

Current summary above is authoritative. Previous snapshots are kept for context only.

Previous review (commit b9ce59b)

Status: 1 Issue Found | Recommendation: Address before merge

Executive Summary

The new /api/internal/usage/record route's recorded branch leaks a PostgreSQL-shaped createdAt onto a wire contract that explicitly forbids non-ISO-8601 timestamps; the cross-region handoff, redelivery recovery, and first-usage dedup logic otherwise check out.

Overview

Severity Count
CRITICAL 0
WARNING 1
SUGGESTION 0
Issue Details (click to expand)

WARNING

File Line Issue
apps/web/src/app/api/internal/usage/record/route.ts 107 recorded branch returns the DB-rendered createdAt (timestamptz text) instead of the request's strict ISO 8601 core.created_at, contradicting the contract's documented invariant and the duplicate branch above it
Files Reviewed (10 files)
  • AGENTS.md — docs-only; region tables consistent with isUSRegion and next.config.mjs rewrites
  • apps/web/src/app/api/internal/usage/record/route.ts — 1 issue
  • apps/web/src/lib/ai-gateway/processUsage.ts — redelivery recovery and first-usage event gating verified sound
  • apps/web/src/lib/ai-gateway/processUsage.types.ts — type move breaks the import cycle cleanly
  • apps/web/src/lib/ai-gateway/usage-record-client.ts — bounded retries/timeouts inside after(); no leak vectors; no secret logging
  • apps/web/src/lib/ai-gateway/usage-record-contract.ts — compile-time DB-alignment assertions verified against @kilocode/db/schema-types
  • apps/web/src/lib/ai-gateway/usage-record-client.test.ts
  • apps/web/src/lib/ai-gateway/usage-record-contract.test.ts
  • apps/web/src/lib/ai-gateway/processUsage.test.ts
  • apps/web/src/lib/ai-gateway/first-usage-events.test.ts

Fix these issues in Kilo Cloud


Reviewed by kimi-k3 · Input: 75.2K · Output: 4.5K · Cached: 180.2K

Review guidance: REVIEW.md from base branch main

…write

A fresh insert reports `RETURNING created_at`, which PostgreSQL renders as
"2026-04-29 01:16:12.945+00" — not the strict ISO 8601 the rest of this path
guarantees. `core.created_at` is the same instant in the guaranteed format,
because that column was inserted from it, and it is already what the `duplicate`
branch and `logMicrodollarUsage` report.
abuse_classification: z.nativeEnum(ABUSE_CLASSIFICATION),
organization_id: z.string().uuid().nullable(),
inference_provider: z.string().nullable(),
project_id: z.string().nullable(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you make changes here they should be backwards and forwards compatible I assume?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I think so. I was wondering whether a retry would pick up the new schema, but I guess not.

@RSO
RSO merged commit a5b91ba into main Aug 6, 2026
17 checks passed
@RSO
RSO deleted the database-connection-timeo branch August 6, 2026 12:13
RSO added a commit that referenced this pull request Aug 6, 2026
* feat(ai-gateway): add usage-record write diagnostics

POST /api/internal/usage/record is strongly bimodal: p50 around 5ms against a
p95 near 50s. The database is idle throughout — zero blocked sessions,
sub-second oldest transaction, and log_lock_waits (on, deadlock_timeout 1s)
producing only a handful of entries per hour — and Supavisor reports
client_waiting 0 against a pool size of 320. So the time is spent inside the
Node process and nothing currently emitted can say where.

Two candidates need different fixes, so they have to be separated by
measurement rather than argument:

- Waiting on the in-process pg pool, capped at 10 connections per instance.
  /api/cron/db-pool-metrics reports Supavisor's counters, not this pool's, so
  its saturation is invisible today. waitingCount settles it.
- Event-loop starvation, which would inflate every phase including ones that
  do no I/O. The delay histogram settles that one.

Also adds describeDatabaseError, which extracts the driver's code, constraint,
table and routine from a drizzle-wrapped failure while carrying none of the
statement. A drizzle error stringifies to the interpolated query, which for
this write is ~30KB and includes user_prompt_prefix, system_prompt_prefix, the
client IP, city and JA4 digest. detail is omitted too, since PostgreSQL puts
the conflicting key values there.

isUsageRowConflict identifies a delivery colliding with its own id. It is
deliberately narrow: microdollar_usage and microdollar_usage_metadata are
keyed by the per-delivery id, while the twelve deduplicated lookup tables have
a WHERE NOT EXISTS race that retrying genuinely does resolve. Constraint names
were checked against pg_constraint — the lookup tables use <table>_pkey and
microdollar_usage carries the generated PK_a71b90d910e7882358c3defe8e2.

Emission is gated on a 1s threshold plus a 1% sample, and it replaces a log
line that serialized ~30KB per failure, so net log volume falls.

* fix(ai-gateway): stop the usage-record redelivery storm

Moving the usage write to a Frankfurt-local endpoint (#5034) fixed the hot-row
convoy — the local write is 5ms at p50 and the primary is now idle — but
replaced it with a self-inflicted retry storm.

recordUsageInPrimaryRegion used a 10s per-attempt timeout against an endpoint
whose p95 is roughly 50s. Every request slower than the timeout was abandoned
and re-sent with the same core.id, so around 20% of deliveries became
redeliveries colliding on the microdollar_usage primary key: about 4,400
collisions per five minutes, each one adding load that made the endpoint slower
still. The failures were invisible because the client only logs after
exhausting every attempt, which happened just 167 times.

Three changes break the loop:

- The attempt budget is 90s, above the observed tail and below the endpoint's
  maxDuration of 150s. A timeout is now never retried: the endpoint may still
  be mid-write, so re-sending reproduces exactly this collision. It falls back
  to the local write instead, which is slower but which the endpoint's conflict
  handling already recognises.
- Retryable statuses narrow from "5xx or 429" to 429, 502 and 503 — refusals
  that prove the function did not run. A 500 means our handler threw, possibly
  after commit, and 504 is equally ambiguous; both used to be retried.
- insertUsageRecord stops retrying a collision on this record's own id. id is
  fixed for the delivery, so all three attempts collided and each rebuilt and
  re-logged the same ~30KB statement before the outer handler recovered the
  committed identity.

Failure logs no longer carry the raw error. Both the retry warning and the
final captureException report the redacted description instead, so prompt
prefixes, client IP, city and JA4 digest stop reaching Axiom and Sentry. The
stack is preserved on the replacement error so the call site stays
identifiable.

The endpoint now records phase timings, in-process pool gauges and event-loop
delay on one structured line, which is what will locate the remaining tail.

* fix(ai-gateway): keep redacted usage errors free of the statement

Two findings from review on #5098.

The redacted Sentry error copied the original stack to preserve the throw site,
but V8 renders error.stack as `${name}: ${message}` followed by the frames, so
the copy put the interpolated statement — prompt prefixes, client IP, city, JA4
digest — straight back into the captured event. Verified: the assembled stack
still contained the prompt text and IP. That defeated the redaction it sat next
to.

stackFramesUnderHeader carries over only the frames, under a caller-supplied
header. Frames are `at fn (chunk:line:col)` and hold no request data, so the
throw site survives, which Error.captureStackTrace on the replacement would have
discarded.

Separately, `await response.json()` sat outside any guard, so a truncated or
non-JSON body threw into the outer catch and was treated as a retryable
transport failure. The status was already 2xx at that point, so the write may
have committed — the same hazard as the schema mismatch immediately below, which
deliberately does not retry. Both now fall back instead of re-sending and
manufacturing a redelivery.

* fix(ai-gateway): strip the error header by exact prefix, not by frame search

Follow-up finding on #5098. stackFramesUnderHeader located the message boundary
with `/^\s+at\s/m`, a heuristic applied to a string that embeds
user_prompt_prefix. A user pasting a stack trace into their prompt — routine for
a coding assistant — puts a frame-shaped line inside the message, so the search
matched there and the slice carried the remaining parameters into the result.

Verified against a prompt containing a pasted trace: the assembled stack still
held the client IP and city. The previous fix closed the header leak and left
this one open.

The header is now removed by exact `${name}: ${message}` prefix, so no part of
the message can survive, and the result is filtered to frame-shaped lines as a
second barrier against a message mutated after construction.

Fails closed: an unrecognised header means the boundary is unknown, so no frames
are emitted. describeDatabaseError still carries the actionable code and
constraint, and losing a throw site is cheaper than leaking a prompt.

Also renames the shadowed `payload` binding in the response-body guard, noted in
the same review as currently harmless.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants