</> MAANG.io
system design · 301

Advanced

Master system design interviews with scalable architecture patterns, distributed systems, and real-world design challenges.

0/480 solved 0% complete

Section 7: Two Maximums Walk Into a Transaction - Deep Dive: Bid Acceptance & the Proxy Engine

Executive Summary: Everything the prompt called non-negotiable is decided inside one transaction about two milliseconds long, opened here statement by statement: idempotency lookup, row lock, checks, proxy engine, ledger insert, row update, outbox row, commit, cache write, verdict. The engine is a pure function with four cases, and all three moments of auction #7731 run through it against an eBay-style increment table, including the lookup-order rule that puts Thursday's price at $9,550. Then the defence: why a row lock is enough at 200 bids a second, why FOR UPDATE beats a version check under contention, and why a replayed request returns the verdict it earned the first time, even after the price has moved on.

The interviewer taps the Bid Service box from Section 6. "Open it. Elena's Thursday bid, every statement, in order."

Two maximums are about to meet: Marcus's $12,000, secret on the auction row since Tuesday, and Elena's $9,500, arriving now. This transaction is the only place they are ever compared.

The Ten Steps

BEGIN;
-- 1. idempotency: a replay returns its stored verdict, never reaching the lock
SELECT body_hash, verdict_json FROM bid_attempts
  WHERE bidder_id = $b AND auction_id = $a AND key_hash = $k;
--    hit, same body_hash -> return verdict_json;  hit, different body_hash -> 422 KEY_BODY_MISMATCH
-- 2. one auction, one serialisation point; 50 ms lock wait, one retry
SELECT status, end_time, seller_id, current_price, high_bidder_id, high_max_amount, next_seq, owner_node
  FROM auctions WHERE auction_id = $a FOR UPDATE;
-- 3. checks, in order:  owner_node IS NULL   else ROLLBACK, forward to the owner, same key (Section 8)
--    status = 'OPEN' AND $accepted_at < end_time                     else AUCTION_ENDED
--    $b <> seller_id                                                 else SELLER_OWN_ITEM
--    $max >= current_price + increment(current_price)  (first bid: >= start_price)  else BELOW_MINIMUM_BID
--    $b = high_bidder_id ?  require $max > high_max_amount, skip step 4
-- 4. (new_high, new_high_max, new_price, outcome) = resolve(...)       -- pure, below
-- 5. the ledger; seq is assigned under the lock
INSERT INTO bids (auction_id, seq, bid_id, bidder_id, max_amount, accepted_at, price_after, outcome)
VALUES ($a, next_seq, $bid_id, $b, $max, $accepted_at, new_price, outcome);
-- 6. the row holds the answer
UPDATE auctions SET current_price = new_price, high_bidder_id = new_high,
       high_max_amount = new_high_max, bid_count = bid_count + 1,
       next_seq = next_seq + 1, version = version + 1 WHERE auction_id = $a;
-- 7. event and verdict ride the same commit
INSERT INTO outbox (event, auction_id, seq, payload) VALUES ('bid.accepted', $a, next_seq, ...);
INSERT INTO bid_attempts (bidder_id, auction_id, key_hash, body_hash, verdict_json, created_at)
  VALUES ($b, $a, $k, $body_hash, ..., now());
COMMIT;   -- 8. returns after the second-AZ replica acknowledges
-- 9. write-through the hot-state cache: price, high bidder id, count, version; never the max;
--    best-effort, never blocks the verdict
-- 10. return the verdict

Step 2 is the sentence the module rests on: one auction is one serialisation point. Every bid on #7731 passes through this lock one at a time, and seq is handed out inside it, which is why two bids can share a millisecond but never a sequence number. Step 3 opens with a fence. If the row carries an owner_node, the auction has been promoted to Section 8's hot-item mode: the transaction rolls back and the bid service forwards the request, same idempotency key, to that owner; if the owner is unreachable, the answer is a 503 with Retry-After: 0. The row itself refuses the normal path once promoted, so a node that has not yet heard about the promotion cannot accept a bid behind the owner's back. The rest of step 3 stamps nothing: accepted_at was taken on ingress, before any queueing, and is only compared here. Theo's 19:00:00.050 bid dies on the end-time check with AUCTION_ENDED, in the owner's memory by then since #7731 was promoted at 18:58, and the attempt row is still committed, so Section 10's "prove I bid" challenge has evidence.

The last check is the one candidates miss. If Marcus, already leading, raises his own maximum on Friday, nobody has outbid anybody: the engine is skipped, high_max_amount moves, and the price stays at $9,550. Lifting the price when the leader raises their own ceiling is bidding against yourself, in front of 50,000 watchers.

Steps 5 to 7 are one fact or no fact, and the outbox row is the one interviewers probe. Publish bid.accepted after commit, and a crash in between means Dana never learns she was outbid on Tuesday. Publish before commit, and a rollback means she learns it for a bid that never existed. A late notification is recoverable; a false one is not. So the event is a row in the same transaction, a relay publishes it after commit at least once, and consumers deduplicate on (auction_id, seq).

Flowchart of the ten-step bid path: idempotency lookup, SELECT FOR UPDATE on the auction row, forward to the hot-item owner if owner_node is set, validity checks that reject with a committed attempt row, then resolve, insert the bid with the next seq, bump the row version, write outbox and attempt rows, commit after the sync replica ack, and cache write-through

Quick check before moving on:


Sign in to continue reading

The rest of this lesson is available with a free account. Signing in with Google or Microsoft is free.

Sign in to read the full lesson

Sign in to MAANG.io

Use your Google or Microsoft account — no password to remember.

Continue with Google Continue with Microsoft

Please accept the terms above to continue.