Selling numbered cinema seats is an object-model problem whose hard part is concurrency: the read that decides and the write that acts must be one indivisible step.
API (the whole external surface)
hold(seat_ids, user_id) -> SeatHold— reserves a set of seats with a TTL; raisesHoldRejectedand changes nothing on failure.confirm(hold_id, amount_cents) -> Booking— turns a live hold into a paid receipt.release_expired() -> int— the reaper; drops abandoned holds, returns how many.available() -> list[str]— read-only seat map, current now.
Core objects
| Object | Is | Lifetime |
|---|---|---|
Seat | physical chair (row, number, class) | years |
Show | one movie, one auditorium, one time | one screening |
ShowSeat | this chair at that screening + status | with the show |
SeatHold | temporary claim on a set, with expiry | seconds–minutes |
Booking | paid claim + price charged | forever (receipt) |
ShowSeatis the class first drafts miss.Seat.is_bookedmakes a chair bookable once ever;ShowSeatmakes it bookable once per show.Bookingstores the amount charged, not a pointer to a price rule — a refund from a rule that changed later is a silent bug.- A 200-seat auditorium × 6 shows/day = 1200
ShowSeatrows/day; the 200Seatrows are created once.
The race: check-then-act
Two servers read C7 = AVAILABLE, both decide “allowed”, both write HELD, both confirm. One chair, two tickets, no error line.
read status -> AVAILABLE read status -> AVAILABLE
decide: allowed decide: allowed
write HELD by A write HELD by B <- overwrites
- The bug is not “forgot a lock”; it is that read and write were two steps. Any fix that rejoins them works.
Three fixes, priced
| Fix | Mechanism | Cost / scope |
|---|---|---|
Monitor (one lock per Show) | with self._lock: around read-decide-write | correct only in one process |
| Compare-and-set per seat | atomic AVAILABLE -> HELD | lock-free; multi-seat needs rollback |
Conditional UPDATE | WHERE status='AVAILABLE' | the real multi-server answer |
- One lock per show is not a bottleneck: ~0.33 holds/sec vs a ~5µs critical section = ~600,000x headroom.
Seat state machine
AVAILABLE --hold()--> HELD --confirm()--> BOOKED
HELD --release()/TTL expiry--> AVAILABLE
BOOKED --cancel()-------------> AVAILABLE
HELDexists only because payment takes human time. Remove payment, the state disappears.
Rules and gotchas
- Clock is injected as a
Protocol(now() -> float). Reading the wall clock makes expiry untestable (tests wouldsleep); aFakeClock.advance(121)tests the TTL in zero wall-clock time. - TTL = 120s is p95 checkout time; costs ~1% of inventory temporarily unsellable (double the TTL, double the cost).
- Contract:
HoldRejectedor nothing changed. Validate unknown seat ids and the empty set before the reap loop; validate money at the top ofconfirm(non-negativeint). holdtakes a set, all-or-nothing — group booking is then free and cannot be retrofitted onto a single-seat API.- Sort seat ids in
hold(sorted(set(...))): a global acquisition order that prevents deadlock, for free. - Reap on every read path, not just the timer, so a stale seat shows free one second after expiry.
- The lock protects the method, not the field. Public
seats/holdslet one line defeat every lock; production uses private fields + the DBWHEREclause. Booking/SeatHoldarefrozendataclasses — facts that happened; an editable receipt disagrees with the money that moved.
Change absorbed by interfaces
- Pricing = Strategy + Decorator, computed by the caller and handed to
confirmasamount_cents;Showholds noPricingStrategy. - Refunds = policy over
Booking.amount_cents(the stored snapshot, not a rule). - Group-adjacent = one pure
adjacent_runsfunction; a run ofnis genuine whenwindow[-1] - window[0] == n - 1. It runs on a snapshot, so treat the search as advisory and letholdreject and re-search. - Baked in (no interface): a seat is a physical location (so overbooking is a bug), contention unit is one
Show, money is integer cents, hold authority is a timestamp.