Post-mortem · 2026-11-11 · Production

Release 3848f7a0e: API refusals and model-list errors

A new database lookup on GET /v1/models overloaded the production database. That slowed the job that writes request records, and because gateway capture waited on those records, the gateway refused API requests for 27 minutes. A database hotfix ended it at 19:52 UTC.

~3,400API requests refused with 503, 19:10 to 19:37 UTC
~6,400/v1/models 500 errors, 19:30 to 19:55 UTC
~7%peak share of Koodos traffic refused
~20 coresdatabase CPU spent on the new lookup

What went wrong

MR !1124 made GET /v1/models list each customer's free :free model names. To do that it called a database function on every listing. Clients call this endpoint about 8 times a second, and on production data each call took about 1.4 seconds of database CPU. Traffic did not change; the cost per call did.

  1. 1
    Every model listing ran an expensive query.

    18,265 calls in under an hour, averaging 3.3 s under load: about 60,000 s of database time.

  2. 2
    The database ran out of CPU.

    Up to 56 copies of the query ran at once. Everything else on the database slowed down.

  3. 3
    The request-record writer fell behind.

    It went from about 28,000 to 15,000 receipts per 5 minutes, so request rows landed late.

  4. 4
    Capture waited for those rows, and its buffer filled.

    Each gateway worker's pending captures went from about 1 to 400 to 760 (about 500 MB).

  5. 5
    The gateway refused new requests it had to capture.

    503 capture_unavailable, mostly on uncensored models but on every model.

Two other things made it worse. The release reset the control plane to 4 pods, and a missing setting (BALANCE_LEDGER_WRITEBACK_REPLICAS) let one pod hold four of the eight record streams, so one customer's records stayed about 20 minutes late after the database recovered. None of the other MRs in the release caused the incident; !1125 has a separate bug, fixed by !1142.

Timeline (UTC)

TimeEvent
06:57!1124 merged.
19:03New gateway workers start serving. /v1/models median goes from 90 ms to 5.5 s by 19:16.
19:10Capture 503s begin. The release resets the control plane to 4 pods.
19:26Kion merges !1141, reverting !1124. It cannot be deployed: the release record is stuck.
19:30Kion starts cancelling the slow query. The database recovers, but /v1/models now returns 500.
19:37Control plane scaled to 16, then 24 pods. Capture 503s stop.
19:42Gateway rollback attempt fails (see below). Three heavy crons paused.
19:52Database hotfix switches the lookup off. Active database sessions drop from about 87 to 15.
19:55/v1/models 500s end.
20:11Writer setting fixed. The late records drain by about 20:40.

Why testing missed it

How we fixed it

What we are changing

How rollback will work (!1145)

Today a gateway rollback cannot work: the old build is refused when it starts outside a release, and Porter reports there is nothing to roll back. !1145 replaces that with a traffic switch between two gateway fleets, called colors.

  1. Two fleets. The gateway runs as two copies, color a and color b. One serves all traffic (the stable color); the other is idle with zero pods.
  2. A release starts the idle color on the new build, already scaled to the serving color's size, while the old build keeps serving.
  3. Traffic moves gradually. The edge reads a routing record every 2 seconds and splits traffic by API key, so each customer sticks to one color. Our own orgs and the smoke-test key go first (the cohort), then 1%, 10% and 50% of everyone else, five minutes per step. A protected list of key customers stays on the old version until the final 100% step, after the rest of the release has proved itself (being added in a follow-up MR).
  4. It stops by itself. At every step the release checks the new color's 5xx share, p95 latency, capture drops, writeback lag, database connections and database CPU. If any crosses its threshold, the release halts and traffic returns to the old color. The thresholds are configuration, not code.
  5. An hour of safety. After 100% the release bakes for an hour with the old color still running. During the steps and the bake, one click (Admin, then Platform, then Production releases, typing ROLL BACK, or one CI job) sends 100% of traffic back to the old color within seconds, whatever state the release is in. A preview measured the switch back at 6.3 seconds.
  6. Commit. After a clean bake the old color is scaled to zero (its settings kept) and becomes the idle color for the next release.
  7. After the bake. Rollback is then a re-release of the previous build onto the idle color with the same gradual steps: minutes, not seconds. The control plane, edge and web are restored from that release's saved configuration.

What it does not undo: database migrations. They apply before any pod moves and are never rolled back, which is why every migration must stay readable by the previous build (the expand-then-contract rule, enforced in CI). A rollback switches code, not schema.

Cost: during a release the gateway fleet is doubled for about an hour and a half.

Can we roll back the next release?

Not with the fast switch. !1145 ships turned off: the release that carries it still runs one gateway fleet, so there is no second color to switch to. Its rollback request does work, but without colors it waits for the old build's fleet to come back, which takes minutes, and the old build here is several days old. For the next release the protections are:

Fast rollback is available from the first release after colors are turned on: run the index, rehearse on staging, then turn colors on in production.

How isolated is the API from the dashboard?

Serving /v1 never depended on the dashboard: gateway workers go straight to the database, which is why the API stayed up when the dashboard was slow. After the next release and !1147:

How to isolate them completely

The principle: a request to /v1 should never wait on anything the dashboard can slow down. Today the database is the shared dependency, so the work is to take the API's request path off it and take the dashboard's heavy reads off the primary. In order of value:

StepWhat it isolatesTrade-offsCost
1. Dashboard and analytics reads on a read replica Most dashboard and admin load (listings, usage pages, admin search) moves off the primary, so a heavy page or report cannot slow API accounting. Replica data is a few seconds behind, so pages that show what the user just saved must read the primary. Writes still go to the primary. A second database instance of similar size: roughly the primary's compute again (about $1k/month at the current size by list price, to be confirmed) plus its disk.
2. API request path off Postgres The serving path stops waiting on Postgres: key checks and catalog come from worker memory, balances from the in-memory ledger (already the case), captures stored independently (with !1147), accounting written back asynchronously (already the case). A slow database then delays reports, never requests. More state held in memory must be kept fresh and correct; a cache bug can serve stale permissions. Needs careful invalidation and tests. Engineering time only (weeks). No new infrastructure.
3. Analytics in ClickHouse, not Postgres Nightly checks, usage rollups and admin analytics stop scanning the request ledger on the primary (the cause of tonight's slow dashboard, and a large share of daily database time). Two stores to keep consistent; ClickHouse data lags the ledger by minutes. Small: ClickHouse is already paid for; some extra storage and export volume.
4. Separate deploy units The record writer as its own Porter app, and separate edge apps for /v1 and the dashboard relay, so one deploy or one overloaded edge cannot affect the other. More apps to deploy and monitor; network-policy and secret wiring. A few more small pods: tens of dollars a month.
5. Separate databases (only if 1 to 4 are not enough) Full isolation: serving and money in one database, dashboard and product data in another. The biggest change by far: no transactions across the two, data that spans both (organizations, keys, credits) must be duplicated or synced, and a long migration. A second primary database (similar to step 1) plus months of engineering.

Recommendation: do steps 1 to 4. Together they give practical isolation (a slow dashboard or a heavy report can no longer affect API requests) at the cost of one read replica and a few weeks of work. Step 5 is only worth it if the API still feels dashboard load after that, which is unlikely once the request path no longer waits on Postgres.

Release order

Each release is watched to the end by whoever starts it. Nothing ships until the stuck release record from this incident is cleared, which also unblocks catalog publishing (blocked since 18:24 UTC because the gateway runs a release that was never promoted).

  1. Next release (main): the !1124 revert (!1141), the breaker fix (!1142), the scaling floor (!1144), accounting isolation (!1146), capture never blocking the API (!1147), the geo-country change (!1150), the cron fixes (!1154), the vacuum fix (!1180), and the release-check fix for the new writeback service (!1182, without which this release would have jammed exactly like the 11-11 one), plus other teams' merged work.
  2. The :free listing done properly, with admission tiers (platform-4a's combined MR, replacing !1149 and !1143).
  3. !1152: Gemini Decisions.
  4. !1145: fast rollback (shipped off), then its index, a staging rehearsal, and colors turned on in production.