Drill 13 — One Tenant Takes Everyone Down
A multi-tenant AI generation platform: a Postgres job queue, three LLM providers, a credit ledger and a spend cap, broken five times
Time box: 45 minutes. Uses Ch 1 — Timeouts and Deadlines, Ch 3 — Isolation and Blast Radius, Ch 4 — Overload Protection, Ch 5 — Replication and Failover, Ch 6 — Graceful Degradation. Sims: hot-shard, concurrency-pool. Background: GenAI-JS 12 — Durable AI workflows, AI Ch 9 — From demo to production, Q30 — Circuit breaker, Q49 — Outbox, Full-Stack Playbook 07 — Multi-tenant SaaS.
How to Run This Drill
This drill is built to look like a system you may have run yourself: jobs in Postgres claimed with FOR UPDATE SKIP LOCKED, an outbox, failover across LLM providers, and a credit ledger. If that's on your CV, expect these exact breaks in the interview, asked about your system. Answer them here first, with numbers.
Read the prompt and the baseline. Start a 45-minute timer. At each break, answer the "Before you read on" questions out loud in the order detect → contain → degrade → recover → prevent. Commit to the timeline's prediction before you step past it.
Every break in this drill is a question about sharing. Tenants share workers, providers, a budget, a database and a region. For each break, ask: what is shared, who is using it up, and how do I stop one user from taking more than their share?
The Prompt
"Design a B2B platform that generates long documents with LLMs. A tenant (a company) submits a job through our API or web app: a brief plus some source files. The job makes four LLM calls in sequence (outline, draft, expand, polish) and returns a document. Results are fetched by polling or delivered by webhook.
Scale: 2,000 tenants, 250,000 jobs a day, 5 jobs a second at the weekday peak. Each LLM call takes about 8 seconds.
Plans: Free and Pro tenants buy credits in advance, and a job is charged when it is submitted. Enterprise tenants are invoiced monthly for what they use.
We use three LLM providers. Provider A is primary, with a limit of 3,000 requests a minute on our account. B (600/min) and C (300/min) are fallbacks. Our cost is about $0.02 a job on A. Finance has set a platform spend cap of $8,000 a day. Normal spend is about $5,000.
Target: 95% of jobs finish within 2 minutes of submission."
Numbers to write on the board first
| Quantity | Value | Working |
|---|---|---|
| One job | about 32 s | 4 calls × 8 s |
| Jobs in flight at peak | 160 | Little's law: 5 jobs/s × 32 s |
| LLM calls at peak | 20/s = 1,200/min | 160 in flight ÷ 8 s |
| Worker slots | 40 processes × 5 jobs = 200 | |
| Job capacity | 6.25 jobs/s | 200 slots ÷ 32 s |
| Utilisation at peak | 80% | 5 ÷ 6.25 |
| Spare at peak | 1.25 jobs/s | 6.25 − 5 |
| Provider A headroom | 1,200 of 3,000/min used | 40% |
| B + C together | 900/min | 75% of peak demand |
| Normal spend | $5,000/day, about $360 in a peak hour | 250k × $0.02; 5 × 3,600 × $0.02 |
Two numbers decide most of this drill: the 1.25 jobs/s of spare capacity, and the fact that the fallbacks can carry only 75% of peak.
The Baseline Design
| Component | What it does | What it assumes |
|---|---|---|
| API | Charges credits with one statement (UPDATE wallets SET balance = balance - $cost WHERE tenant_id = $1 AND balance >= $cost), inserts the job and an outbox row in the same transaction, returns 202 | Tenants submit at a sane rate |
| Job queue | The jobs table. Workers claim WHERE status = 'queued' ORDER BY created_at LIMIT 1 FOR UPDATE SKIP LOCKED | First in, first out is fair |
| Workers | Run the four steps. Each step's output is saved to job_steps, so a crashed job resumes from its last finished step | Any job takes about 32 s |
| LLM gateway | Tries A, then B, then C. A circuit breaker per provider, a 60 s timeout per call | The fallbacks have A's capacity |
| Spend cap | One counter for today's provider spend, checked before every call | Spend grows evenly through the day |
| Ledger | Charges and refunds as rows, with UNIQUE (job_id, kind), so a refund can't be paid twice even if several recovery paths reach the same failed job | |
| Region | One region, the database standby in a second zone | The region stays up |
This is a good design. Every break below is aimed at one of the right-hand column's assumptions.
Break 1 — One Tenant Sends 100× Their Normal Load
"13:00. Acme, an Enterprise tenant, ships a new integration. It treats our
202 Acceptedas a failure and resubmits every job once a second. Acme normally sends 0.05 jobs a second. Now it's sending 5. Every other tenant's jobs are getting slower."
Before you read on:
- Acme is invoiced monthly. Which of your existing safeguards would have stopped a Pro tenant doing this, and why doesn't it stop Acme?
- What is the arrival rate now, and how fast does the backlog grow?
- What fraction of the worker slots is Acme getting under first-in, first-out, and what should it get?
Answer
Why nothing stopped it. For a Pro tenant the credit check is a natural limit: the balance runs out and the API returns 402. Acme is invoiced afterwards, so there is no balance to run out. It has no per-tenant rate limit, no concurrency limit, and no idempotency key, so every resubmission is a new job. Say this first: it shows you know the credit check was protecting you by accident.
Detect. Two signals, both per tenant:
- Submission rate compared with that tenant's own baseline. Acme at 100× its 7-day average pages within a minute.
- Queue wait (p95) by tenant, and the share of running jobs per tenant. "One tenant holds more than 25% of slots" is an alert.
Contain. Three limits, at three layers:
-
A per-tenant rate limit at the API: a token bucket per tenant. Enterprise: 1 job/s sustained, a burst of 60. Above that,
429withRetry-After. Acme's loop now gets429s instead of new jobs. -
A per-tenant concurrency cap in the dequeue: at most 20 running jobs per tenant, 10% of the fleet. It then takes ten tenants at their cap to fill the fleet. The claim query picks the least recently served tenant that is under its cap, then that tenant's oldest job. That's round-robin across tenants, not FIFO across jobs:
sqlWITH t AS ( SELECT t.tenant_id FROM tenants t WHERE t.running < t.max_running AND EXISTS (SELECT 1 FROM jobs q WHERE q.tenant_id = t.tenant_id AND q.status = 'queued') ORDER BY t.last_served_at LIMIT 1 FOR UPDATE OF t SKIP LOCKED ), j AS ( SELECT id FROM jobs WHERE tenant_id = (SELECT tenant_id FROM t) AND status = 'queued' ORDER BY created_at LIMIT 1 FOR UPDATE SKIP LOCKED ), claimed AS ( UPDATE jobs SET status = 'running', started_at = now() FROM j WHERE jobs.id = j.id RETURNING jobs.id, jobs.tenant_id ) UPDATE tenants SET running = running + 1, last_served_at = clock_timestamp() FROM claimed WHERE tenants.tenant_id = claimed.tenant_id RETURNING claimed.id, claimed.tenant_id;The counter
tenants.runningis incremented in the same statement that claims the job, under the tenant row's lock, and decremented in the transaction that finishes the job. (The reaper that requeues dead jobs must decrement it too.) We tested this on Postgres 18, with partial indexes onjobs (tenant_id, created_at) WHERE status = 'queued'andjobs (tenant_id) WHERE status = 'running'. With 50 tenants and Acme holding 6,000 queued jobs, 200 claims gave every tenant exactly 4 slots, Acme included. With 10 workers claiming concurrently, Acme's running count stopped at exactly 20. A first version that counted running jobs in a subquery instead of keeping a counter overshot to 21: the count ran on a snapshot taken before another worker's claim committed. That is the same check-then-act race as in the credit ledger. -
An idempotency key on
POST /jobs. If Acme's loop resubmits the same job with the same key, the API returns the original job instead of creating a new one (Q26). This is the fix for this bug; the two limits above are the fix for the next one.
Degrade. Acme gets 429s and its queued jobs run 20 at a time. A tenant that submits a new job now gets a slot within one claim cycle, because the round-robin serves it before Acme's next job.
Recover. Two backlogs are left, and they need different answers.
- Everyone else's. At 13:10 about 1,125 of the 2,250 waiting jobs belong to other tenants. With Acme held to 20 slots, they share 180 slots: 180 ÷ 32 s = 5.6 jobs/s, against 4.95 arriving, so the spare is 0.67/s and the backlog takes 1,125 ÷ 0.67 ≈ 28 minutes. Set Acme's cap to 0 until it clears: 200 slots give 6.25 − 4.95 = 1.3 spare, and it takes about 14 minutes. Pausing the tenant who caused the backlog is fair, and it halves everyone else's recovery.
- Acme's. By 13:10 Acme had submitted 3,000 jobs, but they are 60 distinct documents, each resubmitted about 50 times. About 1,875 have already run, at half the fleet, and 1,125 are still queued. Ask Acme, then cancel the queued duplicates and don't invoice the ones that ran. Whatever is left drains at Acme's cap: 20 slots ÷ 32 s = 0.625 jobs/s. If all 1,125 were real, that would be 1,125 ÷ 0.625 = 1,800 s, 30 minutes. That is the right outcome. Acme waits for Acme's own burst, and nobody else does.
Prevent. Per-plan limits in the tenants table: Free 2 running, Pro 5, Enterprise 20, raised by contract. An Enterprise customer that really needs 5 jobs/s needs 160 slots: that's capacity they reserve and pay for, not something they take from everyone else at 13:00 on a Tuesday.
The trade-off: a tenant with a real, urgent burst is now slower than it would have been under FIFO. You are choosing predictable latency for everyone over best-case latency for one.
The follow-up: "Instead of quotas, why not split the workers into four groups and route each tenant to one group by hash(tenant_id) % 4? Then Acme can only hurt its own group."
Answer
It limits the blast radius to a quarter of the tenants, but it doesn't help that quarter, and adding groups doesn't move the hot tenant. The hot-shard sim shows why. On the Celebrity key tab, one key draws 30% of all traffic and lives on one of 4 shards. Lock in a guess and press Play. Shard 2 runs at 153% of capacity. After a second, click Add 2 shards (4 → 6): Shard 2 only drops to 127%. A key lives on exactly one shard, so more shards just thin out the keys next to it. Replace "key" with "tenant" and "shard" with "worker group": Acme's 500 neighbours in its group still wait 18 minutes.
The sim's fix for a hot read key is a cache. A tenant's jobs can't be cached, so the fix here is the per-tenant cap. Hash partitioning is still worth having for faults a quota can't catch: a job type that crashes its worker, a 200 MB input that runs the process out of memory. For those, use shuffle sharding. Run 8 worker groups and assign each tenant 2 of them. There are C(8,2) = 28 pairs, so a given other tenant shares both of Acme's groups with probability 1/28 ≈ 3.6%. Even a tenant that shares one group still has its other one working. Quotas protect against too much load. Shuffle sharding limits how many tenants one bad workload can reach.
Break 2 — The Primary LLM Provider Goes Down
"15:00. Provider A stops answering. For the first minute it accepts connections and never responds. Then it returns
503s. It's out for 40 minutes. Walk me through what your gateway does, second by second."
Before you read on:
- With a 60-second call timeout, what are your 160 in-flight calls doing at 15:00:30, and what is the breaker doing?
- The fallbacks carry 75% of peak demand. Where does the other 25% go, and what can you switch off to close the gap?
- When A comes back, how do you move traffic back without hurting A or yourself?
The burst at 15:01:01 is the concurrency-pool sim's Pool limit tab. The top lane is Promise.all sending 30 calls at once to a provider that serves 5 at a time and returns 429 Retry-After: 1 to the rest. The bottom lane is a pool of 5. Lock in a guess and press Play. Promise.all collects 75 429s and needs 6.1 s. The pool gets none and finishes in 4.5 s. Then drag Pool size K to 10: 19 429s come back and it takes 5.2 s. K is your per-provider concurrency limit, and R is the provider's.
Answer
Detect. The hang is the dangerous minute, because hangs produce no errors. Two signals catch it in seconds, not 60:
- A time-to-first-token timeout of 5 s on streaming calls. A healthy provider starts streaming in about 1 s, so a call that hasn't produced a token in 5 s is counted as failed.
- Calls in flight per provider. In-flight calls climbing while completions drop to zero is a hang, whatever the error rate says.
Then lower the total timeout from 60 s to 30 s, a bit above the p99 of a healthy call (about 20 s). Ch 1 covers choosing timeouts from p99.
Contain. The breaker for A opens on timeouts as well as 5xx. Then failover must respect the fallbacks' limits. Convert each provider's per-minute limit into a concurrency limit with Little's law: B allows 600/min = 10 calls/s, at 8 s each = 80 in flight. C allows 300/min = 40 in flight. A gets 360 (90% of 400). Each provider has its own pool. A worker that can't get a slot waits in a short queue instead of firing a call that will come back as a 429.
Degrade. You can serve 75% of peak demand. Close the gap on purpose instead of letting the queue decide:
- Drop the optional polish step during failover. Three calls per job instead of four: 15 calls/s ÷ 3 = 5 jobs/s, exactly peak demand. The backlog stops growing. Mark those jobs
polished: falseand offer a free re-polish later. That is a feature switch decided in advance (Ch 6). - Shed by plan if that's not enough: Free-tier jobs wait (they're about 30% of jobs), and paid jobs go first.
- Tell users: "Generation is slower than usual" on the status page and in the app.
Recover. Jobs that were mid-way resume on B from their last saved step: step 2 was done on A, steps 3 and 4 run on B. That works only if each step's output was saved when it finished, and if each prompt has been tested on every provider. Test the fallback prompts in CI, not during the outage. The tenant pays the same price. Your cost rises from about $0.02 to $0.035 a job for the duration, so 9,000 jobs × $0.015 ≈ $135. Say that number: it shows you know failover has a price.
Failback is its own step. When the breaker's half-open probes succeed, send 10% of traffic to A, then 25%, 50%, 100%, a few minutes per stage, watching A's error rate and latency. Sending everything back at once hits A with your whole load, at the same moment A's other customers are failing back, while A is still recovering.
Prevent. The 30 s timeout and 5 s first-token timeout, per-provider pools sized from their limits, the polish switch, and the polished flag. For the 25% gap itself, either raise B's limit with that provider (it costs little until you use it) or add a fourth provider.
The trade-off: degraded output (no polish) for 40 minutes, against an 83-minute backlog. Decide which you prefer with product, before the outage.
The follow-up: "A recovers at 15:40. Your 40 processes all see the breaker go half-open at the same moment. What happens?"
Answer
If every process keeps its own breaker and they all opened at about 15:01, they all probe on the same 30 s schedule. At 15:40 all 40 probe together, all succeed, and all close together: 360 calls land on A in the same second. That is the synchronised retry spike that the Retry storm tab of the same sim shows. The fixes: put jitter on the probe interval, so processes probe at different moments; share breaker state (a small table or a Redis key), so one probe speaks for the fleet; and ramp the traffic share in stages, as above, rather than flipping each breaker from open to fully closed. A breaker that closes all at once is just a retry storm with a delay.
Break 3 — The Spend Cap Trips
"15:11. The platform spend cap trips. Every LLM call for every tenant is refused. 160 jobs fail mid-way. New jobs keep being accepted, charged, and failing. What happened, and what should the cap have done?"
Before you read on:
- Spend was $5,000 a day yesterday. Who spent the extra, and when could you have known the cap would trip?
- Who is the blast radius of a single platform-wide cap? Who should it be?
- What happens to the credits of the jobs that failed, given four different code paths can try to refund the same job?
Answer
Detect. Alert on the projected time to cap, not on the spend itself: (cap − spent) ÷ spend rate over the last hour. At 12:30 that is (8,000 − 3,800) ÷ 1,560 ≈ 2.7 hours, early enough for a person to look. Pair it with a per-tenant spend leaderboard: by 14:00, Lumen accounted for 77% of the last two hours' spend.
Contain. Right now: raise the platform cap deliberately (it's your own number, and finance can approve a one-day raise in minutes) and pause Lumen's remaining jobs, as paused, not failed. Then call Lumen.
Degrade: what the cap should have done. A single cap has the whole platform as its blast radius. Make it a hierarchy, checked in this order:
| Level | Limit | When reached |
|---|---|---|
| Per job | Estimated cost at submission; reject above the plan's per-job maximum | Lumen's $1.50 jobs need approval or the batch path |
| Per tenant | Daily spend limit from the plan or contract: Enterprise default $1,000/day | That tenant's new jobs wait as waiting_for_budget, with a clear message |
| Per plan | Free tier's share of the daily budget | Free jobs wait |
| Platform | $8,000 backstop | Shed in order: Free at 80% of the cap, Pro at 95%, Enterprise at 100% |
And at every level: in-flight jobs finish. A job that has done 2 of its 4 steps has spent half its cost already. Failing it wastes that spend and owes a refund. Finishing it costs about $0.01 more.
Recover. Every failed job owes exactly one refund. Four code paths can reach the same failed job: the failure handler, the stale-job reaper, a user's cancel, and the admin tool. A refund is safe to repeat only because the ledger row is unique per job and kind, and the wallet is credited only when that insert succeeds:
WITH r AS (
INSERT INTO ledger (tenant_id, job_id, kind, amount)
VALUES ($1, $2, 'refund', $3)
ON CONFLICT (job_id, kind) DO NOTHING
RETURNING tenant_id, amount
)
UPDATE wallets w SET balance = balance + r.amount
FROM r WHERE w.tenant_id = r.tenant_id;
The second path to arrive inserts nothing, so it credits nothing. Re-queue the 8,860 failed jobs (8,700 new + 160 mid-way) automatically, and charge them again when they run, so a tenant never pays twice and never loses a job.
Prevent. Estimate before you spend, and reserve, then settle:
-- At submission: check and hold the estimate in one statement.
UPDATE wallets
SET available = available - $2, reserved = reserved + $2
WHERE tenant_id = $1 AND available >= $2
RETURNING available;
-- When the job ends (once per job): charge the actual cost, release the rest.
WITH s AS (
INSERT INTO ledger (tenant_id, job_id, kind, amount)
VALUES ($1, $3, 'settle', LEAST($4, $2))
ON CONFLICT (job_id, kind) DO NOTHING
RETURNING amount
)
UPDATE wallets
SET reserved = reserved - $2, available = available + ($2 - s.amount)
FROM s WHERE wallets.tenant_id = $1;
Here $2 is the estimate and $4 is the actual cost, capped at the estimate. A failed job settles at its actual spend so far, or at 0 if you absorb it. Tested on Postgres 18: a second reservation larger than the available balance returns no row, and a second settle changes nothing. The estimate comes from the input size: 300 pages × about 500 tokens a page × 4 calls ≈ 600k input tokens, at the provider's price. For Enterprise tenants, the "wallet" is their daily spend limit rather than prepaid credit, and the same two statements apply.
Big batches like Lumen's belong on a provider's batch API: many providers offer one at a discount, with results within a day. "Your batch runs overnight at lower cost" is a better answer than "your batch took down the platform".
The trade-off: an estimate can be wrong both ways. Reserve generously and release the difference, and cap the actual charge at the estimate so a bad estimate costs you, not the customer.
The follow-up: "Why reserve at all? Just check the tenant's remaining budget before each LLM call."
Answer
Because the check and the spend are separate steps, and 20 of the tenant's jobs run at once. All 20 read "$10 left" at the same moment, each decides it can afford a $1.50 call, and together they spend $30. That is check-then-act across concurrent workers, the same race as reading a balance and then writing it (JS 5 — Race conditions). The reservation makes the check and the hold one atomic statement, so the 7th job's reservation fails while the first 6 succeed. There's a second cost: a platform-wide spend counter updated before every call is one hot row taking 20 updates a second, every one contending for the same row lock. Reserve per job instead, and keep the platform total as a sum you refresh every few seconds. The backstop can be a few seconds stale, and that's fine.
Break 4 — One Tenant's Export Slows the Database for Everyone
"16:00. Acme's admin runs 'export all my generations' for each of their 30 workspaces, all at once. Each export is a 4-minute query over 2 million rows. Within two minutes, every tenant's API calls are timing out and job throughput has dropped by two-thirds."
Before you read on:
- The exports use 30 of 200 connections. Why is everyone slow, not just Acme?
- Which Postgres settings and pool designs stop one workload from taking the whole database?
- Where should exports run instead, and what new problem can that create?
Answer
Detect. pg_stat_activity grouped by query fingerprint and by tenant. Set application_name per workload (api, worker, export) and put the tenant id in a SQL comment, so one query shows the 30 identical scans and who owns them. Database CPU and the p99 of the charge query page.
Contain. Cancel the exports (SELECT pg_cancel_backend(pid) for each), and turn off the export endpoint for Acme with a switch. That takes under a minute.
Degrade. Exports become asynchronous jobs: "Your export is being prepared; we'll email a link." Nobody needs 2 million rows within one HTTP request.
Prevent: bulkheads inside the database.
- Separate connection pools per workload: the API pool, the worker pool, and an export pool of at most 4 connections. The pool size is the bulkhead. Exports can never hold more than 4 connections, however many requests arrive (Ch 3).
- A role per workload with its own timeout:
ALTER ROLE api SET statement_timeout = '5s', a longer one forexport. An API query that runs away is killed at 5 s instead of holding a connection for minutes. - Limit expensive endpoints by cost, not by count: one running export per tenant, two platform-wide.
- Move the exports off the primary, to a read replica or to a nightly snapshot in object storage, and page through with keyset pagination (
WHERE id > $last ORDER BY id LIMIT 5000), not one 4-minute query.
The trade-off: a replica for exports is a second machine to pay for, and it is stale by its replication lag. For an export, a few seconds stale is fine.
The follow-up: "Exports now run on a replica. They kept getting cancelled, so someone turned on hot_standby_feedback. A week later, claiming a job takes 200 times longer than before. Why?"
Answer
Long queries on a replica can be cancelled when replaying the primary's changes would remove rows those queries still need to see. hot_standby_feedback = on stops the cancellations by reporting the replica's oldest running snapshot to the primary. The primary then keeps every dead row version that snapshot might still need, so vacuum can't remove them. Your job table churns: every job's status changes leave dead versions behind. With a 4-minute export running almost all day, they pile up, and the claim query has to step over the dead index entries at the head of the queue before it reaches a live row.
We reproduced the effect on Postgres 18 with a long transaction on the primary, which holds vacuum back the same way. While a 40-second repeatable-read transaction was open, we inserted and deleted 300,000 job rows. VACUUM reported "300040 are dead but not yet removable". The claim query took 12 ms and read 3,689 buffers. After the transaction ended and vacuum ran, it took 0.05 ms and read 5. That's a 240× slowdown, from rows that no longer exist.
The fixes: run exports on a replica without hot_standby_feedback, with max_standby_streaming_delay = -1, so that replica pauses replay and lags during an export rather than cancelling it (fine, because nothing else reads from it). Alert on the age of the oldest open transaction on the primary and on any replica with feedback. Set idle_in_transaction_session_timeout so a forgotten open transaction can't do the same thing.
Break 5 — The Region Is Gone
"11:00 on a weekday. The whole region is out: compute, the database, both zones. The provider says recovery could take hours. What do you have, and what do you do?"
Before you read on:
- With the baseline, what is your recovery time? What would you need to have built beforehand to do better?
- If you fail over to an asynchronously replicated database, which jobs, charges and webhooks are in the last second that didn't make it?
- Why does keeping the queue, the ledger and the outbox in the same Postgres help you here?
Answer
With the baseline, there's nothing to fail over to. Your recovery time is the region's recovery time, and your data is only as safe as your backups. If those are stored in the same region, you can't reach them either. Say this plainly. Then describe what you would build, and its cost.
The warm standby (build it before the outage):
- An asynchronous replica in Region 2, typically under a second behind. Alert when the lag exceeds 5 s.
- Backups and the WAL archive copied to Region 2 as well (Ch 7).
- Workers and API deployed in Region 2 at minimum size (4 processes), ready to scale to 40.
- Provider API keys that work from both regions. Provider limits are usually per account, so the same 3,000/min follows you.
- DNS with a 60 s TTL for the API.
Detect. Region-level health checks from outside the region, plus the cloud provider's status. The hard part is the decision, not the detection: failing over has a cost (some data loss and a failback later). Set the rule in advance, for example "fail over if the region is down for 10 minutes and there's no recovery estimate", and name who makes the call.
Contain: fence first. Before you promote, make sure the old primary can't take writes if Region 1 comes back. Otherwise you have two primaries (split brain, Ch 5). Fence it with every lever you have: stop it through the cloud API when it becomes reachable, remove it from service discovery, and point every config at the new primary.
Degrade. About 10–15 minutes of errors, then normal service. In-flight work resumes rather than restarting.
Recover: the last second. At 5 jobs/s, about 5 jobs were committed in Region 1 and never replicated.
| What | What happens | Why it's safe |
|---|---|---|
| Jobs 8,997–9,001 | Gone in Region 2. Clients polling get 404 and resubmit with the same idempotency key | The idempotency record was lost too, so the resubmission creates the job once and charges it once |
| Their credit charges | Gone with the jobs | Same transaction: there's never a charge without a job |
| 160 running jobs | Rows say running with heartbeats 60 s+ old. The reaper requeues them | Each resumes from its last saved step, redoing at most one 8 s call: about 160 × $0.005 ≈ $1 of duplicated provider spend |
| Refunds issued in the last second | Lost, so the recovery paths run them again | UNIQUE (job_id, kind) replicates with the data, so each refund still pays exactly once |
| Webhooks | Some were sent, but the outbox "sent" mark didn't replicate, so they are sent again | Receivers deduplicate by event id |
This is the payoff of keeping the queue, the ledger and the outbox in one Postgres: everything that must agree commits in one transaction, replicates in one stream, and is lost or kept together. With the queue in Redis and the ledger in Postgres, a failover can leave a job without its charge, or a charge without its job, and someone has to reconcile them by hand.
Prevent. The warm standby above, practised: a planned failover drill every quarter, timed. And consider cells: split tenants into two or more independent stacks (database, workers, queue each), so the loss of a region or a cell affects half the tenants or fewer, not all of them.
The trade-off: a warm standby roughly doubles database cost and adds a cross-region replication stream to watch. You buy an RTO of about 15 minutes instead of "whenever the region comes back". Price it against what a multi-hour outage costs your largest customers.
The follow-up: "Region 1 is back at 15:00. Do you move back?"
Answer
Not straight away, and never by simply restarting the old primary. It holds about 5 transactions that Region 2 never saw: its history has diverged. First save those transactions for reconciliation: compare its jobs and charges after the last replicated position with what clients resubmitted. Then rebuild Region 1 as a replica of Region 2, using pg_rewind to rewind the old primary onto the new timeline, or a fresh base backup. Then fail back as a planned switchover, in a quiet window: stop writes, wait for replication lag to reach zero, promote, and move DNS. Done that way, failback loses nothing, because you control the timing. Many teams simply stay in Region 2 and make Region 1 the standby. Failback is its own risky operation, with its own runbook (Ch 5).
The Final Design
| Change | Motivated by | What it costs |
|---|---|---|
Idempotency key on POST /jobs | Break 1, Break 5 | A key table with a TTL |
| Per-tenant token bucket at the API, by plan | Break 1 | Bursty tenants get 429s |
| Per-tenant running cap in the claim query, round-robin across tenants | Break 1 | A counter on the tenant row, kept correct by the reaper too |
| Shuffle-sharded worker groups (2 of 8 per tenant) | Break 1 follow-up | Some idle capacity per group |
| 30 s call timeout, 5 s first-token timeout, in-flight alert | Break 2 | Tuning per provider |
| Per-provider pools sized from limits by Little's law | Break 2 | Workers wait for a slot |
| "Polish" step switchable off during failover | Break 2 | Lower quality for the duration, agreed with product |
| Staged failback with jittered probes | Break 2 follow-up | Slower return to A |
| Cost estimate at submission, reserve then settle | Break 3 | Estimates to maintain per job type |
| Hierarchical budgets: job, tenant, plan, platform; in-flight jobs finish | Break 3 | More limits to explain to customers |
| Projected-time-to-cap alert, per-tenant spend leaderboard | Break 3 | |
| Batch path for large jobs | Break 3 | Results within a day, not minutes |
Pools and roles per workload, statement_timeout per role | Break 4 | Pool sizes to tune |
| Exports async, on a replica without feedback, keyset pagination | Break 4 and follow-up | A replica that lags during exports |
Oldest-transaction alert, idle_in_transaction_session_timeout | Break 4 follow-up | |
| Warm standby in Region 2, 60 s DNS TTL, fencing, quarterly drill | Break 5 | About double the database cost |
| Cells as the next step | Break 5 | Operating several stacks |
Scoring Yourself
| Skill | Strong | OK | Weak |
|---|---|---|---|
| Spotting what's shared | Names the shared resource in every break: worker slots, provider limit, budget, database CPU, region | Finds it for most breaks | Treats each break as a separate bug |
| Noisy tenant | Notices postpaid tenants bypass the credit check; rate limit + per-tenant concurrency cap + idempotency key; does the 18-minute arithmetic; knows a counted cap can overshoot | Rate limit only | "Scale the workers" |
| Partitioning | Explains why hashing tenants to groups doesn't help the hot group; proposes shuffle sharding with the 1/28 figure | Suggests a separate queue per big tenant | Thinks adding partitions fixes a hot tenant |
| Provider outage | Hang vs error, first-token timeout, pools from Little's law, the 75% gap and the polish switch, staged failback | Breaker and failover, no capacity sums | "We fail over to B" with no numbers |
| Spend control | Projected-time-to-cap alert, hierarchical budgets, in-flight jobs finish, reserve/settle, idempotent refunds | Per-tenant caps | One global kill switch |
| Database isolation | Pools and roles per workload, statement_timeout, async exports on a replica, and the hot_standby_feedback trap | Moves exports to a replica | Adds a bigger database |
| Region loss | Says the baseline has no answer, prices the warm standby, fences before promoting, walks the last second of commits table by table | Async replica and DNS failover | "Postgres replicates, so we're fine" |
Five or more strong: you can defend this system in a room. If Break 1 or Break 3 was weak, those are the two you will be asked first.
The Causal Chains
- If a queue is first in, first out across tenants
- If the credit check is the only limit
- If a provider hangs instead of failing
- If failover sends the full load to a smaller provider
- If one spend cap covers every tenant
- If a long snapshot is held anywhere, including a replica with feedback
- If the job, its charge and its outbox row commit in one transaction
What's Next
Drill 14 — The Bad Deploy: no outage, no overload, just a migration and a one-line bug that corrupt data for 40 minutes, and a restore nobody has ever run.