Skip to content

Incident: 502s after a deploy (pool exhaustion, missing FK index)

Same page, two causes: the state column tells held connections from a slow new query; remove the query first, then index its foreign key.

Predict first

Before you read: predict

PAGE · ECON-201, minute 14 · ExamHall's attempt API answers autosaves with 503s, and the ingress returns 502s · database CPU 31%

Release 2026.09.26-1 went out at minute 12. Its whole diff on the autosave route is one statement, run on its own after the answer is saved, for the "answered 17/40" badge:

The two tables, as created:

CREATE TABLE attempts (id bigint PRIMARY KEY, exam_id text NOT NULL);
CREATE TABLE answers (id bigint PRIMARY KEY, attempt_id bigint NOT NULL REFERENCES attempts (id), question_no int NOT NULL, body text);

answers holds 1,200,000 rows, every sitting's answers. Each of the 6 API pods has a pool of 10 connections. The on-call engineer groups the API's 60 connections in pg_stat_activity by state and wait type.

What does she see?

Code for this question
const { rows } = await pool.query('SELECT count(*) FROM answers WHERE attempt_id = $1', [attemptId]);
Pick one

The 30-second version

After a deploy, a 503 is the app's pool giving up and a 502 is the ingress losing a restarted pod. Diff the release, then group pg_stat_activity: idle in transaction means held connections, active on one new statement means a slow query. Confirm with its plan on production-sized data. Remove the statement first, then index.

Same page, two causes: the state column tells held connections from a slow new query; remove the query first, then index its foreign key.

How it works

Step 1 of 12

Two codes, two layers. 503 is the app's pool rejecting a waiter; 502 is the ingress losing a pod the kubelet restarted.
In the kitchen
  • the floor manager sends home anyone holding a slip for two minutes → the acquire timeout: 503 (step 1)
  • the market office phones each hour; only a free butcher answers; after three missed calls it closes the counter and reopens it, and customers mid-order leave with nothing → liveness through the pool, restart, 502 (step 1)

Analogy: A market butcher's counter

Ten butchers serve one customer each; the rest take a numbered slip and wait. A new rule says every label must print "bought here this month", so each butcher leafs through the month's order book, which is filed by date, not by customer.

At the counter In the incident
ten butchers, one customer each the pod's ten pooled connections
slips in hand, waiting in line the pool's queue in the app
leafing through the whole order book for every label the count, a Seq Scan on the unindexed foreign key (steps 8–9)
the floor manager sends home anyone holding a slip for two minutes the acquire timeout: 503 (step 1)
the market office phones each hour; only a free butcher answers; after three missed calls it closes the counter and reopens it, and customers mid-order leave with nothing liveness through the pool, restart, 502 (step 1)
butchers bent over the book, not chatting with a customer who has wandered off active on one statement, not idle in transaction (steps 3–4)
the manager stops printing the line turning the feature off (step 10)
a card box of orders by customer the index on the foreign key (step 11)

Breaks down when: a butcher can tell a waiting customer how long it will be; a pool cannot, and the office would ask what is wrong before closing the counter, while the kubelet only counts missed probes.

Contrasting case: a release whose new code waits on another service inside a transaction. The same 503s and 502s appear, but its connections read idle in transaction, no new statement is slow, and an index changes nothing (db-connection-pooling failure mode 2).

In real terms: after a deploy, read the state column before you read the plan, and stop the new statement before you index it.

Examples, run for real

4.1 ExamHall EXH-2930: the incident console at minute 14

Release 2026.09.26-1 adds the badge's count to every autosave. On a copy with the stem's tables and 1,200,000 answers, the console's two database tools, then one pod on the model:

4.1 PostgreSQL 17.11 · the stem's attempts and answers · answers 1200000 rows · release 2026.09.26-1 sends: SELECT count(*) FROM answers WHERE attempt_id = $1
4.1 pg_stat_activity, state | wait type | statement | connections: active | - | SELECT count(*) FROM answers WHERE attempt_id = $1 | 60
4.1 EXPLAIN | Aggregate (actual rows=1 loops=1)
4.1 EXPLAIN |   Buffers: shared hit=25532
4.1 EXPLAIN |   ->  Seq Scan on answers (actual rows=17 loops=1)
4.1 EXPLAIN |         Filter: (attempt_id = 4711)
4.1 EXPLAIN |         Rows Removed by Filter: 1199983
4.1 EXPLAIN |         Buffers: shared hit=25532
4.1 EXPLAIN | Planning:
4.1 EXPLAIN |   Buffers: shared hit=8
4.1 the scan read 1200000 rows to keep 17
4.1 model, one pod for the first minute after the release (25 autosaves/s, pool 10, connectionTimeoutMillis 2000, hold 1800 ms, liveness SELECT 1 through the pool every 10 s, timeout 1 s, threshold 3): arrived 1375 · 200 320 · 503 995 · 502 60 · restarts 1 · first restart at 31 s

The connections are busy in the new statement, not waiting between statements (steps 3–4), and its plan reads the whole table to keep 17 rows (step 8). On the pod, the queue turns into 503s within seconds and the kubelet's restart into 502s (step 1).

4.2 Payments ledger MR !3230: the index that ends the scan

A wallet ledger shipped the same mistake: a balance badge reads SELECT sum(amount_cents) FROM ledger_entries WHERE account_id = $1, and account_id references accounts. MR !3230 builds the index on its own, CREATE INDEX CONCURRENTLY, while entries keep arriving:

4.2 PostgreSQL 17.11 · ledger_entries 240000 rows · before: Seq Scan on ledger_entries (actual rows=17 loops=1) · Rows Removed by Filter 239983
4.2 MR !3230 · CREATE INDEX CONCURRENTLY ledger_entries_account_id_idx ON ledger_entries (account_id), on its own connection · inserts that committed while it built: at least 1 · inserts that failed: 0 · index valid: true
4.2 after: Index Scan using ledger_entries_account_id_idx on ledger_entries (actual rows=17 loops=1) · Rows Removed by Filter 0
4.2 the plan now reads the account's entries only: true

With the index, the plan reads the account's entries and not the table (step 11), and the build let every insert through.

Try it

Answer first, then check. Nothing is saved.

Question 1 of 7

A payments ledger's release 4.2 adds one read to every balance request:

SELECT sum(amount_cents) FROM ledger_entries WHERE account_id = $1;

ledger_entries was created with account_id bigint NOT NULL REFERENCES accounts (id) and a primary key on id, and has no other index. Today it holds 1,500,000 entries and it gains 120,000 a day; no account holds more than 400 of them. On the production database one query scans 4,000,000 rows a second. Each API pod serves 20 balance requests a second with a pool of 10 connections, and nothing else keeps a connection for long.

Counting today as day 0, from which day does a pod need more than 10 connections busy at once, on average?

Pick one

Question 2 of 7

A music service's release 12.4 shows a "plays this week" badge on every player heartbeat; its count makes each heartbeat keep its database connection for 1,600 ms instead of 8 ms. Each of the 6 API pods: a pool of 10 with connectionTimeoutMillis: 2000, 25 heartbeats a second, and a liveness probe whose handler runs SELECT 1 through the same pool (periodSeconds: 10, timeoutSeconds: 1, failureThreshold: 3). Heartbeats have failed with 503s and 502s for 5 minutes, during a live concert. Every change below except ending the counts reaches the pods by replacing them, one every 30 s.

Which change leaves no heartbeat failing from 3 minutes after it starts?

Pick one

Question 3 of 7

A chat service's release 7.3 adds an unread badge to every message send. A send now runs, in order:

SELECT user_id FROM sessions WHERE id = $1;
SELECT user_id FROM sessions WHERE id = $1;
INSERT INTO messages (id, room_id, author_id, body) VALUES ($1, $2, $3, $4);
SELECT count(*) FROM messages WHERE room_id = $1;

The first lookup is the login check and the second the rate limiter. messages.room_id references rooms (id); messages and sessions have only their primary keys. messages holds 200,000 rows and sessions 5,000. The on-call engineer resets pg_stat_statements, lets 500 sends run, then lists its rows by shared_blks_hit + shared_blks_read, largest first.

Which statement is on the top row?

Pick one

Question 4 of 7

A ticketing service's release 5.1 adds one statement to every seat hold, run on its own after the hold is saved:

SELECT count(*) FROM holds WHERE event_id = $1;

holds.event_id references events (id), holds has only its primary key, and it holds 1,200,000 rows. The rolling update has replaced 3 of the 6 API pods; each pod has a pool of 10 and puts its name in application_name. Holds now fail with 503s. The on-call engineer groups pg_stat_activity by pod and state, and each group by wait type and statement.

What does she see?

Pick one

Question 5 of 7

In the interview you are given EXH-2930's page and asked: "Users see 502s right after a deploy.

Walk me through it." What do you answer, in at most 150 words?

Question 6 of 7

An ExamHall release adds a proctor dashboard that shows, for each candidate in a sitting, how often they left the exam tab. Each refresh sends one statement:

SELECT a.id, (SELECT count(*) FROM focus_events f WHERE f.attempt_id = a.id) AS tab_leaves FROM attempts a WHERE a.exam_id = $1;

focus_events.attempt_id references attempts (id), and each table has only its primary key. In review, the team ran EXPLAIN (ANALYZE, BUFFERS, COSTS OFF, TIMING OFF, SUMMARY OFF) on it for ECON-201, on a copy of production. The server runs with parallel query off.

Seq Scan on attempts a (actual rows=40 loops=1)
  Filter: (exam_id = 'ECON-201'::text)
  Rows Removed by Filter: 1960
  Buffers: shared hit=76451
  SubPlan 1
    ->  Aggregate (actual rows=1 loops=40)
          Buffers: shared hit=76440
          ->  Seq Scan on focus_events f (actual rows=12 loops=40)
                Filter: (attempt_id = a.id)
                Rows Removed by Filter: 299988
                Buffers: shared hit=76440

On production one query scans 4,000,000 rows a second, and each API pod serves 2 refreshes a second.

How many of a pod's connections do the refreshes keep busy, on average, to two significant figures?

Pick one

Question 7 of 7

A wallet's transfers have failed with 503s since 10 minutes after release 9.0 went out, and database CPU is 22%. The 4 API pods have pools of 10. The on-call engineer groups the API's 40 connections in pg_stat_activity by state, wait type, wait event and query:

state | wait_event_type | wait_event | query | count
idle in transaction | Client | ClientRead | SELECT sum(amount_cents) FROM ledger_entries WHERE account_id = $1 | 40

What does release 9.0's transfer do?

Pick one

Go deeper

Pro tips
  1. Split the status codes first. 503 is the app giving up on the pool; 502 is the proxy losing the pod. Two codes, two mechanisms, one cause (curriculum row's own tip). When: the first minute of any page with both codes. Status: EXECUTED (model).
  2. Group, don't scroll, pg_stat_activity. SELECT state, wait_event_type, left(query, 60), count(*) FROM pg_stat_activity WHERE application_name LIKE 'api-%' GROUP BY 1, 2, 3 ORDER BY 4 DESC; answers "held, slow or locked" in one screen. When: steps 3–4, before any EXPLAIN. Status: EXECUTED.
Source

PostgreSQL, Kubernetes and node-postgres behaviour can change: the facts below are as of 2026-10-06. No item is keyed on a source; every key is executed. Valid as of 2026-10-06.

  • PostgreSQL 17 documentation, 5.5 Constraints, Foreign Keys: https://www.postgresql.org/docs/17/ddl-constraints.html (pg-ddl-constraints.txt), the curriculum row's first source. Quoted: "the declaration of a foreign key constraint does not automatically create an index on the referencing columns." Supports steps 8 and 11.
  • PostgreSQL 17 documentation, 14.1 Using EXPLAIN: https://www.postgresql.org/docs/17/using-explain.html (pg-using-explain.txt), the row's second source. Its examples print Rows Removed by Filter under a scan's Filter line. Quoted: "the loops value reports the total number of executions of the node, and the actual time and rows values shown are averages per-execution." Supports steps 8 and 9.
  • PostgreSQL 17 documentation, 27.2 The Cumulative Statistics System (pg_stat_activity): https://www.postgresql.org/docs/17/monitoring-stats.html (pg-monitoring-stats.txt). Quoted: "active: The backend is executing a query."; "idle in transaction: The backend is in a transaction, but is not currently executing a query."; query "Text of this backend's most recent query. If state is active this field shows the currently executing query. In all other states, it shows the last query that was executed."; wait_event_type "The type of event for which the backend is waiting, if any; otherwise NULL." Supports steps 3–6.
  • PostgreSQL 17 documentation, F.32 pg_stat_statements: https://www.postgresql.org/docs/17/pgstatstatements.html (pg-stat-statements.txt). Quoted: shared_blks_hit "Total number of shared block cache hits by the statement"; shared_blks_read "Total number of shared blocks read by the statement". Supports step 7.
  • Kubernetes documentation, Configure Liveness, Readiness and Startup Probes: https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/ (k8s-probes.txt), the row's third source, and Liveness, Readiness and Startup Probes: https://kubernetes.io/docs/concepts/configuration/liveness-readiness-startup-probes/ (k8s-probes-concept.txt), where the field definitions now live. Quoted: "If the command returns a non-zero value, the kubelet kills the container and restarts it."; failureThreshold "After a probe fails failureThreshold times in a row, Kubernetes considers that the overall check has failed"; timeoutSeconds "Defaults to 1 second"; "Incorrect implementation of liveness probes can lead to cascading failures." Supports step 1 and the model's probe rules.
  • RFC 9110, HTTP Semantics, §15.6.3–15.6.4: https://www.rfc-editor.org/rfc/rfc9110.html (rfc9110.txt). Quoted: 502 "indicates that the server, while acting as a gateway or proxy, received an invalid response from an inbound server"; 503 "indicates that the server is currently unable to handle the request due to a temporary overload". Supports step 1.
  • node-postgres, pg.Pool: https://node-postgres.com/apis/pool (node-postgres-pool.txt). Quoted: connectionTimeoutMillis "By default this is 0 which means no timeout"; calls "will timeout with an error or hang indefinitely if you have connectionTimeoutMillis configured to 0." The queue rule itself is read from pg-pool 3.14.0's source (index.js l.206–231). Supports the model.
  • Node.js documentation, process, Signal events: https://nodejs.org/api/process.html (node-process.txt). Quoted: "'SIGTERM' and 'SIGINT' have default handlers on non-Windows platforms that reset the terminal mode before exiting with code 128 + signal number." Supports the model's restart rule.

Checked against the tools and docs as of 2026-10-06.