25 min read

Drill 10 — The Primary Database Dies

A checkout service on Postgres, where every lost write is a customer who paid for nothing

Time box: 45 minutes. Uses: Chapter 2 — Retries and Idempotency, Chapter 5 — Replication and Failover, Chapter 7 — Data Safety, Chapter 8 — Observability and Recovery. Background: SD 2 Ch 5 — Leader-follower replication, SD 1 — Replication: WAL, lag and read consistency, SD 1 — Leader election and Raft, Q26 — Idempotency for write APIs, SD 2 Ch 23 — Payment processing. Sims: replication-lag (Failover), raft (Partition), cap-partition (CP: majority quorum).


How to run this drill

Draw your baseline from the prompt first. Then, for each break, say your answers to "Before you read on" out loud, step through the timeline or run the sim, and only then open the answer. Every answer runs detect → contain → degrade → recover → prevent.

This drill is different from the cache drill in one way. A cache that fails loses speed; you can always rebuild it from the database. A primary that fails can lose facts: an order that a customer was told exists, for money that has already left their card. So the question behind every break is: which writes could we lose, and how would we know?


The prompt

"Design the checkout service for a marketplace. A customer pays for a cart, we take the money through a payment provider and record the order. Black Friday peak is 300 checkouts per second. The business rule is: never lose an order the customer was told about, and never charge twice. Then I'm going to start breaking things."

QuestionAnswer
Peak rate300 checkouts/s; average order $60, so $18,000 of orders per second at peak
What a checkout doesTransaction 1: create the order as pending, reserve stock. Then charge the card at the provider (p50 400 ms, p99 1.5 s). Transaction 2: mark the order paid, write ledger rows and an outbox row for the confirmation email
DurabilityAn order the customer saw as "confirmed" must survive any single failure
Availability target99.95%: about 22 minutes of downtime a month
WhereOne cloud region, three availability zones (AZ-a, AZ-b, AZ-c)

The baseline design

What a decent candidate draws in 15 minutes: one primary, two async replicas, and something that promotes a replica when the primary stops answering. It is not hardened yet.

ComponentChoiceNumbers
API30 Node pods, pg pool of 10 each300 connections to the primary
PrimaryPostgres in AZ-aNormal replica lag 50–200 ms
ReplicasAsync streaming to AZ-b and AZ-c, half the size of the primaryReplica 1 also serves order history; replica 2 runs reporting
FailoverA watchdog pings the primary every 5 s; after 3 misses it promotes replica 1 and points the DNS name at itDetection about 15 s
PaymentProvider called between the two transactions; idempotency key = the order's id from a sequence

Say the in-flight numbers when you draw it, because every break uses them. Little's law: things in flight = arrival rate × time each one takes.

  • Open database transactions: 300/s × ~25 ms of DB time ≈ 8 at any moment.
  • Checkouts waiting on the payment provider: 300/s × 0.4 s ≈ 120 at any moment. These are orders that are pending in the database while money may already be moving.

Break 1: The primary dies mid-transaction

"The primary's host has a kernel panic at peak. It's gone. Tell me what state every in-flight checkout is in, and how long until checkout works again."

Before you read on:

  1. Sort the in-flight checkouts into safe, lost, and unknown. Which group is the dangerous one?
  2. The watchdog promotes in about 20 s. Is that your recovery time?
  3. A customer whose card was charged got a 500 and presses Pay again. What happens?
#
Checkout API
Primary (AZ-a)
Replica 1 (AZ-b)
Payment provider
1
order 1041 pending, COMMIT
committed
has it 120 ms later
1 / 11
Answer

The three states. A transaction that never reached COMMIT (1043) is rolled back: nothing to do. A transaction whose COMMIT was sent but never answered (1042) is unknown: it may have committed on the primary and may or may not have reached the replica. The dangerous group is 1041: the charge succeeded, the order is pending, and the customer saw a 500. Little's law says about 120 checkouts are in that window at any moment.

Why the recovery time isn't 20 s. Detection (3 × 5 s) plus promotion is about 20 s. DNS adds up to 30 s for new connections. But the pods' existing pooled connections point at a host that no longer answers. No RST comes back, so a query on them waits for TCP to give up, which on Linux defaults takes about 15 minutes of retransmissions. The database failed over in 20 s; the application could take 15 minutes.

Detect. Connection errors and query timeouts on every pod; the watchdog's failed pings; checkout success rate below 99%; pending orders older than 2 minutes climbing.

Contain. Make the app fail over as fast as the database: query_timeout and statement_timeout of 5 s on the pg client, TCP keepalives on, connectionTimeoutMillis: 2000 on the pool, and evict a connection from the pool on any network error. Then a dead primary costs each request at most 5 s, and new connections go to the new primary.

Degrade. Checkout returns "we couldn't confirm your payment yet, we'll email you" instead of a bare 500 when transaction 2 fails after a successful charge. The order stays pending for reconciliation. Don't tell the customer to try again.

Recover. A reconciliation job runs every minute: for every order pending for more than 2 minutes, ask the provider for the charge by idempotency key. Charged → mark paid, send the email. Not charged → cancel and release the stock. The provider's webhook does the same thing faster; the job is the safety net for missed webhooks.

Prevent. Model the checkout as a state machine with every state stored before the next side effect: pending → charging → paid (or failed). Write charging before calling the provider, so a crash in between always leaves a row that says "go and ask the provider". Use an idempotency key on the charge that doesn't depend on the database (see the follow-up and Break 2).

The follow-up: "The charged customer sees the error and presses Pay again. What stops you from charging them twice?"

Answer

An idempotency key created before the first attempt: the checkout page gets a checkout_session_id when it loads, and both attempts send it. The server does INSERT INTO orders (..., idempotency_key) ... ON CONFLICT (idempotency_key) DO NOTHING RETURNING id; if the row already exists, it returns that order's current state instead of starting again. The same key goes to the payment provider, and most payment APIs return the original charge for a repeated key instead of charging again (Stripe, for example, keeps keys for at least 24 hours). The second attempt shows "paid". Details in Chapter 2 and Q26.

Note why the key must come from the client session, not from the order's sequence id: Break 2 shows that a sequence can hand out the same number twice after a failover.


Break 2: The promoted replica was 2 seconds behind

"Same crash, but tonight a batch job rewrote 3 million rows an hour ago and replica 1 never fully caught up. When the watchdog promoted it, it was 2 seconds behind. What did you lose?"

Before you read on:

  1. How many checkouts were confirmed in the last 2 seconds? How many of those customers were charged?
  2. Postgres reports write_lag, flush_lag and replay_lag. Which one is data loss, and which one only slows down the promotion?
  3. What would make this loss zero, and what does it cost per commit?

The sim runs the same story at small scale: async replication, the primary crashes at 5 s and the most caught-up replica is promoted 1 s later. Lock in a prediction and press Play, then read the order list and the Confirmed, lost counter. Next, drag Replica 1 lag and Replica 2 lag both to 2000 ms, press Reset and Play: the counter goes from 3 to about 14. Finally switch Commit to Semi-sync and watch Commit wait.

Loading simulation…
Answer

What you lost. With async replication the primary says "OK" as soon as the commit is on its own disk and ships the WAL afterwards. Everything in the last 2 s of WAL existed only on the dead primary: 2 s × 300/s = 600 checkouts, nearly all already charged, about $36,000 taken for orders the database no longer has. Some of their confirmation emails may already have been sent by the outbox relay. In the sim the default 300 ms lag lost 3 confirmed orders; 2 s lost about 14 of the 37 it confirmed.

Which lag is loss. write_lag and flush_lag measure WAL that hasn't reached the replica's disk: that is the data you lose. replay_lag measures WAL that the replica has received but not yet applied. That WAL is not lost: on promotion, Postgres finishes replaying everything it has received before it accepts writes. I checked this on Postgres 18: with replay paused on the replica, three rows written on the primary were missing from replica reads, but after an immediate shutdown of the primary and pg_promote() on the replica, all three were there. Replay lag costs you recovery time, not data. A reporting query on a replica can hold replay back for up to max_standby_streaming_delay (30 s by default), which is one reason the reporting replica shouldn't be a failover candidate.

Detect. On the primary, pg_stat_replication: alert when flush_lag passes 500 ms for a minute. At failover time, record the old primary's last known LSN from monitoring and the promoted replica's received LSN: the difference is the size of the hole.

Contain. Promote the most caught-up replica, never a fixed one (the sim does this). If the old primary's disk survives, start it isolated, with no application access, and copy out the rows written after the fork point before anyone reuses that machine.

Degrade. Checkout stays up on the new primary. The 600 customers' orders are gone from the database but their charges exist at the provider.

Recover. List the provider's charges created in the window (crash time minus 5 s to crash time), match them to orders by checkout_session_id, and for each charge with no order, either recreate the order from the provider's metadata and the salvaged rows, or refund it. This only works if every charge carries the session id as metadata.

Prevent. Synchronous replication for the checkout database: synchronous_commit = on with synchronous_standby_names = 'ANY 1 (replica1, replica2)'. A commit returns only when at least one standby has flushed it, so an acknowledged order survives the loss of any one node: RPO = 0 for a single failure. The cost is one cross-zone round trip per commit, typically 1–2 ms inside a region, and you must decide what happens when both standbys are down: block writes (stay consistent) or fall back to async (stay available). Say which you'd choose for payments: block, and alert.

The follow-up: "You're on the new primary now. A minute later, a customer support agent finds two different customers' orders with the same order ID at the payment provider. How?"

Answer

Sequences repeat after an async failover. The lost 2 s of WAL included sequence advances. The new primary only knows the sequence values that reached it, so it hands out order IDs the old primary already used: the 600 lost checkouts' IDs are issued again to new customers. Postgres pre-logs sequence values in batches of 32, which hides small gaps but not 600. If the provider's idempotency key was order-<id>, a new customer's charge with key order-1041 collides with the lost order's charge, and the provider returns the old customer's charge instead of charging the new one. Fix: idempotency keys and external references come from the client session or a UUID, never from a database sequence. If you need ordered IDs, put a node or epoch component in them.


Break 3: The old primary comes back

"Plot twist: the primary wasn't dead. AZ-a's network to the other zones dropped for 40 seconds. The watchdog in AZ-b promoted replica 1. When the network came back, the old primary was still running, and the 10 pods in AZ-a were still connected to it. What's happening now?"

Before you read on:

  1. How many primaries are accepting writes? Which pods write to which?
  2. What happens to order IDs and to stock counts on the two sides?
  3. What rule would have stopped the old primary from accepting writes while it was cut off?
#
Pods in AZ-a
Old primary (AZ-a)
New primary (AZ-b)
Pods in AZ-b and AZ-c
1
writing
primary
replica
writing
1 / 8

The raft sim shows how a consensus protocol prevents exactly this. The network splits leader S1 and S2 from S3–S5. Lock in a prediction, press Split the network, then Write to new leader when the majority side has elected one, then Heal the network, and watch what happens to the old leader's entries.

Loading simulation…
Answer

What the sim shows. S1 accepted a write but only S1 and S2 had it, 2 of 5, so it never committed and no client was told it succeeded. The majority side elected a new leader in a higher term and committed its writes. On heal, S1 saw the higher term, stepped down, and its uncommitted entry was overwritten. Plain Postgres with a watchdog has none of this: a primary commits on its own disk and doesn't ask anyone. The old primary must be stopped from outside, or must stop itself.

What's happening in the baseline. Two primaries for the whole time nobody notices. The 10 AZ-a pods (a third of the traffic, 100 checkouts/s) write to the old one; their pooled connections never re-resolve DNS, so the DNS change doesn't reach them. Over the 40 s partition plus 3 minutes until a human notices, that's 100 × 220 ≈ 22,000 orders on a primary that will have to be thrown away (Break 4). Both sides hand out the same order IDs: I reproduced this with two local Postgres 18 servers after a promotion, and both returned id = 34 for their next insert. Stock is decremented independently on each side, so the last unit sells twice.

Detect. Alert when more than one node reports pg_is_in_recovery() = false. That alert must come from a monitor that can reach both sides, ideally from a third zone.

Contain. Fence the old primary at once: block its port with the security group or power it off through the cloud API ("STONITH", shoot the other node in the head). Then the AZ-a pods' connections fail and they reconnect to the real primary.

Degrade. For the minutes it takes, accept that a third of checkouts fail rather than land on the wrong primary. A failed checkout can be retried; an order on the wrong primary has to be reconciled by hand.

Recover. Break 4.

Prevent. Replace the watchdog with an HA manager that uses a consensus store (etcd or similar, with 3 members in 3 zones) and a leader lease:

  • The primary's agent must renew the lease, say every 10 s with a 30 s TTL. If it can't reach a majority of etcd, it demotes its own Postgres to read-only before the lease runs out. Patroni works this way.
  • A replica is promoted only after the old lease has expired, so two lease holders never overlap.
  • Applications connect through a proxy (HAProxy or PgBouncer) that routes only to the node whose health endpoint says "I hold the lease". A node that comes back unfenced gets no traffic.
  • On restart, the old primary starts as a replica, never as a primary.

This is the same rule as the sim: a leader that can't reach a majority stops acting as leader.

The follow-up: "Your lease TTL is 30 seconds and the new primary has to wait for it to expire. Isn't that 30 seconds of downtime you just added?"

Answer

Yes, and it is the price of never having two primaries. Recovery time becomes roughly detection + lease expiry + promotion, about 30–45 s, and the availability budget of 22 minutes a month can afford that a few times. A shorter TTL (say 10 s) recovers faster but fails over on every network blip of 10 s, and each failover has its own risk (Break 4). Two more details interviewers like: the lease relies on the nodes' clocks running at roughly the same rate, so use monotonic timers and give the old leader a safety margin (demote at TTL minus a few seconds); and connection draining matters, because pods still have to notice the switch, so keep the 5 s query timeouts from Break 1.


Break 4: You need to fail back

"You're on replica 1 in AZ-b. It's a 16-vCPU machine and it's running at 85% CPU; tonight's peak will tip it over. The old 32-vCPU primary in AZ-a is fenced and healthy. Get back onto it, safely."

Before you read on:

  1. Can you just restart the old primary as a replica of the new one?
  2. What happens to the 22,000 split-brain orders that exist only on the old primary?
  3. When do you do the switchover, and what do customers see during it?
#
Operator
Old primary (AZ-a)
New primary (AZ-b)
Checkout
1
restart old primary as a replica
starts, tries to stream
serving writes
normal
1 / 9
Answer

Why it can't just rejoin. The old primary's WAL went further than the point where the new primary's timeline forked off: it has the split-brain writes (or, after Break 2, the lost 2 s). When I started a diverged Postgres 18 primary as a standby of the promoted one, it looped on "new timeline 2 forked off current database system timeline 1 before current recovery point" and never streamed.

pg_rewind throws the diverged writes away. It copies the changed blocks back from the new primary so the old node matches the fork point. In my test, a row inserted only on the old primary was gone after the rewind. So the order is fixed: extract first, rewind second. With the node isolated, run COPY (SELECT * FROM orders WHERE created_at >= :fork_time) TO ... for every table checkout writes, and hand those rows to reconciliation: re-insert them on the real primary with new IDs (the old ones collide), check each against the provider's charges, and contact customers whose stock was sold twice.

Three practical details. pg_rewind needs wal_log_hints = on or data checksums, set before the incident. It needs the old node's WAL back to the last checkpoint before the fork: in my first attempt that WAL had been recycled and it failed with "could not find previous WAL record", so keep enough WAL or a WAL archive it can read. And it copies the source's configuration files over the target's (my rewound node came up on the new primary's port), so check the config before starting it.

Detect / contain. Before you start: the old node is fenced, the alert from Break 3 is quiet, and the reconciliation export is saved somewhere other than the node itself.

Degrade. The switchover needs a short write pause: checkout returns "try again in a moment" (or queues the request client-side) for about 20 s. At 4 am the rate is about 20 checkouts/s, so roughly 400 customers see the message, instead of 6,000 at peak.

Recover. Rewind, stream until lag is zero, pause writes, wait for replay LSN = primary LSN, promote the old node, repoint the proxy, demote the AZ-b node to a replica, resume writes. Then watch for an hour.

Prevent. The real lesson is to not need failback at all: make every failover candidate the same size as the primary, so promotion leaves you on a machine you can stay on. Then failback becomes optional and can wait for a quiet week. And practise this whole sequence on a staging copy every quarter, timed; an unpractised failback is an unplanned outage you schedule yourself.

The follow-up: "pg_rewind fails: 'could not find previous WAL record'. It's 2 pm. What now?"

Answer

The WAL it needs was recycled. Two options. If you archive WAL (you should, for point-in-time recovery, Chapter 7), point pg_rewind at the archive with --restore-target-wal so it fetches the missing segments through restore_command. If not, rebuild the old node from scratch with pg_basebackup from the new primary. For a 2 TB database at about 500 MB/s that is roughly 70 minutes, plus catch-up. Either way, the extract from the old node must happen before you rebuild it, because a base backup overwrites it completely. And it's 2 pm: the rebuild can run now, but the switchover still waits for 4 am.


Break 5: The whole region goes dark

"The entire region is down: all three zones. Your primary and both replicas are in it. What's your plan, and what will it cost?"

Before you read on:

  1. Where is the data that survives? If the answer is "nowhere", what do you add?
  2. Why not replicate synchronously to another region? Do the maths for a hot stock row.
  3. Who decides to fail over a whole region, and how do you avoid Break 3 at the scale of a continent?

The sim has three replicas in two regions: A and B on one side, C on the other, and every operation needs 2 of 3. Lock in a prediction and press Play: the link is cut, Client 1 writes through A, then Client 2 on C's side reads. After the answer appears, switch On a cut to AP: any replica, press Reset and Play, and compare what Client 2 gets.

Loading simulation…
Answer

What the sim shows. With a majority quorum, Client 2 on the minority side got an error for both its read and its write, and nobody ever read the old balance after the new one was confirmed. With "any replica", Client 2's write succeeded and its read returned the stale value 0. For checkout the CP behaviour is the right one: the side without a majority must refuse writes, or you get two primaries across regions.

Where the data is. In the baseline, nowhere outside the region. Add a cross-region async replica and ship WAL and base backups to storage in another region. Not synchronous: a round trip between regions is about 70 ms, and a commit holds its row locks until it returns. A hot stock row (the Black Friday deal SKU) can then take at most about 1,000 / 72 ≈ 14 updates per second, against 200 purchases per second on that SKU. Async costs you the cross-region lag instead: at a typical 0.5 s, about 0.5 × 300 = 150 checkouts at risk, which you reconcile with the provider exactly as in Break 2.

Detect. Probes from outside the region (another region, a third-party monitor). The internal monitoring is in the dead region.

Contain. A human decides. Region failover is a business decision with a runbook, not an automatic action, because a false positive (a broken monitoring path, not a dead region) creates split brain between regions. Make the decision mechanical anyway: the runbook says "fail over if the region has been unreachable from two independent vantage points for 10 minutes". Before promoting, fence the old region at the edge: DNS and the load balancer stop sending it traffic, and the old primary's credentials are revoked, so that if it comes back it can't take writes.

Degrade. While the decision is being made, checkout can run in an accept-and-confirm-later mode: the surviving region writes the order request to a durable queue and shows "order received, confirmation by email". That is an availability choice (AP) with a known risk: oversold stock. Limit it to products with more than 50 units in stock, and charge only when the order is confirmed.

Recover. Promote the cross-region replica (RTO: the 10-minute decision, plus a few minutes to promote and repoint), drain the queue, reconcile the at-risk window with the provider.

Prevent. Rehearse region failover twice a year in a game day. If the business needs a lower RTO, keep an etcd voter in a third region so the quorum can decide automatically which side holds the lease, with the same rule as Break 3.

The follow-up: "The region comes back two hours later. Its old primary has 0.6 s of orders the new region never received. Do you fail back tonight?"

Answer

No. Treat it exactly like Break 4, at a larger scale: keep the old region fenced, start its primary isolated, extract the rows after the fork point (about 0.6 × 300 = 180 checkouts) for reconciliation, then rebuild it as a replica of the new region with pg_rewind or a fresh base backup. Fail back only if there is a reason (cost, latency to most customers, the provider's allowlisted IPs) and only in a planned, rehearsed window. Two failovers in one day doubles your chance of a mistake for no gain.


The final design

ChangeWhyBreak
Checkout as a stored state machine: pending → charging → paidA crash between steps always leaves a row that says what to check1
Reconciler job plus provider webhooksFinds charged-but-pending orders1, 2, 5
query_timeout, statement_timeout, keepalives, pool connect timeoutThe app fails over in seconds, not 15 minutes1
Idempotency key from the checkout session, also sent to the providerRetries and reissued sequence IDs can't double charge1, 2
Synchronous replication, ANY 1 of two standbysAn acknowledged order survives any single node loss2
flush_lag alert; promote the most caught-up nodeKnow the size of the hole, and keep it small2
Reporting on a replica that is never promotedLong queries can't delay a failover candidate's replay2
HA manager with an etcd lease; the old primary demotes itselfNo two primaries3
Proxy that routes only to the lease holder; fencing of the old nodeA node that comes back gets no traffic3
Alert on more than one primary, from a third zoneDetect split brain in seconds3
wal_log_hints = on, WAL archive, extract-then-rewind runbookFailback without losing the diverged rows4
Standbys the same size as the primaryFailback becomes optional4
Cross-region async replica, backups in another regionThe data survives a region5
Human-decided region failover with a mechanical rule, fencing at the edgeNo split brain across regions5
Accept-and-confirm-later mode for well-stocked productsSome checkout during the decision window5

Scoring yourself

SkillStrongOKWeak
In-flight stateSplits in-flight work into safe, lost, unknown, and finds the ~120 charged-but-pending ordersMentions rollback of open transactions"The transaction rolls back, so it's fine"
Recovery timeSeparates database failover (20 s) from application failover (pooled connections, DNS, TCP timeouts)Mentions DNS TTLTakes the HA manager's number as the answer
RPOComputes 2 s × 300/s = 600 lost orders; knows flush lag is loss and replay lag is delayKnows async can lose data"Replicas have a copy"
Sync replicationProposes ANY 1 of two standbys, states the per-commit cost and the both-down decisionSays "make it synchronous"Proposes sync across regions without doing the latency maths
Split brainExplains why the old primary keeps committing, proposes a lease, self-demotion and fencingSays "use a leader election"Doesn't see that the old primary is still running
IdempotencyKeys from the client session, sent to the provider; knows sequences repeat after failoverUses the order ID as the keyNo idempotency
FailbackExtract, rewind, stream, planned switchover at low traffic; same-size standbysKnows pg_rewind exists"Just point traffic back"
Region lossAsync cross-region replica, human decision, fencing, a degraded mode with its risk statedAdds a DR replicaMulti-region active-active with no conflict story

Strong on six or more rows is a strong signal. The rows interviewers press hardest are in-flight state and split brain, because they show whether you think about the moment of failure or only the steady state before and after it.


What's next

Drill 11 — One Downstream Takes 10 Seconds: nothing dies, one dependency just gets slow, and that turns out to be harder to survive.