Horizon production outage: migration lock queue behind a 42-hour runaway query
| Date | 2026-07-28 |
| Severity | SEV-2 — site-wide degradation |
| Outage window | 6:14:09 – 6:22:43 PM PT (01:14–01:22 UTC Jul 29) |
| Duration | 8m 34s |
| Detected by | User report (Wyatt), 6:19 PM PT |
| Prod breaker | Scott — identified the next day (see Attribution) |
| Status | Resolved — action items open |
| Author | Wyatt Marshall |
Summary
A production deploy at 6:14 PM PT ran the HAL-7101 phase-1 Prisma migration
(20261218000000_add_fit_status_derivation, commit e90a7437). Its
ALTER TABLE on horizon.people requested an ACCESS EXCLUSIVE lock that
queued behind an ad-hoc analytics query that had been running against that
table for 42 hours. In Postgres, a queued exclusive lock blocks all new
readers, so every query touching horizon.people — which the root layout hits
on every server render — stacked up behind the stalled migration. The
connection pool exhausted within seconds and the site returned errors
("Something went wrong" boundary pages and API 500s) for roughly 8.5 minutes.
Recovery: the blocking backend was terminated with
pg_terminate_backend(1157); the migration acquired its lock and completed
within seconds (6:22:43 PM), the lock queue drained to zero, and page loads
returned to ~0.4–0.6s. The migration was additive-only; no data was lost or at
risk.
Impact
- All SSR routes and most APIs failed or hung for ~8.5 minutes — the root
layout's session refresh reads
horizon.people, so the block cascaded site-wide (/editor,/dashboard,/api/problems/*,/api/runs/*, ~55 routes total). - Vercel error clusters show a dense storm of
timeout exceeded when trying to connect(pg-pool via Prisma) confined to 6:14:09–6:19:59 PM, including two[RootLayout]failure clusters (~314 occurrences, ~48 distinct users) that exist only inside the outage window. - Editor sessions in flight saw the Next.js error boundary (e.g. reference
3866094746); Yjs collaborative-editing auth (/api/collab/auth) also failed during the window. - Secondary, pre-existing impact: the runaway query contributed to the prod DB
(
status-tracker-db, db.t3.large) running at 85–100% CPU with a CPU credit balance of zero for 12+ hours before the incident.
Timeline (PT)
| When | What happened |
|---|---|
| Jul 27, 12:17 AM | A postgres.js client connects from a residential Comcast IP (73.189.218.13, San Francisco) as the shared statusadmin user and starts an ad-hoc CTE query over horizon.people (training-cohort members since 7/14 without a Halluminate email). It runs continuously — PID 1157. |
| Jul 27–28 | DB CPU pegged at 85–100%; CPU credits exhausted. No alert fires. |
| Jul 28, 6:14:00 PM | Outage begins. Prod deploy runs the HAL-7101 migration (PID 10231). Its ALTER TABLE horizon.people queues behind PID 1157's lock; all new readers of the table queue behind the migration. |
| 6:14:09 PM | Connection-timeout error storm begins across ~55 routes. |
| 6:19:30 PM | Wyatt hits the error boundary on /editor and reports it. Investigation begins. |
| ~6:21 PM | RDS confirmed healthy (status available, no events, memory normal) — ruling out a crash. pg_stat_activity + pg_blocking_pids() identify PID 1157 as the root blocker, with the migration blocking dozens of queued reads (~294 connections at peak). |
| 6:22:3x PM | pg_terminate_backend(1157) executed (with explicit approval). |
| 6:22:43 PM | Recovery. Migration completes (finished_at 01:22:43 UTC); lock queue drains to zero; /editor loads in 0.4–0.6s. |
How it happened
- Why did users see error pages? Server renders and API calls timed out
waiting for a database connection (pg-pool
timeout exceeded when trying to connect). - Why did the pool exhaust? Every query touching
horizon.peoplehung indefinitely, holding its connection; new requests could not get one. - Why did those queries hang? The migration's queued
ACCESS EXCLUSIVElock blocks all new lock acquisitions on the table — Postgres grants locks in order, so readers queue behind a waiting exclusive lock. - Why was the migration stuck waiting? A 42-hour-old ad-hoc query held a
conflicting lock on
horizon.peopleand never finished. - Why could this wedge production? Nothing bounds either side: Prisma
migrations run with no
lock_timeout, and the database has nostatement_timeoutor long-query alerting that would have killed or surfaced the runaway query during the preceding 42 hours.
Root cause: deploy-time DDL with unbounded lock waits, colliding with an
unbounded ad-hoc query on a shared production credential. Either guard alone —
a lock_timeout on migrations, or a statement/idle timeout on interactive
sessions — would have prevented the outage.
Attribution
Not conclusively identified. What we know about the blocking session:
- Client library
postgres.js(the porsager npm driver) — not used anywhere in this repo, so it was a personal script or external tool, not deployed application code. - Source IP
73.189.218.13: Comcast residential, San Francisco. Not Wyatt's current IP. No connections from that IP remain. - Connected Monday, Jul 27 at 12:17 AM PT as
statusadmin— the single shared credential every engineer'sPROD_DB_URLuses, so the database cannot distinguish who it was. - The query analyzed training-cohort onboarding (people entering training after 7/14 without a Halluminate email), which suggests someone working on training/onboarding tooling.
Resolved the next day: it was Scott, who had kicked off the query from his
laptop. Notably, the database never told us — attribution came from asking
humans. The trail above is exactly as far as the system itself could take us,
which remains the finding: with every engineer connecting as statusadmin,
client_addr is the only identity signal we have (see action item 4).
Blameless framing: Scott did nothing unreasonable — a long analytical read on prod is normal work, and the query hanging for 42 hours was as invisible to him as to everyone else. The system allowed a routine query and a routine deploy to combine into a site-wide outage.
What surprised us
- A lock you're merely waiting for can take down the site. The migration
never got its
ACCESS EXCLUSIVElock — it didn't need to. A queued exclusive lock blocks every new reader behind it, so the outage was caused by a statement that was doing literally nothing. - A query ran on prod for 42 hours and nothing noticed. Not the CPU pegged at 85–100%, not the credit balance at zero, not the query itself. Our mental model was "something would tell us"; the reality was "nothing is configured to."
- The database couldn't answer "who ran this?" The client library
(
postgres.js) appears nowhere in our repo, and everyone connects asstatusadmin, so attribution dead-ended at a residential IP. We assumed connection metadata identified people; it identifies a shared password. (It was Scott — but we learned that by asking around the next day, not from the system.)
What went well / what went poorly
Went well
- Diagnosis to recovery in ~3 minutes from first investigation:
pg_blocking_pids()pinpointed the root blocker immediately. - The migration was additive-only ("expand" phase), so unblocking it carried no data risk and it completed in seconds.
- Root-layout fail-open guards (from the HAL-5695 work) kept some pages rendering degraded rather than blank.
Went poorly
- A query ran for 42 hours on prod, burning a core the whole time, with zero alerting — on runtime, on CPU pegged at 85–100%, or on the CPU credit balance sitting at zero.
- Detection was a human hitting an error page, not monitoring.
- Migrations run unguarded during deploys: no
lock_timeout, no retry, no pre-flight check for conflicting long-running locks. - The error boundary exposed only an opaque reference number; correlating it to the server-side cause required log archaeology.
Action items
| # | Action | Priority | Ticket |
|---|---|---|---|
| 1 | Run Prisma migrations with a lock_timeout (~5–10s) and retry, so a blocked DDL fails fast instead of wedging the site |
P0 | HAL-7271 |
| 2 | Set database-level statement_timeout and idle_in_transaction_session_timeout for interactive use (exempting the migration path as needed), codified in Pulumi rather than live-only config |
P0 | HAL-7272 |
| 3 | Alert on long-running queries (>15 min) and on CPUCreditBalance = 0 for status-tracker-db, plus an external uptime check |
P1 | HAL-7273 |
| 4 | Per-engineer database credentials (or at minimum distinct roles for humans vs. the app) so sessions are attributable beyond client_addr |
P1 | HAL-7274 |
| 5 | Investigate residual CPU saturation now that the runaway query is dead; if CPU stays pegged, open a separate capacity/query investigation | P1 | Resolved — CPU fell from 85–100% to 25–39% within hours of the kill; the runaway query was the load |
| 6 | Housekeeping surfaced during investigation: Slack notification cron failing with channel_not_found (~1,150 errors / 3h) and env-linter cron missing its Linear API key |
P2 | HAL-7275, HAL-7276 |
Evidence: RDS CloudWatch metrics (status-tracker-db), pg_stat_activity
captures, Vercel runtime error clusters, _prisma_migrations records.