Larkspur Systems · Platform Engineering
Root cause analysis

Dispatch API partial outage in the EU region, 2026-08-27

A migration added a column that a new assignment query filtered on without an index. Under afternoon peak the query scanned the full jobs table, exhausted the database connection pool, and 38% of assignment requests failed for 41 minutes.

Incident
INC-2026-0827
Severity
SEV-2 customer-visible degradation, one region
Author
Priya Natarajan, SRE lead
Reviewers
Ines Fontaine (Eng. Manager, Dispatch), Tomasz Wierzbicki (Backend)
Date
2026-09-02
Status
Completed
Version
1.2

Issue summary

Between 14:02 and 14:43 UTC on 2026-08-27, the Dispatch API in the EU region returned HTTP 503 for 38% of POST /v2/jobs/{id}/assign requests and for 11% of all other write requests. Technicians using the mobile app in the region could not receive new assignments; dispatchers saw "assignment pending" for jobs they had already routed. Reads were unaffected. No data was lost or corrupted.

Deploy 2026.08.27-3 shipped migration 0412_add_jobs_assigned_at and a new query in the assignment service that filtered on the new column. The column had no index. At EU afternoon peak the query took 2.1 to 4.8 seconds per call, held connections for the duration, and saturated the 60-connection pool. Mitigation was an index created concurrently at 14:31 UTC; the pool recovered by 14:38 UTC.

41 min
Duration of impact
38%
Assignment requests failed
1,912
Technicians affected
214
Customer accounts affected
0
Records lost or corrupted
Detection gap

The fault began at 14:02 UTC. The first alert fired at 14:06 UTC and was acknowledged at 14:11 UTC. Nine minutes elapsed between fault and acknowledgement; the pool-saturation threshold of 95% is addressed in action A-3.

Timeline

All times UTC. Sources: deploy log, PagerDuty timeline, incident channel #inc-2026-0827.

  1. PR #4187 merged: adds jobs.assigned_at and a query in AssignmentService.pending_for_region() filtering WHERE assigned_at IS NULL AND region_id = $1. Review noted the column; nobody noted the missing index.

  2. Deploy 2026.08.27-3 to canary (US-West, 5% traffic). Canary p95 for the assignment endpoint rises from 140 ms to 210 ms; within the 300 ms budget, so promotion continues.

  3. Deploy reaches EU. The jobs table in EU holds 9.4 million rows versus 1.1 million in US-West canary.

  4. EU afternoon peak begins. Assignment query latency climbs to 2.1 to 4.8 s. First 503 responses as the connection pool (60 connections, 30 s checkout timeout) fills.

  5. Alert DispatchDBPoolSaturation > 95% for 3m fires and pages the on-call.

  6. On-call acknowledges. Incident channel opened; SEV-2 declared at 14:14.

  7. pg_stat_statements shows one query accounting for 71% of total execution time since 14:00 (see RCA-004). Query plan confirms a sequential scan on jobs.

  8. Decision: create the index concurrently rather than roll back, because rollback would also revert migration 0412 and the mobile client already writes the new field. Estimated index build 3 to 5 minutes.

  9. CREATE INDEX CONCURRENTLY jobs_region_unassigned_idx ON jobs (region_id) WHERE assigned_at IS NULL; started.

  10. Index build completes. Query latency drops to 8 ms. Pool checkouts begin to succeed.

  11. Pool utilisation below 60%. Mobile clients retrying with backoff drain the backlog.

  12. Error rate below 0.1% for five consecutive minutes. Impact window closed.

  13. Incident closed. Same index applied to US-West, US-East, and APAC ahead of their peaks.

  14. Migration 0413 adds the index declaratively so fresh environments match production.

Root cause findings

Five whys

  1. Why did assignment requests fail?The application could not obtain a database connection within the 30 s checkout timeout and returned 503.
  2. Why were no connections available?Each call to pending_for_region() held a connection for 2 to 5 s instead of milliseconds, so 60 connections served roughly 15 requests per second against a peak demand of 40.
  3. Why was the query slow?It filtered on assigned_at IS NULL, a column added in the same deploy with no supporting index, forcing a sequential scan of 9.4 million rows.
  4. Why did the missing index reach production?Code review has no checklist item for query plans, and the canary region's table is one ninth the size of EU's, so the canary stayed inside its latency budget.
  5. Why does canary not represent production data volume?Canary was designed to catch crashes and error-rate regressions, not data-dependent performance; no stage in the pipeline runs new queries against a production-sized dataset.

Findings

RCA-001

New query filtered on an unindexed column

Root cause
Component
dispatch-api · AssignmentService.pending_for_region() · migration 0412
Significance
Directly produced the 2 to 5 s query time that exhausted the pool. Without it, the remaining findings would not have caused an outage.

The query SELECT id, customer_id, window_start FROM jobs WHERE assigned_at IS NULL AND region_id = $1 ORDER BY window_start LIMIT 200 used the existing jobs_region_idx to narrow by region, then filtered 1.6 million EU rows by assigned_at on the heap. A partial index on (region_id) WHERE assigned_at IS NULL reduces the candidate set to the roughly 3,000 unassigned jobs at any moment.

RCA-002

Pool checkout timeout amplified the failure

Contributing factor
Component
dispatch-api connection pool configuration (DB_POOL_SIZE=60, DB_POOL_TIMEOUT=30s)
Significance
Requests waited the full 30 s before failing, so mobile clients held open sockets and dispatch consoles appeared frozen rather than showing a fast error and retrying.

A 30 s checkout timeout suits batch jobs, not an interactive endpoint with a 300 ms budget. With a 2 s timeout the same fault would have produced faster failures, a clearer error signature, and roughly 40% fewer queued requests during the window.

RCA-003

Canary data volume does not represent the largest region

Contributing factor
Component
Deploy pipeline · canary stage (US-West, 5%)
Significance
The regression was measurable in canary (p95 140 ms to 210 ms) but stayed within budget because the table was small. Promotion proceeded automatically.

The canary budget is absolute (300 ms) rather than relative. A relative rule ("p95 must not rise more than 25% versus the previous deploy") would have held promotion for review.

RCA-004

pg_stat_statements during the window

Evidence
Evidence type
Database query statistics
Source
EU primary, captured 14:19 UTC by the on-call
queryid      calls  total_exec_time  mean_exec_time  rows
-----------  -----  ---------------  --------------  ------
8812043771    1147      3,204,118 ms      2,793.5 ms  229,400   <- pending_for_region
2210984510   48211        401,022 ms          8.3 ms   48,211
...
Seq Scan on jobs  (cost=0.00..412884.10 rows=3121 width=32)
  Filter: ((assigned_at IS NULL) AND (region_id = 'eu'))
  Rows Removed by Filter: 1,598,204
RCA-005

Alert fired four minutes after the fault began

Evidence
Evidence type
Metric and alert timeline
Source
Grafana panel Dispatch DB pool; PagerDuty incident P-77120

Pool utilisation crossed 80% at 14:02:40 and 95% at 14:03:10. The alert requires 95% for three minutes, so it fired at 14:06:10. An 80%-for-two-minutes warning would have fired at 14:04:40 with a lower-urgency page.

Impact analysis

Systems affected

  • Dispatch API (EU): 38% of assignment writes and 11% of other writes returned 503 for 41 minutes.
  • Technician mobile app: assignment feed stale; the app retried with backoff and recovered without user action.
  • Dispatcher console: "assignment pending" state; no incorrect assignments were displayed.
  • Other regions: none. Reporting and billing unaffected.

Users affected

  • 1,912 technicians in the EU region did not receive new assignments during the window; 640 of them had at least one job whose start time fell inside it.
  • 214 customer accounts; 31 contacted support. Two accounts on premium SLAs are eligible for credits (Finance notified 2026-08-28).

Data impact

  • Modified: no. Failed writes were rejected before any statement ran.
  • Lost: no. Clients retried; 100% of queued assignments landed by 14:52 UTC, verified by reconciling client request IDs against jobs.assigned_at.
  • Corrupted: no.

Corrective actions

#ActionAddressesOwnerDueStatus
A-1Add partial index jobs_region_unassigned_idx in all regions; add migration 0413RCA-001Tomasz W.2026-08-28Done
A-2Lower DB_POOL_TIMEOUT to 2 s for interactive services; keep 30 s for batch workersRCA-002Priya N.2026-09-05Done
A-3Add 80%-for-2-minutes warning alert on pool utilisation; keep the 95% pageRCA-005Priya N.2026-09-05Done
A-4Canary promotion rule: hold if p95 rises more than 25% versus the previous deploy, in addition to the absolute budgetRCA-003Ines F.2026-09-19In progress
A-5CI job runs EXPLAIN for new or changed queries against a production-sized snapshot and fails on sequential scans over tables above one million rowsRCA-001, RCA-003Tomasz W.2026-10-03Planned
A-6Add "query plan reviewed" to the PR template for changes touching db/ or repository classesRCA-001Ines F.2026-09-12Done

Gaps and open questions

GAP-001

Whether the 11% failure rate on non-assignment writes shared the same cause

Open
Description
Other write endpoints use the same pool, so pool exhaustion explains most of it, but PATCH /v2/jobs/{id} shows a 14:00 to 14:02 latency rise that predates saturation.
Impact
Low. Does not change the root cause; may reveal a second slow query.
Resolution
Yuki Tanabe to replay the window from query logs by 2026-09-09.

Assumptions

ASM-001

Client retry reconciliation is complete

Assumption
Every assignment attempted during the window was retried by the mobile client and landed by 14:52 UTC.
Impact if wrong
Some jobs would show no technician despite a dispatcher having routed them; support would see "unassigned" complaints after 14:52.
Validation
Reconciled 100% of client request IDs logged between 14:00 and 14:45 against jobs.assigned_at; zero unmatched. Support ticket search for the following 48 hours found none.
Related items
RCA-002