Halluminate prod status

← back to the counter

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

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

  1. 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).
  2. Why did the pool exhaust? Every query touching horizon.people hung indefinitely, holding its connection; new requests could not get one.
  3. Why did those queries hang? The migration's queued ACCESS EXCLUSIVE lock blocks all new lock acquisitions on the table — Postgres grants locks in order, so readers queue behind a waiting exclusive lock.
  4. Why was the migration stuck waiting? A 42-hour-old ad-hoc query held a conflicting lock on horizon.people and never finished.
  5. Why could this wedge production? Nothing bounds either side: Prisma migrations run with no lock_timeout, and the database has no statement_timeout or 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:

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

What went well / what went poorly

Went well

Went poorly

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.