Fifty people tap the same two seats for the Friday night show. Exactly one of them gets a timer and a payment page. The rest are told no, at once, and nobody is ever charged for a seat they don't get.
Every movie app in India, BookMyShow and the cinema chains' own apps among them, works the same way from the outside: pick seats, see a countdown, pay before it runs out. That countdown is the whole design problem. Between "these seats look free" and "these seats are yours" sits a hold, and the machine-coding round is about getting the hold right: atomic across several seats, expiring on its own, and consistent with a payment that can arrive late or twice.
This lesson builds the classes and the concurrency in one process. The distributed version, with Redis holds and a payment saga across services, is the IRCTC Tatkal side quest; the seat-hold logic here is the same idea at the size of one cinema.
Seats are held all-or-nothing for 10 minutes behind a SeatLockProvider (a dict in the round, Redis SET NX PX in production), and a booking moves HELD to CONFIRMED, EXPIRED or CANCELLED. Fifty users race for A2 and A3 and one wins; payment pay_101 confirms after the hold is re-checked, a retried callback changes nothing, and a payment 601 seconds late is refunded and frees the seat. Per-seat locks are taken in sorted order to avoid deadlock.
How the round runs#
"Design the booking flow for a movie ticketing app. Users pick a show, pick seats, get a few minutes to pay, and get a booking. Two users must never get the same seat. Show me working code with concurrent users. Search, recommendations and the payment gateway itself are out of scope."
- 0–10 min — clarify: hold length, multi-seat holds, what happens to late payments, seat types and prices
- 10–25 min — the catalogue classes, the
Bookinglife cycle, and theSeatLockProviderseam - 25–50 min — the lock provider: all-or-nothing, TTL, lazy expiry, owner checks
- 50–70 min —
BookingService: hold, confirm on payment, cancel, availability - 70–80 min — a 50-thread race for two seats, then an expired hold
- 80–90 min — follow-ups: Redis holds, seat maps, offers, cancellations and refunds
Requirements and clarifying questions#
| You ask | They answer | What it changes |
|---|---|---|
| Can a user hold several seats? | Yes, up to 10, and they must be held together. | All-or-nothing locking across seats |
| How long is a hold? | 10 minutes. | A TTL on each hold; expired holds count as free |
| What if payment succeeds after the hold expired? | Refund it; the seats may already be someone else's. | confirm checks the hold and refunds on failure |
| Can the gateway call us twice for one payment? | Yes, callbacks are retried. | confirm must be idempotent |
| Seat types and prices? | Regular and premium, priced per show. | SeatType enum, a price table on Show |
| Cancellations of confirmed bookings? | Follow-up. | Nothing now |
Why not just check "is it free?" and then book?
A user selects A2 and A3. The code checks both are free, then writes the booking. What goes wrong with 50 users, and what is the missing concept?
One strong answer
Between the check and the write, other users run the same check and see the same free seats: a check-then-act race, and several bookings for one seat. Even with a lock around check-and-write, you can't hold a lock for the 10 minutes a user takes to pay. The missing concept is a hold: an atomic, short-lived claim ("these seats belong to booking BK00001 until 10:42") taken in one step, checked again when payment arrives, and released or expired if payment never comes.
The classes#
%% title: Movie booking: the classes
classDiagram
class Show {
+show_id str
+movie str
+screen str
+seats dict
+prices dict
}
class Seat {
+seat_id str
+type SeatType
}
class Booking {
+booking_id str
+user str
+seat_ids tuple
+amount int
+expires_at float
+status BookingStatus
}
class BookingStatus {
<<enumeration>>
HELD
CONFIRMED
EXPIRED
CANCELLED
}
class SeatLockProvider {
<<interface>>
+lock(show, seats, owner, ttl, now) bool
+holds(show, seats, owner, now) bool
+unlock(show, seats, owner)
+is_held(show, seat, now) bool
}
class PaymentGateway {
<<interface>>
+refund(payment_ref, amount)
}
class BookingService {
+hold(user, show, seats, now) Booking
+confirm(booking_id, payment_ref, now) Booking
+cancel_hold(booking_id)
+available(show, now) list
}
Show "1" *-- "many" Seat
Booking --> BookingStatus
BookingService --> SeatLockProvider
BookingService --> PaymentGateway
BookingService "1" o-- "many" Booking
SeatLockProvider <|.. InMemorySeatLocks
PaymentGateway <|.. FakeGatewayThree kinds of state, kept apart on purpose:
- The catalogue (
Show,Seat, prices): read-mostly; frozen dataclasses. - Holds (who has claimed which seats until when): short-lived, behind
SeatLockProvider. In one process it's a dict; across servers it's Redis withSET NX PX. The service doesn't care which. - Sales (which seats are sold for good) and bookings (the user's record): owned by
BookingService.
The Booking moves through a small life cycle: HELD → CONFIRMED, or HELD → EXPIRED (paid too late, or never), or HELD → CANCELLED (the user backed out). Only a HELD booking can be confirmed.
The interfaces#
The catalogue and the booking record:
# booking.py, part 1: the catalogue (things that rarely change)
from __future__ import annotations
import itertools
import threading
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from enum import Enum
class SeatType(Enum):
REGULAR = "regular"
PREMIUM = "premium"
@dataclass(frozen=True)
class Seat:
seat_id: str # "A1"
type: SeatType
@dataclass(frozen=True)
class Show:
show_id: str
movie: str
screen: str
starts_at: float
seats: dict[str, Seat]
prices: dict[SeatType, int] # rupees
class BookingStatus(Enum):
HELD = "held"
CONFIRMED = "confirmed"
EXPIRED = "expired"
CANCELLED = "cancelled"
@dataclass
class Booking:
booking_id: str
user: str
show_id: str
seat_ids: tuple[str, ...]
amount: int
expires_at: float
status: BookingStatus = BookingStatus.HELD
payment_ref: str | None = NoneThe seam that decides who owns a seat right now, an in-memory implementation, and the payment gateway (only refund is ours to call; the charge happens on the gateway's page):
# part 2: the seam that decides who owns a seat right now
class SeatLockProvider(ABC):
@abstractmethod
def lock(self, show_id: str, seat_ids: list[str], owner: str, ttl: float, now: float) -> bool: ...
@abstractmethod
def holds(self, show_id: str, seat_ids: list[str], owner: str, now: float) -> bool: ...
@abstractmethod
def unlock(self, show_id: str, seat_ids: list[str], owner: str) -> None: ...
@abstractmethod
def is_held(self, show_id: str, seat_id: str, now: float) -> bool: ...
@dataclass
class _Hold:
owner: str
expires_at: float
class InMemorySeatLocks(SeatLockProvider):
"""All-or-nothing holds with a TTL; an expired hold is treated as free (lazy expiry)."""
def __init__(self) -> None:
self._holds: dict[tuple[str, str], _Hold] = {}
self._mu = threading.Lock()
def lock(self, show_id: str, seat_ids: list[str], owner: str, ttl: float, now: float) -> bool:
with self._mu:
for s in seat_ids:
h = self._holds.get((show_id, s))
if h and h.expires_at > now and h.owner != owner:
return False # one seat taken: take none
for s in seat_ids:
self._holds[(show_id, s)] = _Hold(owner, now + ttl)
return True
def holds(self, show_id: str, seat_ids: list[str], owner: str, now: float) -> bool:
with self._mu:
return all((h := self._holds.get((show_id, s))) is not None and h.owner == owner and h.expires_at > now for s in seat_ids)
def unlock(self, show_id: str, seat_ids: list[str], owner: str) -> None:
with self._mu:
for s in seat_ids:
if (h := self._holds.get((show_id, s))) and h.owner == owner:
del self._holds[(show_id, s)]
def is_held(self, show_id: str, seat_id: str, now: float) -> bool:
with self._mu:
return (h := self._holds.get((show_id, seat_id))) is not None and h.expires_at > now
class PaymentGateway(ABC):
@abstractmethod
def refund(self, payment_ref: str, amount: int) -> None: ...
class FakeGateway(PaymentGateway):
def __init__(self) -> None:
self.refunds: list[tuple[str, int]] = []
def refund(self, payment_ref: str, amount: int) -> None:
self.refunds.append((payment_ref, amount))Two details in InMemorySeatLocks.lock carry the design. It checks every seat before taking any, so a request for A2 and A3 when A3 is held takes neither: no half-held pairs. And expiry is lazy: nothing sweeps expired holds; a hold whose expires_at has passed is simply treated as free the next time someone asks.
The patterns, and why#
| Pattern | Where | Why it earns its place |
|---|---|---|
| Strategy / adapter | SeatLockProvider |
In-memory for the round, Redis or a database row lock in production. The booking logic is identical. |
| State (as a status enum) | BookingStatus |
Every operation first asks "what state is this booking in?" and only some transitions are legal. |
| Idempotency key | the booking_id is the hold's owner, and the payment reference is checked on confirm |
A retried callback finds the booking already CONFIRMED with that reference and does nothing. |
| Facade | BookingService |
The API layer calls three methods and never touches locks or the sold set directly. |
Concurrency and edge cases#
The race for seats is closed by the lock provider: one mutex around "check all seats, then take all seats". With per-seat locks instead (needed at scale), always acquire them in sorted seat order: two users wanting A2+A3 and A3+A2 who lock in their own order can deadlock, each holding one seat and waiting for the other. hold() sorts the seat ids for that reason.
The race between a hold and a sale: hold() checks the sold set and takes the lock inside the service's mutex, and confirm() moves seats into the sold set under the same mutex, so a seat can't be both newly held and newly sold.
The race between payment and expiry is the one interviewers wait for. The user pays at 9 minutes 59 seconds; the gateway's callback arrives at 10 minutes 1 second. By then the hold has expired and someone else may hold the seats. confirm() checks the hold again, under the lock, at the moment the callback arrives; if it's gone, the booking becomes EXPIRED and the money is refunded. The seats were never sold twice and the money was never kept.
- Duplicate seats in one request (
A2, A2): deduplicate before locking - An unknown seat id: reject before touching any lock
- The same user retries
holdfor the same seats: a new booking id is a new owner, so it fails while the first hold lives; return the existing hold instead if the product wants that - The gateway calls
confirmtwice: the second call seesCONFIRMEDwith the same payment reference and returns the booking - A callback for a cancelled hold: refund; never revive a cancelled booking
- Availability is a snapshot: by the time the seat map renders, a seat may be gone; the hold is the only real check
The code#
The service:
# part 3: the service: hold, confirm, cancel
class SeatUnavailable(Exception): ...
class HoldExpired(Exception): ...
@dataclass
class BookingService:
shows: dict[str, Show]
locks: SeatLockProvider
payments: PaymentGateway
hold_ttl: float = 600.0 # 10 minutes to pay
bookings: dict[str, Booking] = field(default_factory=dict)
_sold: dict[str, set[str]] = field(default_factory=dict) # show_id -> seats sold for good
_ids: itertools.count = field(default_factory=lambda: itertools.count(1))
_mu: threading.Lock = field(default_factory=threading.Lock)
def hold(self, user: str, show_id: str, seat_ids: list[str], now: float) -> Booking:
show = self.shows[show_id]
seats = sorted(set(seat_ids))
if not seats or any(s not in show.seats for s in seats):
raise ValueError("unknown seat")
with self._mu: # the sold check and the lock must not interleave with a confirm
if self._sold.get(show_id, set()) & set(seats):
raise SeatUnavailable("already sold")
booking_id = f"BK{next(self._ids):05d}"
if not self.locks.lock(show_id, seats, booking_id, self.hold_ttl, now):
raise SeatUnavailable("held by someone else")
amount = sum(show.prices[show.seats[s].type] for s in seats)
b = Booking(booking_id, user, show_id, tuple(seats), amount, now + self.hold_ttl)
self.bookings[booking_id] = b
return b
def confirm(self, booking_id: str, payment_ref: str, now: float) -> Booking:
"""Called when the gateway reports a successful payment for this booking."""
b = self.bookings[booking_id]
with self._mu: # two callbacks for one booking must not both pass the checks
if b.status is BookingStatus.CONFIRMED and b.payment_ref == payment_ref:
return b # the gateway retried its callback: same booking, nothing new
if b.status is BookingStatus.HELD and self.locks.holds(b.show_id, list(b.seat_ids), booking_id, now):
self._sold.setdefault(b.show_id, set()).update(b.seat_ids)
b.status, b.payment_ref = BookingStatus.CONFIRMED, payment_ref
self.locks.unlock(b.show_id, list(b.seat_ids), booking_id) # sold now; the hold is done
return b
if b.status is BookingStatus.HELD:
b.status = BookingStatus.EXPIRED
self.payments.refund(payment_ref, b.amount) # money arrived for seats we no longer hold
raise HoldExpired(f"{b.status.value}: refunded, pick seats again")
def cancel_hold(self, booking_id: str) -> None:
b = self.bookings[booking_id]
if b.status is BookingStatus.HELD:
b.status = BookingStatus.CANCELLED
self.locks.unlock(b.show_id, list(b.seat_ids), booking_id)
def available(self, show_id: str, now: float) -> list[str]:
sold = self._sold.get(show_id, set())
return [s for s in self.shows[show_id].seats if s not in sold and not self.locks.is_held(show_id, s, now)]The race and the late payment:
# part 4: a race for the same two seats, then an expired hold
if __name__ == "__main__":
seats = {f"{r}{n}": Seat(f"{r}{n}", SeatType.PREMIUM if r == "A" else SeatType.REGULAR) for r in "AB" for n in range(1, 5)}
show = Show("S1", "Sholay", "Screen 3", 0.0, seats, {SeatType.REGULAR: 200, SeatType.PREMIUM: 350})
gateway = FakeGateway()
svc = BookingService({"S1": show}, InMemorySeatLocks(), gateway)
wins: list[Booking] = []
losers: list[str] = []
def try_book(user: str) -> None:
try:
wins.append(svc.hold(user, "S1", ["A2", "A3"], now=0.0))
except SeatUnavailable:
losers.append(user)
ts = [threading.Thread(target=try_book, args=(f"user{i}",)) for i in range(50)]
for t in ts:
t.start()
for t in ts:
t.join()
print(len(wins), "hold,", len(losers), "told no") # 1 hold, 49 told no
print("free while held:", svc.available("S1", now=60.0)) # A2, A3 are held
b = svc.confirm(wins[0].booking_id, "pay_101", now=120.0)
print(b.status.value, b.amount, b.payment_ref) # confirmed 700 pay_101
print(svc.confirm(b.booking_id, "pay_101", now=130.0).status.value) # confirmed: the retry is a no-op
late = svc.hold("riya", "S1", ["B1"], now=200.0)
try:
svc.confirm(late.booking_id, "pay_102", now=200.0 + 601) # paid 1 s after the 10-minute hold
except HoldExpired as e:
print("riya:", e, gateway.refunds) # expired: refunded ... [('pay_102', 200)]
print("free:", svc.available("S1", now=900.0)) # B1 is free again; A2, A3 are sold1 hold, 49 told no
free while held: ['A1', 'A4', 'B1', 'B2', 'B3', 'B4']
confirmed 700 pay_101
confirmed
riya: expired: refunded, pick seats again [('pay_102', 200)]
free: ['A1', 'A4', 'B1', 'B2', 'B3', 'B4']Fifty threads, one hold. The two premium seats cost ₹350 each, ₹700 together. The retried callback changes nothing. Riya's payment arrives one second after her 10-minute hold and is refunded, and B1 is back on the seat map.
Extending it: the follow-ups#
- "We have 30 servers." Implement
SeatLockProviderwith Redis: one key per seat,SET seat:{show}:{seat} {booking_id} NX PX 600000, taken for all seats in one Lua script so it stays all-or-nothing, and released only if the value is still yours. Or lock rows in the database withSELECT … FOR UPDATE SKIP LOCKED.BookingServicedoesn't change. - "Cancel a confirmed booking." A
CONFIRMED → CANCELLEDtransition that removes the seats from the sold set and issues a refund under a cancellation policy, itself a strategy (full refund up to 4 hours before, 50% after). - "Offers and convenience fees." Pricing moves behind a
PricingStrategythat takes the show, the seats and the user and returns the amount, with coupons as decorators around it. - "Don't leave single empty seats." A seat-selection rule checked in
hold(): reject a selection that strands one seat between two taken ones. It's a validator, not a change to locking. - "Show live seat maps." Publish
held,releasedandsoldevents; clients subscribe over a WebSocket. The hold is still the only source of truth.
Common mistakes#
- Check-then-book with no hold, or a lock held for the whole payment
- Locking seats one by one and leaving the first locked when the second fails
- Per-seat locks in request order, which can deadlock
- Trusting the hold at payment time without re-checking it, so a late payment books seats someone else now holds
- Charging inside the lock, so a slow gateway blocks every other user of the show
- No idempotency on confirm, so a retried callback creates a second booking
Movie booking
Q1/4 A user asks for A2 and A3. A3 is held by someone else. What should lock() do?
Answer & explanation
Answer: B
Groups book together. A partial hold strands A2 for 10 minutes for nobody. Check every seat first, then take them all, inside one lock.
Q2/4 The payment callback arrives one second after the hold expired. What should confirm() do?
Answer & explanation
Answer: D
After expiry another user may hold or have bought the seats. Re-check the hold at confirm time; if it's gone, refund. Every rupee ends in a booking or a refund.
Q3/4 Two users lock per-seat mutexes in request order: one asks for A2 then A3, the other A3 then A2. What can happen?
Answer & explanation
Answer: A
Classic lock-ordering deadlock. Acquire per-seat locks in one global order (sorted seat ids) and it can't happen.
Q4/4 Why is the booking id used as the hold's owner instead of the user id?
Answer & explanation
Answer: C
A user can have several attempts (two tabs, a retry after expiry). Owning the hold by booking id means each attempt's confirm, unlock and refund apply to exactly its own seats.
New lessons by email
New lessons, side quests and case files as they ship, at most once a week. No spam, unsubscribe anytime.
Discussion · …
Public: everyone reading this lesson sees comments. Be kind and keep it on the material.