Transactions, Locking & Concurrency in Code · Capstone Case Studies
Capstone: Never Oversell a Flash Sale
Design inventory reservation for a limited-edition drop: 5,000 units, 50,000 buyers in the first minute.
Scenario
Example company: a sneaker retailer runs limited drops. The next drop: 5,000 pairs across 12 sizes, released at 10:00:00. Last time, 50,000 shoppers arrived in the first minute and the old system oversold 9% — the code did read-modify-write on products.stock and called the payment provider inside the transaction.
Current stack: Postgres 16 primary + 1 replica, a stateless API on 20 pods (pool of 10 connections each), Redis available, a payment provider that supports idempotency keys and takes 0.3–10 s.
Requirements
- Never oversell, under any retry or crash pattern.
- A shopper who gets "you're in" has 10 minutes to pay; unpaid holds return to stock.
- A fair-ish experience: no customer can hold more than 2 pairs.
- The rest of the store (browsing, other products) must stay fast during the drop.
Constraints
- Up to ~1,500 reservation attempts per second at peak, concentrated on 12 rows (sizes).
- Total database connections must stay below 250.
- The team can ship one new table and a background job, not a new datastore.
Deliverables
- The reservation write path: the SQL/ORM statements and transaction boundaries.
- How you keep payment outside the transaction without overselling or double-charging.
- How you handle hot rows (12 sizes × 1,500 req/s) and protect the rest of the store.
- Expiry and recovery: unpaid holds, crashes between steps, retries.
- How you verify it before 10:00 and monitor it during the drop.
Hints
- One conditional
UPDATEper reservation beats any lock-then-check sequence. - Think about what the per-customer limit needs: a constraint or a lock on something that always exists.
- A queue in front of the database can turn a stampede into a steady flow.
Draft each deliverable below before revealing the model answer and rubric.
Further reading: Stripe: designing robust APIs with idempotency · PostgreSQL: explicit locking
Deliverables
Design the reservation write path.
Model answer
- Table
reservations(id, customer_id, product_id, qty, status, expires_at, idempotency_key UNIQUE)andCHECK (stock >= 0)onproducts. - One short transaction per attempt:
BEGIN; UPDATE products SET stock = stock - $qty WHERE id = $size_id AND stock >= $qty; -- 0 rows → sold out INSERT INTO reservations (…, status, expires_at, idempotency_key) VALUES (…, 'held', now() + interval '10 minutes', $key) ON CONFLICT (idempotency_key) DO NOTHING; -- client retries are harmless COMMIT; - Per-customer limit: a partial unique index can't count to 2, so lock the customer's drop row (
drop_entries(customer_id, drop_id), pre-created when they join the queue) withFOR UPDATE, check held + paid < 2, then decrement — the parent-row lock serializes only that customer's requests.
Rubric
Look for: atomic conditional decrement checked via affected rows, idempotency key on the reservation, a constraint as last defence, and a correct per-customer limit (parent-row lock or similar, not check-then-insert). Misses: read-modify-write, SELECT … FOR UPDATE on the product row for every request (works but serializes the hot row longer), no idempotency for retries.
Keep payment outside the transaction without overselling or double-charging.
Model answer
- Reserve (short transaction) → commit → charge with
Idempotency-Key = reservation id→ confirm in a second short transaction:UPDATE reservations SET status = 'paid', charge_id = $c WHERE id = $id AND status = 'held'. - If confirm finds the hold already expired (status changed), refund or re-reserve if stock remains; record the outcome.
- Client retries reuse the same reservation id → the provider deduplicates the charge.
- Payment calls never hold a row lock or a pooled connection.
Rubric
Look for: three steps with commits between them, provider idempotency key derived from the reservation, a guarded confirm (WHERE status = 'held'), and a plan for the expired-but-charged race. Misses: calling the provider inside the transaction, new idempotency key per retry, ignoring the expiry race.
Handle the hot rows and protect the rest of the store.
Model answer
- 12 rows × 1,500 req/s is above what single-row updates absorb comfortably (~a few hundred per row per second including commit). Options:
- Split stock into buckets: e.g. 8 rows per size each holding 1/8 of the stock; try a random bucket, then others before saying sold out.
- Admission queue: a waiting room (Redis list or the API gateway) admits N shoppers per second to the reservation endpoint.
- Bulkhead: the drop endpoint uses its own small connection pool (e.g. 3 per pod) and short
lock_timeout/ pool timeout, so it can't starve browsing and other checkouts. - Product pages read cached stock ("few left") — never the hot rows.
Rubric
Look for: recognition that the 12 rows are the bottleneck, a way to spread or meter writes, isolation of the drop from other traffic, and caching for reads. Misses: only scaling pods (more contention), raising pool sizes, reading live stock on every page view.
Expiry, crashes and retries.
Model answer
- Sweeper every 15 s:
UPDATE reservations SET status = 'expired' WHERE status = 'held' AND expires_at < now() RETURNING product_id, qtyand give the stock back in the same transaction (UPDATE products SET stock = stock + qty …). - Crash after reserve, before charge: the hold expires and stock returns — no orphan.
- Crash after charge, before confirm: the client (or a reconciliation job using provider webhooks) retries confirm with the same idempotency key; webhooks are processed once via a unique
(provider, event_id)row. - Daily reconciliation: paid reservations vs provider charges.
Rubric
Look for: stock returned atomically with expiry, every crash point analysed, webhook dedupe, reconciliation. Misses: expiring in application memory, returning stock in a separate transaction, no handling of 'charged but not confirmed'.
Verify before the drop and monitor during it.
Model answer
- Load test with the real concurrency pattern: thousands of parallel requests on the same 12 sizes; assert
sum(paid + held) <= initial stockand no customer > 2. - Chaos: kill pods mid-request and make the provider slow/fail during the test.
- Live dashboards: reservation rate, sold-out rate, lock waits on
products, pool wait time, p99 by endpoint, held vs paid vs expired counts. - Invariant alarms every minute:
stock < 0(impossible with the CHECK, but alert anyway), held + paid > initial, duplicate charges.
Rubric
Look for: concurrency-focused load tests with invariant checks, failure injection, and live monitoring of both business invariants and database contention. Misses: single-user tests, monitoring only CPU.
Open in the app to mark lessons complete, take notes and track your progress.