System Design (High-Level Design) Module 26 – Case studies: booking, commerce and contention
Design movie seat booking like BookMyShow
Design movie seat booking like BookMyShow: seat holds that expire while people pay, no seat sold twice, payment callbacks and a waiting room.
What you will learn
- Estimate seat-map reads, holds, storage and bandwidth for a national booking platform
- Design seat holds that take a whole group or nothing and expire while the buyer pays
- Prevent double sales with a unique constraint and conditional updates
- Handle payment callbacks that repeat, arrive out of order or arrive after a hold has expired
- Size a waiting room and a seat-map cache for a blockbuster's opening day
- Explain how bookings stay in step with the cinemas' own ticketing systems
Before you start
What you’ll learn · Before you start
What you will learn
- Estimate seat-map reads, holds, storage and bandwidth for a national booking platform
- Design seat holds that take a whole group or nothing and expire while the buyer pays
- Prevent double sales with a unique constraint and conditional updates
- Handle payment callbacks that repeat, arrive out of order or arrive after a hold has expired
- Size a waiting room and a seat-map cache for a blockbuster's opening day
- Explain how bookings stay in step with the cinemas' own ticketing systems
Before you start
On this page
A movie booking system has one job that must never go wrong: each seat of each show is sold once, to one buyer, while thousands of people look at the same seat map. Four moves do it. The seats a buyer picks are held for a few minutes by one conditional update that takes the whole group or nothing. The payment runs while the hold lasts, and its result arrives as callbacks that can repeat or come late, so the code that handles them is idempotent. A row per sold seat, under a unique constraint on show and seat, is the final guarantee that no seat goes to two people, whatever a race or a bug does. And for a blockbuster’s opening day, a waiting room lets buyers in at the rate the booking system can take, while seat maps come from caches that are a second or two old. This case study goes from requirements and estimates to the API, the data model and the design, then answers every deep dive with a model or a program that you can run.
Not affiliated
Based on public information about BookMyShow. MySmartCoPilot is not affiliated with BookMyShow, and BookMyShow’s real architecture may differ from what this lesson describes.
The problem and its scope
Ask a few questions before drawing anything, because each answer changes the design:
- What is sold? Seats in cinemas, each with a row and a number. Events with general admission, such as a standing concert, sell a count instead, which is a simpler problem.
- How big? A national platform: assume 12,000 screens, 4 or 5 shows a screen each day, and about a million bookings a day, with Friday evenings and holidays well above the average.
- How many seats per booking? Up to 10, chosen on a seat map, usually 2 to 4 seats side by side.
- Who else sells the same seats? The cinema’s own box office and other platforms. Either the platform gets a block of seats per show, or it books through the cinema’s ticketing system, live.
- How do people pay? Cards, bank transfers and payment apps through a payment gateway; some payments take seconds and a few take minutes.
That gives the requirements. Buyers find shows by city, film, cinema and time; they see a show’s seat map with prices and current availability; they pick seats and hold them while they pay; a successful payment confirms the booking and issues a ticket, and a failed or abandoned one releases the seats; bookings can be cancelled under the cinema’s rules. Food and drink, reviews, recommendations and dynamic pricing are left out.
The qualities matter more than the features. No seat is ever sold twice, and a buyer who paid always ends up with a ticket or a refund. A hold should be answered quickly, say within 300 ms for 99 % of requests, because the buyer is watching a spinner. Seat maps may be a second or two old, but no more. Browsing should be available 99.95 % of the time; booking, when the database for a show cannot be reached, refuses rather than risks a double sale. And the system must survive opening days that bring many times the usual traffic within minutes.
Estimates
The assumptions, so that anyone can disagree with them: 1,000,000 bookings a day with 2.5 seats each; buyers open 15 seat maps for every booking they make; the busiest hour carries 10 % of a day’s traffic; each of 12,000 screens runs 4.5 shows a day, of 250 seats each.
| Quantity | Working | Result |
|---|---|---|
| Seat-map reads, average | 1,000,000 × 15 ÷ 86,400 s | about 174 a second |
| Seat-map reads, busiest hour | 10 % of 15 million ÷ 3,600 s | about 417 a second |
| Holds, busiest hour | 100,000 bookings × 1.3 tries ÷ 3,600 s | about 36 a second |
| Bookings stored | 1 KB each × 1,000,000 × 365 × 3 copies | about 1.1 TB a year |
| Seat rows | 12,000 × 4.5 × 250 = 13.5 million a day, about 60 bytes each | about 0.8 GB a day |
| Availability sent | 417 reads × 1 KB × 8 bits | about 3.3 Mbit/s |
These numbers are small. A single well-run database could take 36 holds a second, and 1.1 TB a year is modest. What makes seat booking hard is contention: on an opening day, a few hundred shows of one film draw hundreds of thousands of people in the first minutes, and they all want the same seats. Assume 400,000 people in the first 10 minutes for 300 shows of 250 seats: 75,000 seats, about 30,000 bookings, and more than ten people for every booking that can succeed. That rush, not the daily average, shapes the design.
The API
Eight calls cover a booking. The read calls are cacheable; the write calls carry an idempotency key, so a request that a phone sends again after a timeout never creates a second hold or a second payment.
GET /v1/shows?city=…&film=…&date=…lists shows; it is the same for everyone, so a CDN and caches serve it.GET /v1/layouts/{layoutId}/{version}returns a screen’s rows, seat numbers and price categories. A layout changes rarely, and a change gets a new version and so a new address, which lets it be cached for a year.GET /v1/shows/{showId}/availabilityreturns the layout version, an availability version and one bit per seat.POST /v1/shows/{showId}/holdswith the seats and anIdempotency-Keyheader answers201with a hold id, the amount and the expiry time, or409with the seats that were taken and a fresh availability.POST /v1/holds/{holdId}/paymentcreates a payment order with the gateway, using the hold id as its idempotency key, and returns what the app needs to show the payment page.POST /v1/payments/eventsreceives the gateway’s signed callbacks;GET /v1/bookings/{bookingId}returns the booking and its ticket;DELETE /v1/holds/{holdId}releases a hold early.
Stripe’s API reference describes the behaviour that idempotency keys give: the server saves the status and body of the first request made with a key and returns them to every retry, compares the parameters of a retry with the original, accepts keys of up to 255 characters, and may remove a key once it is at least 24 hours old (Stripe). Holds need the same rule for a shorter time: a retried hold request returns the hold that the first request made.
UUID Generator Make random version 4 UUIDs, a good shape for idempotency keys.The data model
The inventory database is partitioned by show, so everything about one show’s seats lives in one partition and every hold is a transaction on a single partition. Shows are independent of each other, which is what makes the partitioning safe: no booking ever spans two shows.
| Table | Key | What it holds |
|---|---|---|
shows |
show_id |
Screen, film, start time, layout version, on sale or closed |
show_seats |
(show_id, seat_id) |
State (available, held, booked), the hold id and the time the hold ends |
holds |
hold_id; (user_id, idempotency_key) unique |
Seats, amount, expiry, state |
booked_seats |
(show_id, seat_id) unique |
The booking that owns the seat: the last line of defence |
bookings |
booking_id; hold_id unique |
Buyer, show, amount, payment, ticket |
payment_events |
event_id |
Every gateway event already handled |
outbox |
id |
Events to publish, written in the same transaction as the change |
A unique constraint ensures that the values of a column, or a group of columns, are unique among all the rows of the
table, and PostgreSQL builds a unique index for it (PostgreSQL).
On booked_seats (show_id, seat_id) it turns “no seat sold twice” from a promise of the code into a rule of the
database. The outbox table follows the transactional outbox pattern: a change and the event that announces it are
saved in one transaction, and a separate worker publishes the event, so the two can never disagree
(Microsoft).
The high-level design
The first complete design of seat booking
Text description of the diagram
The diagram shows the parts of the design from top to bottom.
- The app or website reads posters, show lists and seat layouts from a CDN.
- For a hot show, the app passes a waiting room first; admitted buyers, and every buyer of an ordinary show, call the API gateway, which checks who they are and applies rate limits.
- Behind the gateway, a read service answers show times and seat maps from a cache, refreshed from the inventory database every one or two seconds.
- The booking service takes holds and confirms bookings with conditional updates in the inventory database, which is partitioned by show.
- The booking service asks the payment service to create a payment order with the payment gateway, which reports the result back with callbacks; a dashed arrow shows them.
- Every change to a booking also writes an outbox row in the same database transaction; those rows are published to an event queue (booked, released, expired), which feeds tickets and messages, the sync with cinema systems and reports.
Follow one buyer through it. Browsing never touches the inventory database: show lists, posters and layouts come from the CDN, and availability comes from the seat-map service, which reads each show’s seats from the database at most once a second and serves every viewer from that copy. Holding is one call to the booking service, which runs one conditional update in the show’s partition. Paying goes from the booking service to the payment service and the gateway; the result comes back as callbacks. Confirming writes the booking, its seats and an outbox row in one transaction. After the booking, the outbox worker publishes events to a queue that feeds tickets and messages, the cinema’s ticketing system and reports. The ticket itself is the booking id and seats with a signature, shown as a QR code, so a scanner at the door can check it even when the cinema’s connection is down. A waiting room sits in front only when a show is expected to be hot.
Every seat moves through three states, and each arrow is a single, checkable change:
The life of one seat of one show
Text description of the diagram
The diagram shows the three states of one seat of one show and the arrows between them.
- An available seat becomes held when a hold takes it, with one conditional update for the whole group of seats the buyer chose.
- A held seat becomes booked when the payment is confirmed while the hold is still live. A booked seat is one row in the booked_seats table, which is unique per show and seat.
- A held seat becomes available again when its hold expires, when the buyer cancels or when the payment is given up.
- A dashed arrow leads from booked back to available: a booking cancelled under the cinema's rules, with a refund, puts the seat back on sale.
Five deep dives follow, the ones a design round keeps coming back to: how a group of seats is held without double sales, how long a hold should last, how payment callbacks are handled, how the opening-day rush is absorbed, and how the platform stays in step with the cinemas.
Deep dive: holding a group of seats, all or nothing
Two buyers who see seat F-8 available at the same moment both try to hold it. If the code reads the seat, finds it
available and then writes the hold as two separate steps, both reads can happen before either write, and both
buyers get the seat. The fix is to check and write in one statement whose condition the database evaluates while it
writes: UPDATE show_seats SET state = 'held', hold_id = ?, held_until = ? WHERE show_id = ? AND seat_id IN (…) AND (state = 'available' OR (state = 'held' AND held_until <= ?)). Only one of two such updates can change the row.
Groups add a second problem. A family of four wants F-7 to F-10 together or not at all. Taking the seats one at a time, each with its own conditional update, is safe against double sales, but between two of those updates another buyer can take the next seat, and then the first buyer holds two seats nobody wants and must give them back. The run below gives the same 260 buyers, in groups of 1 to 6, the same 120-seat screen twice: once taking seats one at a time, and once with one update for the whole group inside one transaction, which is rolled back if the update changed fewer rows than the group has seats.
# Group holds on one show: 260 buyers want 1 to 6 adjacent seats of a 120-seat screen within a few seconds.
# Two ways to take a group's seats run on the same buyers, arriving in the same order:
# one seat at a time a conditional update per seat; on a clash, give back what was taken and pick again
# all or nothing one conditional update for the whole group, inside one transaction
# A seeded scheduler decides whose step runs next, the way a busy server interleaves requests, so every run
# prints the same. Whatever the hold logic does, booked_seats has UNIQUE (show_id, seat_id): a seat is sold once.
import random
import sqlite3
ROWS, PER_ROW = "ABCDEFGHIJ", 12
SEATS = [f"{r}-{n}" for r in ROWS for n in range(1, PER_ROW + 1)]
GROUP_SIZES = [1, 2, 2, 2, 3, 4, 4, 5, 6]
BUYERS, SHOW, TRIES = 260, 7, 3
def open_db():
db = sqlite3.connect(":memory:", autocommit=True) # BEGIN, COMMIT and ROLLBACK are written out below
db.executescript("""
CREATE TABLE show_seats (show_id INTEGER, seat_id TEXT, state TEXT NOT NULL DEFAULT 'available',
hold_id INTEGER, PRIMARY KEY (show_id, seat_id));
CREATE TABLE booked_seats (show_id INTEGER, seat_id TEXT, booking_id INTEGER, UNIQUE (show_id, seat_id));
""")
db.executemany("INSERT INTO show_seats (show_id, seat_id) VALUES (?, ?)", [(SHOW, s) for s in SEATS])
return db
def pick(db, size, rng):
"""The first block of `size` adjacent available seats in a randomly ordered list of rows, or None."""
open_seats = {s for (s,) in db.execute("SELECT seat_id FROM show_seats WHERE show_id = ? AND state = 'available'", (SHOW,))}
for row in rng.sample(ROWS, len(ROWS)):
for start in range(1, PER_ROW - size + 2):
block = [f"{row}-{n}" for n in range(start, start + size)]
if all(s in open_seats for s in block):
return block
return None
def give_back(db, buyer):
db.execute("UPDATE show_seats SET state = 'available', hold_id = NULL WHERE hold_id = ? AND state = 'held'", (buyer,))
def pay_then_confirm(db, buyer, size, rng, stats):
for _ in range(rng.randint(3, 8)): # the buyer is paying; other requests run meanwhile
yield
if rng.random() < 0.15: # gave up at the payment page
give_back(db, buyer)
stats["abandoned"] += 1
return
db.execute("BEGIN IMMEDIATE")
held = [s for (s,) in db.execute("SELECT seat_id FROM show_seats WHERE hold_id = ? AND state = 'held'", (buyer,))]
db.executemany("INSERT INTO booked_seats VALUES (?, ?, ?)", [(SHOW, s, buyer) for s in held])
db.execute("UPDATE show_seats SET state = 'booked' WHERE hold_id = ? AND state = 'held'", (buyer,))
db.execute("COMMIT")
stats["seated"] += 1
stats["groups of 4 to 6 seated"] += size >= 4
def partial_holder(db, seat, partial):
"""Was this seat refused because a buyer held it as part of a group they had not finished taking?"""
row = db.execute("SELECT hold_id FROM show_seats WHERE show_id = ? AND seat_id = ?", (SHOW, seat)).fetchone()
return row is not None and row[0] in partial
def one_seat_at_a_time(db, buyer, size, rng, stats, partial):
for _ in range(TRIES):
seats = pick(db, size, rng)
if seats is None:
stats["no block left"] += 1
return
yield # time passes between reading the map and writing
taken = 0
partial.add(buyer)
for seat in seats:
changed = db.execute("UPDATE show_seats SET state = 'held', hold_id = ? "
"WHERE show_id = ? AND seat_id = ? AND state = 'available'", (buyer, SHOW, seat)).rowcount
if changed == 0:
stats["clashes"] += 1
stats["clashes with a half-taken group"] += partial_holder(db, seat, partial)
break
taken += 1
yield # another request can run between two seats
partial.discard(buyer)
if taken == size:
yield from pay_then_confirm(db, buyer, size, rng, stats)
return
if taken:
stats["half-taken groups given back"] += 1
stats["seat-holds wasted"] += taken
give_back(db, buyer)
yield
stats["gave up after 3 tries"] += 1
def all_or_nothing(db, buyer, size, rng, stats, partial):
for _ in range(TRIES):
seats = pick(db, size, rng)
if seats is None:
stats["no block left"] += 1
return
yield # time passes between reading the map and writing
marks = ", ".join("?" * size)
db.execute("BEGIN IMMEDIATE")
changed = db.execute(f"UPDATE show_seats SET state = 'held', hold_id = ? WHERE show_id = ? "
f"AND seat_id IN ({marks}) AND state = 'available'", (buyer, SHOW, *seats)).rowcount
if changed == size:
db.execute("COMMIT")
yield from pay_then_confirm(db, buyer, size, rng, stats)
return
db.execute("ROLLBACK") # nothing of the group stays held
stats["clashes"] += 1
yield
stats["gave up after 3 tries"] += 1
def run(strategy):
db, rng = open_db(), random.Random(2026)
sizes = [rng.choice(GROUP_SIZES) for _ in range(BUYERS)]
stats = {k: 0 for k in ("seated", "groups of 4 to 6 seated", "abandoned", "clashes", "clashes with a half-taken group",
"half-taken groups given back", "seat-holds wasted", "gave up after 3 tries", "no block left")}
partial, active, arrived = set(), [], 0
while arrived < BUYERS or active:
if arrived < BUYERS and rng.random() < 0.6: # a rush: a new buyer arrives at most steps
active.append(strategy(db, arrived, sizes[arrived], rng, stats, partial))
arrived += 1
if active:
i = rng.randrange(len(active))
try:
next(active[i])
except StopIteration:
active.pop(i)
sold = db.execute("SELECT COUNT(*), COUNT(DISTINCT seat_id) FROM booked_seats").fetchone()
stats["seats sold"], stats["distinct seats sold"] = sold
stats["seats unsold"] = db.execute("SELECT COUNT(*) FROM show_seats WHERE state = 'available'").fetchone()[0]
return db, stats
results = {}
for name, strategy in (("one seat at a time", one_seat_at_a_time), ("all or nothing", all_or_nothing)):
db, results[name] = run(strategy)
print(f"{BUYERS} buyers, groups of 1 to 6, a {len(SEATS)}-seat screen\n")
print(f"{'':34}{'one seat at a time':>20}{'all or nothing':>16}")
for key in results["all or nothing"]:
print(f"{key:34}{results['one seat at a time'][key]:>20}{results['all or nothing'][key]:>16}")
# The last line of defence: a confirm path with a bug that skips the hold check still cannot sell a seat twice.
seat, owner = db.execute("SELECT seat_id, booking_id FROM booked_seats ORDER BY rowid LIMIT 1").fetchone()
try:
db.execute("INSERT INTO booked_seats VALUES (?, ?, ?)", (SHOW, seat, 9999))
except sqlite3.IntegrityError as e:
print(f"\nA buggy confirm tried to sell {seat} again: IntegrityError: {e}") Output
260 buyers, groups of 1 to 6, a 120-seat screen
one seat at a time all or nothing
seated 55 35
groups of 4 to 6 seated 3 18
abandoned 10 5
clashes 383 69
clashes with a half-taken group 341 0
half-taken groups given back 98 0
seat-holds wasted 129 0
gave up after 3 tries 56 3
no block left 139 217
seats sold 99 120
distinct seats sold 99 120
seats unsold 21 0
A buggy confirm tried to sell I-1 again: IntegrityError: UNIQUE constraint failed: booked_seats.show_id, booked_seats.seat_id
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 group_holds.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Taking seats one at a time sold 99 seats and left 21 unsold, although 56 buyers gave up after three tries. Most of its 383 clashes, 341 of them, were with a group that was only half taken and was about to be given back, so buyers were refused seats that nobody would end up buying. Large groups suffered most: 3 groups of 4 to 6 got seats, against 18 when each group was taken all or nothing. The all-or-nothing version sold all 120 seats, with 69 clashes and no seat ever held by a group that did not get the rest of its seats. It seated fewer buyers, 35 against 55, because it seated the big groups that the other version kept failing.
The last line shows the unique constraint at work. A confirmation path with a bug that skips the hold check tried to sell a booked seat again, and the database refused it. The constraint does not make the hold logic unnecessary, because a buyer who has already paid must not be refused at the last step, but it makes sure that a bug turns into an error and a refund, never into two people with tickets for the same seat.
Three details complete the hold. In PostgreSQL, two updates that lock overlapping seats in different orders can
deadlock; PostgreSQL detects the deadlock and aborts one of the transactions, and its documentation advises taking
locks on several objects in a consistent order (PostgreSQL),
so sort the seat ids and retry a transaction aborted by a deadlock. When the buyer asks for “the best two seats” instead of
choosing, SELECT … FOR UPDATE SKIP LOCKED lets each request lock different available seats without queueing behind
the others; PostgreSQL warns that skipping locked rows gives an inconsistent view, suitable for queue-like work rather
than general queries (PostgreSQL),
which describes picking any available seat from a block. And a fast cache can screen holds before the database, for
example SET seat-key hold-id NX PX 300000 in Redis, which sets a key only if it does not exist and gives it an expiry
in milliseconds (Redis); the conditional update in the database still
decides, because a cache can lose keys when it fails over.
Deep dive: how long a hold should last
A hold is a promise to one buyer and a refusal to everyone else, so its length is a trade. Too short, and people who are still paying lose their seats: their payment arrives late and needs a refund or a new seat check. Too long, and seats sit held for people who closed the app, while other buyers are told the show is full. The model below takes 20,000 checkouts with assumed payment times (most pay within two or three minutes, a few need much longer because of a slow bank page or a second payment method) and counts both costs for several hold lengths.
# How long should a seat hold last? 20,000 checkouts on a busy evening. Buyers spend a little while on the
# summary page, start paying, and most payments finish within two or three minutes; a few need much longer (a
# slow bank page, a second payment method). A short hold turns slow payers into late payments, which need a
# refund or a new seat; a long hold keeps seats away from other buyers while people who walked away still hold
# them. All times are assumptions of this model, not measurements of any platform. Seeded: every run prints
# the same.
import math
import random
CHECKOUTS = 20_000
rng = random.Random(42)
def lognormal(median_s, sigma):
return median_s * math.exp(rng.gauss(0, sigma))
# (share, median seconds, spread) of the time from starting a payment to the gateway's confirmation.
METHODS = [(0.45, 40, 0.5), (0.35, 80, 0.45), (0.12, 140, 0.5), (0.08, 380, 0.4)]
def payment_time():
x, acc = rng.random(), 0.0
for share, median, sigma in METHODS:
acc += share
if x < acc:
return lognormal(median, sigma)
return lognormal(METHODS[-1][1], METHODS[-1][2])
def checkout():
"""(kind, started, finished): 'paid'; 'left' before paying (finished is None); 'stalled', a payment that was
started and never finished."""
r, started = rng.random(), lognormal(45, 0.6)
if r < 0.72:
return "paid", started, started + payment_time()
if r < 0.92:
return "left", started, None
return "stalled", started, None
people = [checkout() for _ in range(CHECKOUTS)]
def policy(ttl_s, cap_s=None):
"""Late payments, and hold-minutes spent on buyers who never paid. With cap_s, a payment started while the
hold is live extends it once, to cap_s from the start of the hold."""
late = wasted = 0.0
for kind, started, finished in people:
ends = ttl_s
if cap_s and kind != "left" and started < ttl_s:
ends = cap_s
if kind == "paid":
late += finished > ends
else:
wasted += ends / 60 # closing the app tells the server nothing: the hold runs out
return late, wasted
paid = sum(k == "paid" for k, _, _ in people)
print(f"{CHECKOUTS:,} checkouts: {paid:,} paid, {CHECKOUTS - paid:,} never paid\n")
print(f"{'hold':>19}{'late payments':>15}{'per 1,000':>11}{'hold-minutes':>14}")
print(f"{'':>19}{'':>15}{'checkouts':>11}{'for nobody':>14}")
rows = [(f"{m} min", policy(m * 60)) for m in (3, 5, 7, 10, 15, 20)]
rows.append(("5 min, then to 10", policy(5 * 60, 10 * 60)))
for label, (late, wasted) in rows:
print(f"{label:>19}{int(late):>15,}{late / CHECKOUTS * 1000:>11.1f}{wasted / CHECKOUTS * 1000:>14,.0f}")
print("\nhold-minutes for nobody: per 1,000 checkouts, minutes of holds that ended without a payment") Output
20,000 checkouts: 14,408 paid, 5,592 never paid
hold late payments per 1,000 hold-minutes
checkouts for nobody
3 min 3,550 177.5 839
5 min 1,369 68.5 1,398
7 min 703 35.1 1,957
10 min 224 11.2 2,796
15 min 30 1.5 4,194
20 min 4 0.2 5,592
5 min, then to 10 234 11.7 1,796
hold-minutes for nobody: per 1,000 checkouts, minutes of holds that ended without a payment
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 hold_ttl.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
A 3-minute hold makes 177.5 payments in every 1,000 checkouts late; a 20-minute hold makes almost none late, but keeps seats for 5,592 minutes per 1,000 checkouts for people who will never pay. The last row is the policy worth using: hold for 5 minutes, and extend the hold once, to 10 minutes from its start, when the buyer actually starts a payment. It has almost the same late payments as a flat 10-minute hold, 11.7 against 11.2 per 1,000, and wastes 36 % fewer hold-minutes, because people who walk away before paying never get the extension.
Three rules make holds work in practice. The expiry is checked inside the query that takes seats, using the database’s clock, so a seat is available again the moment its hold ends even if no background job has run; a sweeper still marks expired holds and publishes “released” events so that seat maps update. The app shows the buyer the time left, from the server’s expiry time, so nobody is surprised. And the payment order is created with an expiry no later than the hold’s last minute, so a payment cannot start after the seats are gone.
Holds are also a target. Holding seats repeatedly without paying, to make a show look sold out or to keep seats for resale, is listed by OWASP as an automated threat, denial of inventory (OWASP). Allow one live hold per account per show, at most 10 seats in it, and a few holds an hour per account and device; cards and accounts that hold often and never pay are rate limited more tightly.
Deep dive: payment callbacks that repeat, reorder or arrive late
The payment is a saga of three steps: the hold, which can be undone by releasing it; the payment, after which there is no going back; and the confirmation, which must then be retried until it succeeds. Microsoft’s saga pattern calls these compensable, pivot and retryable transactions, and notes that retryable ones must be idempotent (Microsoft).
The gateway reports the result with callbacks, and gateways are explicit that callbacks are imperfect. Razorpay
delivers webhooks at least once, so the same event can arrive several times; it gives each event a unique
x-razorpay-event-id, counts any response other than 2xx, or no response within 5 seconds, as a failure, retries
with exponential backoff for 24 hours, and says events may arrive out of order
(Razorpay). Stripe retries for up to three days in live mode,
does not guarantee the order of events, and advises logging the ids of processed events and returning a 2xx before
any slow work (Stripe). So the handler checks the signature, records the event id
in the same transaction as the change it makes, applies only changes that move a booking forward, and does the slow
work, such as issuing the ticket, from the outbox afterwards.
// Payment callbacks for seat holds. A payment gateway delivers each event at least once, not always in order,
// and sometimes after the hold it pays for has expired. This handler confirms each paid hold exactly once,
// ignores repeats, and turns a payment that arrives too late into a fresh seat check or a refund.
// Times are seconds after the holds were taken; a hold lasts 300 s, extended once to 600 s when a payment starts.
const seats = new Map(); // seat -> { state: 'available' | 'held' | 'booked', hold }
const holds = new Map(); // hold id -> { seats, expiresAt, state }
const bookings = new Map(); // hold id -> booking: one per hold, never two
const processed = new Set(); // event ids already handled
const refunds = [];
for (const s of ['F-7', 'F-8', 'F-9', 'F-10', 'F-11', 'G-4', 'G-5', 'G-6']) seats.set(s, { state: 'available', hold: null });
function hold(id, list, now) {
if (!list.every((s) => seats.get(s).state === 'available')) return false;
for (const s of list) seats.set(s, { state: 'held', hold: id });
holds.set(id, { seats: list, expiresAt: now + 300, state: 'held' });
return true;
}
function paymentStarted(id, now) {
const h = holds.get(id);
if (h.state === 'held' && now < h.expiresAt) h.expiresAt = Math.max(h.expiresAt, 600);
}
function expire(now) {
for (const [id, h] of holds) {
if (h.state !== 'held' || now < h.expiresAt) continue;
h.state = 'expired';
for (const s of h.seats) if (seats.get(s).hold === id) seats.set(s, { state: 'available', hold: null });
console.log(`${String(now).padStart(4)} s ${id} expired: ${h.seats.join(', ')} back on sale`);
}
}
function confirm(id, h) {
for (const s of h.seats) seats.set(s, { state: 'booked', hold: id });
h.state = 'confirmed';
bookings.set(id, { seats: h.seats });
}
// In a real handler the event id is recorded in the same database transaction as the change it causes.
function handle(ev, now) {
const at = `${String(now).padStart(4)} s ${ev.id} ${ev.type.padEnd(18)} ${ev.hold}`;
if (processed.has(ev.id)) return console.log(`${at}: already handled, ignored`);
processed.add(ev.id);
const h = holds.get(ev.hold);
if (ev.type === 'payment.failed') return console.log(`${at}: payment failed, the hold stays until it expires`);
if (ev.type === 'payment.authorized') {
if (h.state === 'confirmed') return console.log(`${at}: older than what we know (already confirmed), ignored`);
return console.log(`${at}: authorised, waiting for capture`);
}
if (bookings.has(ev.hold)) return console.log(`${at}: booking exists, nothing to do`);
if (h.state === 'held' && now < h.expiresAt) {
confirm(ev.hold, h);
return console.log(`${at}: confirmed ${h.seats.join(', ')}`);
}
// Too late: the hold expired. Take the seats again if nobody else has them; otherwise refund.
if (h.seats.every((s) => seats.get(s).state === 'available')) {
confirm(ev.hold, h);
return console.log(`${at}: late, but the seats were still unsold: confirmed ${h.seats.join(', ')}`);
}
h.state = 'refund-due';
refunds.push(ev.payment);
const lost = h.seats.filter((s) => seats.get(s).state !== 'available' && seats.get(s).hold !== ev.hold);
console.log(`${at}: late, and ${lost.join(', ')} now belongs to another buyer: refund ${ev.payment}`);
}
hold('H1', ['F-7', 'F-8'], 0);
hold('H2', ['F-9', 'F-10', 'F-11'], 0);
hold('H3', ['G-4'], 0);
hold('H4', ['G-5', 'G-6'], 0);
const timeline = [
[20, () => paymentStarted('H1', 20)],
[40, () => paymentStarted('H2', 40)],
[70, () => handle({ id: 'ev-101', type: 'payment.captured', hold: 'H1', payment: 'pay-1' }, 70)],
[75, () => handle({ id: 'ev-101', type: 'payment.captured', hold: 'H1', payment: 'pay-1' }, 75)],
[80, () => handle({ id: 'ev-102', type: 'payment.failed', hold: 'H2', payment: 'pay-2a' }, 80)],
[95, () => handle({ id: 'ev-103', type: 'payment.captured', hold: 'H2', payment: 'pay-2b' }, 95)],
[110, () => handle({ id: 'ev-099', type: 'payment.authorized', hold: 'H1', payment: 'pay-1' }, 110)],
[300, () => expire(300)],
[320, () => console.log(` 320 s another buyer holds G-6: ${hold('H5', ['G-6'], 320)}`)],
[430, () => handle({ id: 'ev-104', type: 'payment.captured', hold: 'H3', payment: 'pay-3' }, 430)],
[470, () => handle({ id: 'ev-105', type: 'payment.captured', hold: 'H4', payment: 'pay-4' }, 470)],
[480, () => handle({ id: 'ev-105', type: 'payment.captured', hold: 'H4', payment: 'pay-4' }, 480)],
];
for (const [, step] of timeline) step();
const sold = [...seats.entries()].filter(([, v]) => v.state === 'booked').map(([s]) => s);
console.log(`\nbookings: ${[...bookings.keys()].join(', ')} (${bookings.size}, one per paid hold that kept its seats)`);
console.log(`refunds: ${refunds.join(', ')}`);
console.log(`seats booked: ${sold.join(', ')}; every one by exactly one hold: ${sold.every((s) => bookings.has(seats.get(s).hold))}`); Output
70 s ev-101 payment.captured H1: confirmed F-7, F-8 75 s ev-101 payment.captured H1: already handled, ignored 80 s ev-102 payment.failed H2: payment failed, the hold stays until it expires 95 s ev-103 payment.captured H2: confirmed F-9, F-10, F-11 110 s ev-099 payment.authorized H1: older than what we know (already confirmed), ignored 300 s H3 expired: G-4 back on sale 300 s H4 expired: G-5, G-6 back on sale 320 s another buyer holds G-6: true 430 s ev-104 payment.captured H3: late, but the seats were still unsold: confirmed G-4 470 s ev-105 payment.captured H4: late, and G-6 now belongs to another buyer: refund pay-4 480 s ev-105 payment.captured H4: already handled, ignored bookings: H1, H2, H3 (3, one per paid hold that kept its seats) refunds: pay-4 seats booked: F-7, F-8, F-9, F-10, F-11, G-4; every one by exactly one hold: true
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node payment_callbacks.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
Every case is in the run. The repeated ev-101 and ev-105 were ignored. A failed payment for H2 left its hold in
place, so the buyer’s second attempt confirmed it. An authorized event that arrived after captured was older than
what the system knew and changed nothing. Two payments came after their holds had expired: H3’s seat was still
unsold, so it was taken again and confirmed, while one of H4’s seats now belonged to another buyer, so H4’s payment
was marked for a refund. Three bookings, one refund, and every booked seat owned by exactly one hold.
Callbacks are not the only path. A payment can be interrupted between the buyer, the bank and the gateway, and Razorpay describes such late authorisation, where money may or may not have left the buyer’s account and no result has come back yet (Razorpay). A reconciliation job therefore asks the gateway for the status of every payment still pending a few minutes after it started, and treats the answer like a callback. Where the gateway allows it, capture the payment only after the seats are confirmed: Razorpay lists the states created, authorized, captured, refunded and failed, and refunds an authorised payment that is not captured within 3 days (Razorpay), so a late payment that cannot be seated is simply never captured.
Deep dive: the opening-day rush
Go back to the rush: 400,000 people in 10 minutes for 75,000 seats. Without a waiting room, they all reach the booking system at once, most of them clash over the same good seats, timeouts lead to retries, and the retries add to the load just when it is highest. A waiting room keeps the booking system at a rate it can take. Every visitor gets a ticket number; a “now serving” cursor moves forward at the admission rate; a visitor whose number the cursor has passed gets a signed admission token, which the booking API checks with the waiting room’s key alone. Fastly’s waiting-room tutorial builds the same machine from two counters and a signed cookie (Fastly).
# A waiting room for an opening-day rush: a ticket counter, a "now serving" cursor that moves at the rate the
# booking system can take, and signed tokens, so that the booking API can check an admission without asking
# the waiting room. Tokens are HMAC-SHA256 signatures over a small JSON payload; the secret below is a demo
# value (a real one lives in a secret store and rotates). Time is passed in, so every run prints the same.
import base64
import hashlib
import hmac
import json
SECRET = b"demo-secret-of-the-waiting-room"
def b64(data):
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
def sign(payload):
body = b64(json.dumps(payload, separators=(",", ":")).encode())
return body + "." + b64(hmac.new(SECRET, body.encode(), hashlib.sha256).digest())
def read(token, kind, event, now):
"""(payload, "ok") if the token is genuine, of this kind, for this event and not expired; else (None, why)."""
try:
body, mac = token.split(".")
if not hmac.compare_digest(mac, b64(hmac.new(SECRET, body.encode(), hashlib.sha256).digest())):
return None, "bad signature"
payload = json.loads(base64.urlsafe_b64decode(body + "=" * (-len(body) % 4)))
except ValueError:
return None, "not a token"
if payload["kind"] != kind or payload["event"] != event:
return None, f"it is a {payload['kind']} token"
if now >= payload["exp"]:
return None, "expired"
return payload, "ok"
class WaitingRoom:
def __init__(self, event, per_second):
self.event, self.per_second = event, per_second
self.next_ticket, self.admitted_up_to, self.since = 1, 0, 0.0
self.sold_out = False
def join(self, now):
ticket, self.next_ticket = self.next_ticket, self.next_ticket + 1
return sign({"kind": "queue", "event": self.event, "ticket": ticket, "exp": now + 3600})
def advance(self, now):
"""Move the cursor at the admission rate; once the booking system says sold out, it never moves again."""
if not self.sold_out:
steps = int((now - self.since) * self.per_second)
self.admitted_up_to += steps
self.since += steps / self.per_second
def check(self, queue_token, session, now):
self.advance(now)
payload, why = read(queue_token, "queue", self.event, now)
if payload is None:
return "refused", why
position = payload["ticket"] - self.admitted_up_to
if position <= 0:
return "admitted", sign({"kind": "admission", "event": self.event, "session": session, "exp": now + 600})
if self.sold_out:
return "sold out", "nobody else will be let in"
return "waiting", f"position {position}, about {position / self.per_second:.1f} s"
room = WaitingRoom("show-day-1", per_second=2)
queue = {f"u{i}": room.join(now=0) for i in range(1, 13)} # 12 people join in the opening second
for now, user in [(0, "u1"), (0, "u12"), (2, "u3"), (2, "u12"), (4, "u8")]:
state, detail = room.check(queue[user], session=user, now=now)
print(f"t={now} s {user}: {state}" + ("" if state == "admitted" else f" ({detail})"))
_, admit = room.check(queue["u3"], session="u3", now=5)
print("\nThe booking API checks an admission token with the secret alone, without a call to the waiting room:")
print(f" u3's admission token at t=5 s: {read(admit, 'admission', 'show-day-1', 5)[1]}")
print(f" the same token at t=605 s: {read(admit, 'admission', 'show-day-1', 605)[1]}")
print(f" u2's queue token used to book: {read(queue['u2'], 'admission', 'show-day-1', 5)[1]}")
body, mac = queue["u12"].split(".")
forged = json.loads(base64.urlsafe_b64decode(body + "=" * (-len(body) % 4)))
forged["ticket"] = 1 # u12 edits the ticket number in its token
edited = b64(json.dumps(forged, separators=(",", ":")).encode()) + "." + mac
print(" u12's edited queue token: %s: %s" % room.check(edited, session="u12", now=5))
room.sold_out = True # the booking system: every seat is taken or held
print(" u12 after the sell-out, t=6 s: %s: %s" % room.check(queue["u12"], session="u12", now=6)) Output
t=0 s u1: waiting (position 1, about 0.5 s) t=0 s u12: waiting (position 12, about 6.0 s) t=2 s u3: admitted t=2 s u12: waiting (position 8, about 4.0 s) t=4 s u8: admitted The booking API checks an admission token with the secret alone, without a call to the waiting room: u3's admission token at t=5 s: ok the same token at t=605 s: expired u2's queue token used to book: it is a queue token u12's edited queue token: refused: bad signature u12 after the sell-out, t=6 s: sold out: nobody else will be let in
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 admission_tokens.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The cursor moved at 2 people a second, so u3 was let in at 2 seconds and u8 at 4, while u12 saw its position fall from
12 to 8. The booking API accepted u3’s admission token, refused it 600 seconds later when it expired, refused a queue
token presented as an admission token, and refused u12’s token after u12 had edited its ticket number, because the
signature no longer matched. Signatures are compared with hmac.compare_digest, which avoids the timing differences
of an ordinary comparison (Python). Once the show sold
out, the cursor stopped and u12 was told so at once, instead of waiting for a seat that would never come.
The admission rate comes from Little’s law. Suppose an admitted buyer spends 4 minutes, 240 seconds, choosing seats and paying, and makes about 8 requests to the booking system on the way, and suppose the booking system can take 1,500 such requests a second, of which you plan to use 70 %. Then 1,050 requests a second allow 1,050 × 240 ÷ 8 = 31,500 buyers inside at once, and Little’s law (people inside = arrival rate × time inside) gives an admission rate of 31,500 ÷ 240, about 131 people a second. If three in four admitted buyers book, 30,000 bookings need about 40,000 admissions, which take about 5 minutes. People far down the queue learn early that the show sold out, which is kinder than a long wait and saves their requests too.
Two more choices keep the rush fair and cheap. Visitors who join before sales open get a random place among
themselves, so refreshing at the exact second gains nothing, while later arrivals queue in order. And seat maps
during the rush are cheap by construction. The layout is immutable and versioned, so the CDN serves it with
Cache-Control: immutable, which RFC 8246 defines as a promise that the resource will not change while it is fresh,
so clients need not revalidate it (RFC 8246). Availability is
computed once a second for each show and served from that copy, so 300 hot shows cost the database 300 reads a
second however many people are watching, and a 409 carries fresh availability, so a buyer who lost a seat sees the
current map at once. OWASP lists getting scarce goods by unfair automated means as scalping
(OWASP), so the
waiting room also issues one ticket per signed-in account and challenges suspicious traffic before it gets a number.
Deep dive: the cinema’s own systems
The platform rarely owns the seats outright. In this design the cinema also sells at its own box office, through its own ticketing system, and possibly through other platforms, so the integration takes one of two shapes.
- An allocation. The cinema gives the platform a block of seats per show, and the platform sells it as described so far. Unsold seats are handed back at a cut-off before the show. It is simple and fast, but seats outside the block cannot be sold even when the block has sold out.
- Live inventory. Every hold and confirmation is also made in the cinema’s system through its API, which stays the source of truth. A hold then exists twice, and the shorter of the two expiries is the real one. Calls to the cinema’s system can be slow or fail, so each carries an idempotency key, and a booking can wait in a “confirming” state while the call is retried.
Either way, a reconciliation job compares the platform’s bookings with the cinema’s records for every show, regularly and once more shortly before the show starts. A seat that both sides think they sold is a problem to solve before two people arrive at it: the newer booking is moved to an equivalent seat if one exists, or refunded with an apology. The events of the outbox make the cinema’s view current within seconds, and reconciliation catches what events miss.
What breaks at 10× and 100×
| Load | What gives way first | The change |
|---|---|---|
| 10× (10 million bookings a day) | Seat-map reads and the availability cache | More cache replicas, availability pushed to watching apps instead of polled |
| 10× | The payment gateway’s rate limits | Several gateways, a payment queue with backpressure, holds extended while a payment waits |
| 100× (a national release on one day) | Writes to the hottest shows’ partitions | One queue per hot show, so one worker decides its holds in order |
| 100× | Messages and tickets after a wave of bookings | Outbox events processed by many consumers, tickets made lazily when first opened |
| Any | A region failing | Shows partitioned so that a region owns its cinemas; a failed region refuses bookings instead of overselling |
Failure drills
Each line pairs a failure with what this design does about it.
- The inventory database’s primary fails during a confirmation. The transaction either committed or did not. The payment handler retries with the same event; if the booking exists, the retry finds it and does nothing.
- The webhook endpoint is down for 20 minutes. Gateways retry for hours, and the reconciliation job asks for the status of pending payments, so bookings confirm late but not twice; holds extended for started payments cover most of the gap.
- The sweeper stops. Holds still expire, because every hold query checks the expiry time. Seat maps show expired holds as taken until the sweeper resumes, so an alert on its lag matters.
- The seat-map cache serves a stale map. Buyers pick taken seats and get a
409with fresh availability; nothing is sold twice, and the clash rate shows the problem. - A deploy ships a confirmation path that skips the hold check. The unique constraint refuses the second sale, the error rate rises, and the deploy is rolled back with refunds instead of angry customers at the cinema.
- The gateway is slow, with payments taking 30 seconds. Holds of started payments are extended to their cap, the waiting room slows its admission, and new holds stop when the payment queue is too long.
Options considered and rejected
| Option | Why it is rejected |
|---|---|
| A distributed lock per show around every booking | Serialises all buyers of a show and depends on lock timing; the database’s conditional update already decides each seat |
| Holds kept only in a cache | A failover can lose holds and sell a held seat; a cache may screen holds, but the database decides |
| Pay first, choose seats afterwards | Fine for general admission; with seat maps, buyers would pay without knowing their seats |
| Holds of 20 minutes | Almost no late payments, but seats held for nobody grow four times compared with 5 minutes extended to 10 |
| Checking availability only at payment | Buyers pay for seats someone else got, and every clash becomes a refund |
What each level is expected to cover
- Fresher: the requirements, the API, a table of seats with states, and a unique constraint against double sales.
- Mid-level (2 to 5 years): holds with expiry, the conditional update for a whole group, idempotency keys, and estimates that find the opening-day rush.
- Senior (5 to 10 years): the payment saga with duplicate and late callbacks, the hold length chosen with numbers, a waiting room sized with Little’s law, and partitioning by show.
- Staff (10 years and more): integration with cinema systems and reconciliation, the failure drills, abuse and fairness, and what changes at 10× and 100×.
A 45-minute self-mock
Set a timer and say your answer aloud, or write it. Spend 5 minutes on scope questions and requirements, 5 on the estimates, 10 on the API, the data model and the high-level design, 15 on two deep dives of your choice (groups of seats and late payments are the usual pair), 5 on the opening-day rush and 5 on failures and trade-offs. Afterwards, go through your answer next to this page, one section at a time, and against the levels listed above.
Exercise and self-check
Exercise · Medium · Python
Hold a group of seats all or nothing, then confirm it
Write two functions in holds.py for the seat table of a cinema booking system. Each show's seats are rows of show_seats (show_id, seat_id, state, hold_id, held_until), where state is 'available', 'held' or 'booked', and held_until is a time in milliseconds. Times are passed in as now_ms, so the tests decide what "now" is. The connection is opened with autocommit=True, so write BEGIN, COMMIT and ROLLBACK yourself.
hold_group(db, show_id, seats, hold_id, now_ms, ttl_ms)holds every seat in the list forhold_iduntilnow_ms + ttl_ms, or none of them. A seat can be taken if it is available, or if it is held by a hold whoseheld_untilis not later thannow_ms(that hold has expired). A booked seat can never be taken. ReturnTrueif the group is held; otherwise returnFalseand leave every row as it was. An empty list returnsFalse.confirm(db, show_id, hold_id, now_ms)books the seats of a hold that is still live (held_untillater thannow_ms): their state becomes'booked', and one row per seat goes intobooked_seats (show_id, seat_id, hold_id), which is unique per show and seat. Return the number of seats booked. A hold that has expired books nothing and returns 0: the caller refunds that payment or looks for new seats.
For example, with seats A-1 to A-4 available, hold_group(db, 1, ["A-1", "A-2"], 7, 0, 300000) returns True; at now_ms=1000, hold_group(db, 1, ["A-2", "A-3"], 8, 1000, 300000) returns False and A-3 stays available. The sample tests create the tables, import your functions and run in your browser.
Starter code · holds.py
def hold_group(db, show_id, seats, hold_id, now_ms, ttl_ms):
"""Hold every seat in `seats` for `hold_id` until now_ms + ttl_ms, or none of them; True if held."""
# Replace this line with your code.
return False
def confirm(db, show_id, hold_id, now_ms):
"""Book the seats of a live hold; return how many were booked (0 if the hold has expired)."""
# Replace this line with your code.
return 0 The sample tests · test_holds.py
import sqlite3
from holds import confirm, hold_group
TTL = 300_000
def make_db():
db = sqlite3.connect(":memory:", autocommit=True)
db.executescript("""
CREATE TABLE show_seats (show_id INTEGER, seat_id TEXT, state TEXT NOT NULL DEFAULT 'available',
hold_id INTEGER, held_until INTEGER, PRIMARY KEY (show_id, seat_id));
CREATE TABLE booked_seats (show_id INTEGER, seat_id TEXT, hold_id INTEGER, UNIQUE (show_id, seat_id));
""")
for show in (1, 2):
db.executemany("INSERT INTO show_seats (show_id, seat_id) VALUES (?, ?)", [(show, f"A-{n}") for n in range(1, 5)])
return db
def states(db, show_id=1):
return {s: (st, h) for s, st, h in db.execute("SELECT seat_id, state, hold_id FROM show_seats WHERE show_id = ?", (show_id,))}
def test_holds_available_seats():
"""a group of available seats is held for the hold id"""
db = make_db()
assert hold_group(db, 1, ["A-1", "A-2"], 7, 0, TTL) is True
s = states(db)
assert s["A-1"] == ("held", 7) and s["A-2"] == ("held", 7) and s["A-3"] == ("available", None)
assert db.execute("SELECT held_until FROM show_seats WHERE seat_id = 'A-1' AND show_id = 1").fetchone()[0] == TTL
def test_all_or_nothing():
"""one seat held by a live hold refuses the whole group and changes nothing"""
db = make_db()
hold_group(db, 1, ["A-1", "A-2"], 7, 0, TTL)
before = states(db)
assert hold_group(db, 1, ["A-2", "A-3"], 8, 1000, TTL) is False
assert states(db) == before
def test_other_show_untouched():
"""seats with the same names in another show are separate seats"""
db = make_db()
hold_group(db, 1, ["A-1"], 7, 0, TTL)
assert hold_group(db, 2, ["A-1"], 8, 0, TTL) is True
assert states(db, 2)["A-1"] == ("held", 8)
def test_expired_hold_can_be_taken():
"""a hold whose time has come no longer protects its seats"""
db = make_db()
hold_group(db, 1, ["A-1"], 7, 0, TTL)
assert hold_group(db, 1, ["A-1", "A-2"], 8, TTL, TTL) is True
assert states(db)["A-1"] == ("held", 8)
def test_booked_seat_is_never_taken():
"""a booked seat stays booked, whatever its old hold time says"""
db = make_db()
hold_group(db, 1, ["A-1"], 7, 0, TTL)
assert confirm(db, 1, 7, 1000) == 1
assert hold_group(db, 1, ["A-1"], 8, 10 * TTL, TTL) is False
assert states(db)["A-1"] == ("booked", 7)
def test_confirm_live_hold():
"""confirming a live hold books every seat once"""
db = make_db()
hold_group(db, 1, ["A-3", "A-4"], 9, 0, TTL)
assert confirm(db, 1, 9, TTL - 1) == 2
rows = sorted(db.execute("SELECT seat_id, hold_id FROM booked_seats WHERE show_id = 1"))
assert rows == [("A-3", 9), ("A-4", 9)]
def test_confirm_expired_hold():
"""an expired hold books nothing, even when nobody else has taken its seats"""
db = make_db()
hold_group(db, 1, ["A-1"], 7, 0, TTL)
assert confirm(db, 1, 7, TTL) == 0
assert db.execute("SELECT COUNT(*) FROM booked_seats").fetchone()[0] == 0
def test_empty_group():
"""an empty list holds nothing"""
db = make_db()
assert hold_group(db, 1, [], 7, 0, TTL) is False A hint
Do the check and the write in one UPDATE with the condition in its WHERE clause: state = 'available' OR (state = 'held' AND held_until <= ?), for seat_id IN (…). Inside a transaction, the update's rowcount tells you how many seats it took; if that is fewer than the seats asked for, ROLLBACK undoes the ones it did take.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Check yourself
6 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
Interview questions
Why does a booking system hold seats with an expiry instead of locking them until the payment finishes? A database lock held for the minutes a payment takes would keep a transaction open while a person types, block every other buyer who touches those rows, and depend on the connection staying alive. A hold is ordinary data instead: a state and an end time on each seat, written by a short transaction. Other buyers see the seat as taken and are refused at once instead of waiting. If the buyer leaves, nothing needs to be cleaned up for the seat to come back, because every query that takes seats treats a hold whose time has passed as no hold at all. A payment that finishes after the hold ended is checked again: the seats are taken again if they are still available, or the payment is refunded.
What does a unique constraint on show and seat guarantee that checks in the application cannot? It guarantees that the database will never store two sales of the same seat, whatever order concurrent requests run in and whatever bug a later change brings. A check in the application reads first and writes later, so two requests can both pass the check before either writes. The database enforces the constraint while it writes, inside the transaction, and refuses the second row with an error, which the application turns into a refund or a new seat choice. It is the last line of defence, not the first: holds keep the second buyer from ever reaching it in normal operation.
Key takeaways
- Hold a group of seats with one conditional update inside one transaction, all or nothing; seat by seat, groups fail and seats go unsold.
- Put a unique constraint on show and seat in the table of sold seats, so a race or a bug becomes an error, never a double sale.
- Check expiry inside the query that takes seats; hold for about 5 minutes and extend once when a payment starts.
- Treat payment callbacks as at least once and in any order: record each event id with its change, move bookings only forward, and turn late payments into a new seat check or a refund.
- Size the waiting room’s admission rate from the booking system’s capacity with Little’s law, and stop admitting when the show sells out.
- Partition by show, serve layouts from a CDN by version and availability from a copy refreshed every second, and reconcile with the cinema’s records before every show.
That was a complete case study.
References
- PostgreSQL documentation: unique constraints (The PostgreSQL Global Development Group)
- PostgreSQL documentation: SELECT, the locking clause (FOR UPDATE, SKIP LOCKED) (The PostgreSQL Global Development Group)
- PostgreSQL documentation: explicit locking, deadlocks (The PostgreSQL Global Development Group)
- sqlite3, DB-API 2.0 interface for SQLite databases (Python Software Foundation)
- hmac, keyed-hashing for message authentication (compare_digest) (Python Software Foundation)
- SET command reference (Redis)
- Idempotent requests (API reference) (Stripe)
- Receive events in your webhook endpoint (event delivery behaviours) (Stripe)
- Webhooks best practices (Razorpay)
- Payments, payment states (Razorpay)
- Saga design pattern (Azure Architecture Center) (Microsoft)
- Transactional Outbox pattern (Azure Architecture Center) (Microsoft)
- Stateful waiting room (Compute tutorial) (Fastly)
- RFC 8246: HTTP Immutable Responses (IETF)
- OAT-021 Denial of Inventory (OWASP Automated Threats to Web Applications) (OWASP Foundation)
- OAT-005 Scalping (OWASP Automated Threats to Web Applications) (OWASP Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress