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

  1. The reservation write path: the SQL/ORM statements and transaction boundaries.
  2. How you keep payment outside the transaction without overselling or double-charging.
  3. How you handle hot rows (12 sizes × 1,500 req/s) and protect the rest of the store.
  4. Expiry and recovery: unpaid holds, crashes between steps, retries.
  5. How you verify it before 10:00 and monitor it during the drop.

Hints

  • One conditional UPDATE per 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) and CHECK (stock >= 0) on products.
  • 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) with FOR 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, qty and 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 stock and 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.