Design a Hotel Reservation System
This page is one interview loop in three rounds. All three rounds design the same system. Each round opens with the interviewer raising the scope, and the design from the round before has to evolve to meet it.
| Round 1: Mid-level | Round 2: Senior | Round 3: Architect | |
|---|---|---|---|
| Story | A 50-hotel chain's own booking site | An online travel agency: 500,000 hotels, geo search, flash sales | A global marketplace: 9.5M unique homes plus hotels that also sell on other sites |
| Level (Amazon) | SDE II (L5) | Senior SDE (L6) | Principal (L7) |
| Volume | 1,000 bookings/day; peak ~20 holds/s and ~1,000 searches/s | 1M bookings/day; 500 holds/s and 50,000 searches/s at peak | 4M bookings/day; 2,000 holds/s and 200,000 searches/s at peak, across 3 regions |
| Inventory | 91,250 rows (50 × 5 × 365), about 5.5 MB | 1.825 billion rows, about 180 GB with the primary key | 5.29 billion rows, about 565 GB, plus the same count of nightly rates |
| Footprint | 1 region, 3 AZs | 1 region, 3 AZs; survives losing an AZ | 3 home regions, each with a disaster-recovery copy |
| Targets | Never oversell; 99.9% | Search P95 < 150 ms; hold P99 < 80 ms; 99.99% | Search P95 < 200 ms worldwide; cross-channel oversells minimized and handled |
| Reading time | ~35 min | ~40 min | ~45 min |
You can start at any round. Rounds 2 and 3 open with a "Where we left off" summary that catches you up. Payments are designed in their own loop, Design a Payment Processing System; this page links there instead of re-teaching it.
Loop Opener: What Is a Reservation System?
You Already Know One: a Calendar With a Limited Number of Seats
Picture the front desk of a hotel with 20 Deluxe King rooms. On the wall is a calendar. Each night has a box, and each box says how many of those 20 rooms are already promised to someone. Selling a stay from Friday to Monday means writing a mark in three boxes (Friday, Saturday and Sunday night), and only if none of those boxes is already at 20.
That calendar is the whole problem:
| Word | What it means on this page |
|---|---|
| Room type | A kind of room the hotel sells, like "Deluxe King". Guests book a type, not room 1204. |
| Night | The unit we sell. A stay from check-in Oct 15 to check-out Oct 18 is three nights: Oct 15, 16 and 17. The check-out date is not a night. |
| Inventory | For each room type and each night: how many rooms exist, and how many are promised. |
| Hold | A temporary claim on rooms while the guest types card details. It expires if they don't pay. |
| Booking | A confirmed stay, paid or guaranteed. |
| Oversell | Promising more rooms for a night than the hotel has. The guest arrives and there is no bed. |
Synthesizing vector architecture diagram...
Every reservation walks this path. The hard parts are the arrows out of "Hold": a hold must end one way or the other, and never both.
What Makes It Hard
- Millions look, few book. For every booking, a travel site serves hundreds of searches. Searching must be fast and cheap, and it can be a few seconds out of date.
- Booking must never oversell. Two guests racing for the last room must not both get it, even when they arrive in the same millisecond.
- A hold sits between the two. A room held for a guest who is typing card details must not go to someone else, and it must not stay held forever if they walk away.
- Dates are local. "The night of Oct 15" is a calendar date at the hotel, in the hotel's time zone, not a 24-hour slice of UTC.
The Question the Whole Loop Answers
How do we let everyone search freely, while guaranteeing we never sell a room we don't have?
The answer gets sharper every round:
- Round 1: a count per room type per night, an atomic conditional update, holds that expire, and idempotent requests.
- Round 2: split search from booking, a gate for flash sales, "fence first, charge second" against the hold/payment race, sharding by hotel, and overbooking as a written policy.
- Round 3: the outside world: rooms also sold by other sites, prices that change by the hour, one-of-a-kind homes, three regions, and refunds by policy.
Round 1 · Mid-level · "Booking for One Hotel Chain"
~35 min · SDE II (L5) · 1 region, 3 AZs · ~20 holds/s and ~1,000 searches/s at peak · 99.9% · never oversell
R1.1 Establish Design Scope
The interviewer says: "We're a chain of 50 hotels. Design the booking system for our own website." Before we draw anything, we ask questions, and we say out loud what each answer changes.
| We ask | Interviewer answers | What it changes in the design |
|---|---|---|
| Do guests book a specific room, or a room type? | A room type. The front desk assigns the actual room at check-in. | We count rooms per type, not per room. This one answer decides the data model (step 1.1). |
| How far ahead can guests book? | Up to a year. | 365 nights per room type are on sale at any moment. |
| How many hotels, rooms and room types? | 50 hotels, about 200 rooms each, 5 room types per hotel. | 10,000 rooms and 250 room types: a small data set. |
| How long do we keep rooms for a guest who is paying? | 15 minutes. | We need holds that expire, and something that enforces the expiry (step 1.4). |
| How is payment made? | Card, charged in full when the guest confirms, through a payment provider (PSP). | We confirm only after the charge succeeds (step 1.6). The charge itself follows the payment loop. |
| Can guests cancel? | Yes: free until 18:00 hotel time the day before arrival. | A cancel releases the nights and refunds. "18:00 hotel time" means we need each hotel's time zone. |
| Is overbooking ever allowed? | No. Not yet. | Our invariant is strict: promised ≤ rooms, every night. |
| Where are the hotels? | Across the US, from New York to Honolulu. | Six time zones. "Tonight" is a different date in different hotels at the same instant. |
Out of scope for this round:
- Geo search ("hotels near me") and filters. Guests pick a city or a hotel.
- Flash sales. We'll plan for a promotion, but not a stampede.
- Other sales channels. Our website is the only seller.
- Changing prices. Each room type has a nightly price set by the hotel.
The interviewer will widen this scope later. Write your out-of-scope list where you can see it: in a multi-round loop, some of it comes back.
R1.2 Functional Requirements, Derived Step by Step
| Phrase from the problem | Requirement |
|---|---|
| "Guests look for a room for their dates" | GET /v1/availability: free rooms and prices per room type, for a hotel or a city, for check-in and check-out dates |
| "Keep the room while they pay" | POST /v1/holds: claim N rooms of one type for every night of the stay, for 15 minutes, or say which night is sold out |
| "Confirm when paid" | POST /v1/holds/{id}/confirm: charge the card, then turn the hold into a booking |
| "Guests can cancel" | DELETE /v1/bookings/{id}: release the nights, refund if inside the free window |
| "Never sell a room we don't have" | For every room type and night, promised rooms never exceed rooms |
Not yet: geo search, flash sales, other channels, overbooking.
R1.3 Non-Functional Requirements: the Questions
Numbers come in R1.7. For now, the questions, most important first:
- No overselling. The invariant, for every room type and every night: . This beats every other requirement. A slow search costs a click; an oversell costs a guest standing at the desk at midnight with nowhere to sleep.
- Holds end. Every hold ends as a booking or goes back on sale. None stays forever.
- Search speed. A guest compares several hotels and dates; each search should feel instant.
- Hold speed. Clicking Reserve should answer in well under a second.
- Availability. If booking is down, the chain sells nothing online.
R1.4 The API
Search availability.
httpGET /v1/availability?hotel=htl_nola_01&check_in=2026-10-15&check_out=2026-10-18&rooms=1 HTTP/1.1 Host: api.example-hotels.com
httpHTTP/1.1 200 OK Content-Type: application/json { "hotel_id": "htl_nola_01", "time_zone": "America/Chicago", "check_in": "2026-10-15", "check_out": "2026-10-18", "nights": 3, "room_types": [ { "room_type": "DLX_KING", "available": 1, "nightly_minor": [18900, 18900, 21900], "total_minor": 59700, "currency": "USD" }, { "room_type": "STD_QUEEN", "available": 0 } ], "as_of": "2026-09-27T14:32:05Z" }
available is the smallest free count across the three nights (here 4, 2 and 1, so 1): a room type is only as available as its fullest night. as_of says how fresh the answer is; search may be a few seconds old (R1.6). Passing city=new-orleans instead of hotel= returns the same block for each hotel in that city.
Hold rooms. The client makes one idempotency key per hold attempt: a random ID it generates once and sends again on every retry of the same attempt, so the server can recognize the retry.
httpPOST /v1/holds HTTP/1.1 Host: api.example-hotels.com Authorization: Bearer <guest token> Idempotency-Key: 6a0f7c2e-3b91-4f4e-9d5a-1c8e2b7d4f10 Content-Type: application/json { "hotel_id": "htl_nola_01", "room_type": "DLX_KING", "check_in": "2026-10-15", "check_out": "2026-10-18", "rooms": 1 }
httpHTTP/1.1 201 Created Content-Type: application/json { "hold_id": "hld_01JB2Q9Z7K", "status": "HOLD", "nights": ["2026-10-15", "2026-10-16", "2026-10-17"], "total_minor": 59700, "currency": "USD", "expires_at": "2026-09-27T14:47:05Z" }
| Status | When |
|---|---|
201 Created | Rooms held for every night |
201 + Idempotent-Replayed: true | A retry of an attempt that already succeeded: the same hold, not a second one |
409 Conflict with "sold_out_nights": ["2026-10-17"] | At least one night doesn't have enough rooms. Nothing was held. Stored and replayed like a success. |
422 Unprocessable Entity | The same key with a different body: a client bug, never a new hold |
400 Bad Request | Check-out not after check-in, a night in the hotel's past, or more than 365 days ahead |
Confirm.
httpPOST /v1/holds/hld_01JB2Q9Z7K/confirm HTTP/1.1 Idempotency-Key: 0d3e9b61-5c7a-4a2f-8e14-7b6c2f9a1e33 Content-Type: application/json { "payment_method_token": "pm_tok_4Rk2", "guest": { "name": "Jane Doe", "email": "jane@example.com" } }
httpHTTP/1.1 200 OK Content-Type: application/json { "booking_id": "bkg_01JB2QC4M1", "confirmation_code": "NOLA-7Q4K2M", "status": "CONFIRMED", "hold_id": "hld_01JB2Q9Z7K" }
A card decline returns 402 Payment Required; the hold stays a hold, so the guest can try another card before it expires. A confirm after the hold expired returns 410 Gone, and nothing was charged.
Cancel. DELETE /v1/bookings/bkg_01JB2QC4M1 returns 200 with "status": "CANCELLED" and the refund amount. It is idempotent: cancelling a cancelled booking returns the same answer.
Recap
- Four calls: search, hold, confirm, cancel.
- A hold covers every night of the stay or none of them.
- Every call that changes something carries an idempotency key.
- Correctness first: for every room type and night.
Let's build it, starting with the simplest thing that works.
R1.5 Design Evolution: From Rooms and Overlaps to a Calendar of Counts
Every step below follows the same pattern: a problem, your turn to think, the answer, and what the answer costs us. The cost is always the next problem.
Step 1.0: The Baseline
One row per physical room, and a bookings table. To find a free Deluxe King for Oct 15 to 18, look for a room of that type with no booking that overlaps those dates; then insert a booking for it.
sql-- Is room r free for [check_in, check_out)? Two stays overlap when each starts before the other ends. SELECT r.room_id FROM rooms r WHERE r.hotel_id = 'htl_nola_01' AND r.room_type = 'DLX_KING' AND NOT EXISTS ( SELECT 1 FROM bookings b WHERE b.room_id = r.room_id AND b.check_in < DATE '2026-10-18' AND b.check_out > DATE '2026-10-15' ) LIMIT 1;
Synthesizing vector architecture diagram...
What's good about it: it's how most people first picture a hotel, and it answers "which room?" directly.
What it costs us: every search runs an overlap query per room, and the check in step 1 and the insert in step 2 are two separate moments. The next two steps are the ways that breaks.
Step 1.1: Checking Overlaps Is Slow and Error-Prone
The problem: a search for "any Deluxe King, Oct 15 to 18, in the three New Orleans hotels" runs the overlap query against every room of that type. At 1,000 searches a second during a promotion, the database spends its time scanning bookings. And the overlap rule itself is easy to get wrong (is check-out day a night? is < or <= right?).
What would you do?
The table (Aurora PostgreSQL):
sqlCREATE TABLE room_inventory ( hotel_id BIGINT NOT NULL, -- internal numeric ID; the API shows 'htl_nola_01' room_type_no SMALLINT NOT NULL, -- 1..5 within the hotel stay_date DATE NOT NULL, -- the night, as a calendar date in the hotel's time zone total_rooms SMALLINT NOT NULL CHECK (total_rooms >= 0), reserved_rooms SMALLINT NOT NULL DEFAULT 0 CHECK (reserved_rooms >= 0), version INT NOT NULL DEFAULT 0, -- bumped on every change (Round 2 uses it) rate_minor INT NOT NULL CHECK (rate_minor > 0), -- nightly price in cents PRIMARY KEY (hotel_id, room_type_no, stay_date), CONSTRAINT never_oversell CHECK (reserved_rooms <= total_rooms) );
Dates are the hotel's dates, computed, not guessed. stay_date has no time zone because a hotel night is a calendar date at the hotel. Every hotel row stores an IANA time zone name (America/Chicago for New Orleans), and whenever we need "today" or "tonight" for a hotel, we compute it from the current UTC instant and that zone, using the time-zone database. Example: at 2026-10-15T03:30Z, it is already Oct 15 in UTC, but in New Orleans (UTC−5 in October, daylight time) it is 22:30 on Oct 14. A guest there booking "tonight" gets the night of Oct 14, and our "no nights in the past" check must allow it. We use the same rule for the booking window (365 nights from the hotel's today) and for cancellation deadlines (step 1.6). We never add or subtract 24 hours to move between days: across a daylight-saving change, a local day is 23 or 25 hours long.
Step 1.2: Two Guests Got the Last Room
The problem: Oct 17 has 19 of 20 Deluxe Kings reserved. Two guests click Reserve at the same moment. Both requests read reserved_rooms = 19, both see one room free, and both write 20. Two guests hold one room.
What would you do?
The hold, as SQL (Read Committed, one transaction; $ values come from the request):
sqlBEGIN; -- 1. Lock the nights of the stay, in date order (step 1.3 explains the order). SELECT stay_date, total_rooms, reserved_rooms FROM room_inventory WHERE hotel_id = $1 AND room_type_no = $2 AND stay_date >= $3 AND stay_date < $4 -- [check_in, check_out) ORDER BY stay_date FOR UPDATE; -- The service checks: one row per night, and total_rooms - reserved_rooms >= $5 on each. -- 2. Claim them, repeating the condition. UPDATE room_inventory SET reserved_rooms = reserved_rooms + $5, version = version + 1 WHERE hotel_id = $1 AND room_type_no = $2 AND stay_date >= $3 AND stay_date < $4 AND reserved_rooms + $5 <= total_rooms; -- The service checks: rows updated = number of nights. Otherwise ROLLBACK. COMMIT;
Primitive: Database Isolation Levels, ACID and Concurrency Anomalies
Drill: The on-call doctor anomaly (why snapshot isolation allows write skew is the "Where Read Committed would not be enough" paragraph; why not Serializable everywhere is the isolation list above it)
Step 1.3: Two Multi-Night Bookings Deadlocked
The problem: the front desk's group tool holds two room types in one transaction. One agent books a Deluxe King and a Suite for Oct 15 to 18, and the code locks rows in the order the request lists them: Deluxe nights first, then Suite nights. At the same moment another agent books a Suite and a Deluxe King for the same dates, listed the other way round. The first transaction holds the Deluxe rows and waits for the Suite rows; the second holds the Suite rows and waits for the Deluxe rows. The same thing happens with nights if a code path walks dates in whatever order a set or a query plan returns them. PostgreSQL waits one second (the default deadlock_timeout), finds the cycle, and aborts one of them with "deadlock detected".
What would you do?
Synthesizing vector architecture diagram...
Both transactions lock in date order, so they can wait for each other but never in a circle. Here A waits a few milliseconds for B and then finishes.
Step 1.4: Guests Abandon Checkout and Rooms Stay Held
The problem: about half the guests who click Reserve never pay. They compare prices in another tab, or their phone dies. Their holds keep rooms "reserved" that nobody will ever use. What would you do?
The hold table and the sweeper's two statements:
sqlCREATE TABLE holds ( hold_id TEXT PRIMARY KEY, -- 'hld_01JB2Q9Z7K' guest_id TEXT NOT NULL, idempotency_key TEXT NOT NULL, request_hash BYTEA NOT NULL, -- SHA-256 of the request body (step 1.5) hotel_id BIGINT NOT NULL, room_type_no SMALLINT NOT NULL, check_in DATE NOT NULL, check_out DATE NOT NULL, rooms SMALLINT NOT NULL CHECK (rooms > 0), total_minor BIGINT NOT NULL, -- price quoted at hold time status TEXT NOT NULL CHECK (status IN ('HOLD','CONFIRMED','EXPIRED','RELEASED','REJECTED')), expires_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (guest_id, idempotency_key), CHECK (check_out > check_in) ); CREATE INDEX holds_due ON holds (expires_at) WHERE status = 'HOLD'; -- Sweeper, per batch: find due holds without blocking other sweepers (SKIP LOCKED). -- Two sweepers may still pick the same hold; the conditional UPDATE below lets only one release it. SELECT hold_id, hotel_id, room_type_no, check_in, check_out, rooms FROM holds WHERE status = 'HOLD' AND expires_at <= now() ORDER BY expires_at LIMIT 200 FOR UPDATE SKIP LOCKED; -- Then, per hold, in its own transaction: UPDATE holds SET status = 'EXPIRED' WHERE hold_id = $1 AND status = 'HOLD' AND expires_at <= now(); -- If 1 row changed: lock that hold's nights in date order, then UPDATE room_inventory SET reserved_rooms = reserved_rooms - $5, version = version + 1 WHERE hotel_id = $2 AND room_type_no = $3 AND stay_date >= $4 AND stay_date < $6;
The release only runs if the status change succeeded, so a hold is released at most once, and the reserved_rooms >= 0 check would catch a bug that tried twice.
Synthesizing vector architecture diagram...
Every hold ends in exactly one terminal state, and only the two "minus n" arrows give rooms back. Each arrow is one conditional update, so no hold can take two of them.
Primitive: Distributed Locks and Leases (a hold is a lease on rooms: it grants a right for a limited time, and the right is checked where the data lives)
Step 1.5: The Guest Double-Clicked and Got Two Holds
The problem: a guest on hotel Wi-Fi clicked Reserve, saw a spinner, and clicked again. Two requests arrived. Both found rooms and both held one: the guest now holds two rooms, and the next guest sees one fewer. What would you do?
Step 1.6: When Is It Really Booked?
The problem: the guest submits a card. We need to take the money and turn the hold into a booking. In which order, and what if one of them fails? What would you do?
The booking table, and the confirm transaction:
sqlCREATE TABLE bookings ( booking_id TEXT PRIMARY KEY, hold_id TEXT NOT NULL UNIQUE, -- a plain column, no foreign key, so old holds can be deleted confirmation_code TEXT NOT NULL UNIQUE, guest_id TEXT NOT NULL, hotel_id BIGINT NOT NULL, room_type_no SMALLINT NOT NULL, check_in DATE NOT NULL, check_out DATE NOT NULL, rooms SMALLINT NOT NULL, total_minor BIGINT NOT NULL, payment_reference TEXT NOT NULL, status TEXT NOT NULL CHECK (status IN ('CONFIRMED','CANCELLED')), created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -- Confirm, after the PSP said "succeeded": BEGIN; UPDATE holds SET status = 'CONFIRMED' WHERE hold_id = $1 AND status = 'HOLD'; -- 0 rows: the hold expired meanwhile, refund INSERT INTO bookings (...) VALUES (...); -- hold_id is UNIQUE: a retried confirm can't book twice COMMIT;
Notice that bookings.hold_id has no foreign key to holds. That's deliberate: the cleanup job from step 1.5 deletes holds older than 30 days, and a foreign key would block it for every confirmed hold. The booking copies everything it needs.
Round 1 Step Summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 1.0 | (baseline) | A row per room, an overlap query, then an insert | Slow, and racy between check and insert |
| 1.1 | Overlaps are slow and error-prone | One inventory row per room type per night: total_rooms, reserved_rooms; hotel-local dates | Rows for every night in the window; a shared counter |
| 1.2 | Two guests got the last room | Lock the nights, conditional update, CHECK (reserved_rooms <= total_rooms); Read Committed is enough | Row locks |
| 1.3 | Multi-night deadlocks | One global lock order (hotel, type, night); holds before inventory | A rule every code path must follow |
| 1.4 | Abandoned holds keep rooms | expires_at checked with the database clock; sweeper with SKIP LOCKED; lazy release on the hold path | Held rooms look taken for up to 15 min |
| 1.5 | Double click, two holds | Idempotency key as a unique constraint on the hold row; refusals stored too | Hold rows kept 30 days |
| 1.6 | When is it booked? | Charge first with a per-attempt PSP key, then HOLD → CONFIRMED conditionally; cancel mirrors it | The hold can expire during payment (Round 2) |
R1.6 Architecture v1
Now the concepts get AWS names.
Synthesizing vector architecture diagram...
Writes go to the one writer, where the invariant lives. Search reads go to the replica through a short cache, so a promotion's search traffic never queues behind holds. The PSP is reached only through NAT, and only after a hold exists.
The pieces:
- Booking service: one stateless container on ECS Fargate (containers without managing servers), three tasks of 1 vCPU and 2 GB, one per AZ. It serves all four API calls and runs the sweeper loop from step 1.4.
- Aurora PostgreSQL: one writer and one replica in another AZ. The replica serves search reads and is the failover target: if the writer fails, Aurora promotes it, typically within tens of seconds.
- Search cache: each task keeps a 5-second in-memory cache of availability per
(hotel, month). There are only such keys (a 365-night window touches 13 calendar months), so during a promotion almost every search is a cache hit. - Secrets: the PSP key lives in AWS Secrets Manager.
- Calendar roller: a daily job adds the night that has just come into the 365-night window for each hotel (computed from that hotel's local "today") and fills
total_roomsandrate_minorfrom the room type's defaults.
The remaining tables:
sqlCREATE TABLE hotels ( hotel_id BIGINT PRIMARY KEY, public_id TEXT NOT NULL UNIQUE, -- 'htl_nola_01' name TEXT NOT NULL, city TEXT NOT NULL, time_zone TEXT NOT NULL, -- IANA name, e.g. 'America/Chicago' cancel_cutoff_local TIME NOT NULL DEFAULT '18:00' -- free cancellation until this time the day before arrival ); CREATE TABLE room_types ( hotel_id BIGINT NOT NULL REFERENCES hotels(hotel_id), room_type_no SMALLINT NOT NULL, code TEXT NOT NULL, -- 'DLX_KING' rooms SMALLINT NOT NULL CHECK (rooms > 0), -- default total_rooms for new nights default_rate_minor INT NOT NULL CHECK (default_rate_minor > 0), max_guests SMALLINT NOT NULL, PRIMARY KEY (hotel_id, room_type_no) );
Trace 1: search, hold, pay, confirm.
Synthesizing vector architecture diagram...
The inventory changed once, at the hold. The confirm only changed the hold's status and added a booking row; that's why a confirm can never oversell.
Trace 2: a hold that expires. A guest holds one Deluxe King for Oct 15 to 18 at 14:32:05 and closes the tab. At 14:47:05 the hold's expires_at passes. Within 10 seconds, a sweeper finds it through the holds_due index, moves it HOLD → EXPIRED with a conditional update, locks the three nights in date order and subtracts 1 from each. The next search that misses the 5-second cache shows the room again. If the guest comes back at 14:48 and clicks Pay, the confirm's first check fails and they get 410 before any money moves.
R1.7 Numbers
Traffic. On a normal day, from the interviewer's numbers plus our assumptions:
- 50 hotels × 200 rooms = 10,000 rooms. At 75% occupancy, that's 7,500 room-nights a day.
- We assume 40% of them are sold through our own site (the rest come from travel agencies and walk-ins, out of scope): 3,000 room-nights a day. At an average stay of 3 nights, that's 1,000 bookings a day.
- We assume half of all holds are abandoned, so 2 holds per booking: 2,000 holds a day, about 0.02 a second on average.
- We assume 100 searches per booking: 100,000 searches a day, about 1.2 a second.
The peak is a promotion, not an average day. The chain emails a member sale to 2 million members. We assume 5% open it and click in the first hour (100,000 visitors), each runs 10 searches, and 20% of them hold a room:
The busiest minute of that hour runs at about 3.6× the hour's average (an assumption), which gives ~1,000 searches/s and ~20 holds/s. That's the design peak: about 860 times the average search rate (1,000 / 1.16). It's why we size for the promotion, and why a small fixed fleet is fine the rest of the time.
Database writes at peak. Each hold changes 3 inventory rows and inserts 1 hold row. We assume half the holds confirm within minutes (1 hold update + 1 booking insert each) and the other half are released later (1 hold update + 3 inventory rows):
One small Aurora writer handles that without noticing.
Search reads at peak. With the 5-second cache, each task queries at most 650 keys every 5 seconds: queries a second in the worst case, each a primary-key range read of at most rows. In practice a promotion focuses on a few hotels and months, so it's far less. Without the cache, all 1,000 searches a second would hit the replica.
Storage. An inventory row's size, worked out from PostgreSQL's layout (an estimate; alignment rules are PostgreSQL's):
| Part | Bytes |
|---|---|
| Row header (23 bytes, padded to 8-byte alignment) | 24 |
hotel_id BIGINT 8 + room_type_no SMALLINT 2 + padding 2 + stay_date DATE 4 | 16 |
total_rooms 2 + reserved_rooms 2 + version INT 4 + rate_minor INT 4 | 12 |
| Row so far: 52, padded to a multiple of 8 | 56 |
| Line pointer in the page (4 bytes per row) | 4 |
| Total per row | 60 |
The primary-key index adds about 2.8 MB (R2.6 shows the per-entry math). Holds and bookings: about 1 KB per booking with its hold, so 1 MB a day, about 365 MB a year. The whole database fits in the memory of the smallest instance. Size is not our problem; concurrency is.
Availability. 99.9% of a 30.4-day month is minutes.
Latency. A hold is four statements on the writer (insert hold, lock, update, commit): about 1 ms each inside the region plus a few ms for the commit's durable write, so about 15 ms at the median and about 30 ms at P99 with some lock waiting. A confirm is dominated by the PSP (300 to 600 ms at P99), as in the payment loop.
Monthly cost (us-east-1 on-demand prices, 730 hours a month):
| Item | Math | Monthly |
|---|---|---|
Aurora PostgreSQL, 2 × db.r6g.large | 2 × $0.26/h × 730 h | ≈ $380 |
| Aurora storage and I/O | a few GB × $0.10/GB-month, plus a few dollars of I/O | ≈ $10 |
| Fargate, 3 tasks of 1 vCPU / 2 GB | 3 × (1 × $0.04048 + 2 × $0.004445)/h × 730 h | ≈ $108 |
| Application Load Balancer | $0.0225/h × 730 h, plus a few capacity units | ≈ $25 |
| AWS WAF | $5 per web ACL + 5 rules × $1 + ~5M requests × $0.60/M | ≈ $13 |
| NAT gateways, 3 | 3 × $0.045/h × 730 h | ≈ $99 |
| Secrets Manager, CloudWatch | an estimate | ≈ $20 |
| Total | ≈ $660/month |
Against the business. 1,000 bookings a day × 3 nights × an assumed $150 average nightly rate is $450,000 a day, about $13.7M a month. PSP fees at a common list rate of 2.9% + 30¢ on a $450 stay are $13.35 per booking, about $406K a month. The infrastructure is about 0.005% of bookings. Say it: in Round 1, every design choice is about correctness, not servers.
R1.8 Trade-Offs
| Choice | Option A | Option B | Our pick for Round 1 |
|---|---|---|---|
| Inventory model | Counts per room type per night | A row per physical room per night, or bookings checked for overlaps | Counts. Guests book types, so the thing we must not exceed is a count. Per-room rows are 40 times more rows here (M versus 91,250) and need room assignment logic we don't need. Per-room models make sense when the unit really is unique; Round 3 comes back to that. |
| Concurrency | Pessimistic: lock the nights with FOR UPDATE, then update | Optimistic: read with a version, update WHERE version = v, retry on conflict | Pessimistic. On the last rooms of a popular night, many guests collide; optimistic control turns every collision into a failed attempt and a retry, while locks make them wait a few milliseconds in line. Optimistic wins when conflicts are rare and transactions are long; ours are the opposite. |
| Hold expiry | Scheduler: a sweeper releases due holds | Lazy: release expired holds only when someone needs the rooms | Both. The database conditions make an expired hold powerless either way. The sweeper gives rooms back within seconds, so search shows them; the lazy release makes sure a stalled sweeper can never turn a guest away. |
| Search reads | The writer | A replica with a 5 s cache | The replica. Search can be seconds old; the hold transaction always re-checks on the writer. |
R1.9 Failure Modes
| Failure | What you'd see | How the design responds |
|---|---|---|
| The database fails over mid-hold | A burst of connection errors for tens of seconds while Aurora promotes the replica | A hold transaction that didn't commit rolled back completely: no hold row, no inventory change. The client retries with the same key and it works once the new writer is up. If the commit succeeded but the reply was lost, the retry finds the key and replays the hold. Holds whose expires_at passed during the outage are not the guests' fault: our choice is a one-off job after any outage longer than a minute that extends the affected holds by the outage's length, with a conditional update that only touches holds still HOLD; after a failover, the sweepers start only once that job has run. |
| The sweeper stalls | The age of the oldest due hold grows; search shows fewer free rooms than there are | No correctness problem: expired holds are powerless, and the lazy release on the hold path frees rooms when someone asks for them. We alarm when the oldest due hold is more than 60 seconds past its expiry. |
| The PSP is down | Charge errors; the confirm error rate alarms | Confirm returns 503: "Payments are unavailable, you have not been charged." The hold stays until it expires. We never queue a charge to run later: the guest may be gone, and a charge they didn't see is worse than a lost sale. As in the payment loop, a timeout is an unknown, not a failure. |
| A task crashes mid-confirm, after the charge | One confirm request resets | The retried confirm (same idempotency key) replays the charge with the same PSP key, gets the stored success, and finishes HOLD → CONFIRMED. If the hold expired before the retry came, this is the Round 1 gap: we refund. |
| The hotel lowers its room count | An admin tries to set total_rooms = 18 on a night with 19 reserved | The check constraint rejects it. That's correct: those rooms are promised. The admin tool shows which nights conflict, and the hotel moves guests first. |
R1.10 Pillar Check
| Pillar | What Round 1 covers |
|---|---|
| Reliability | Idempotency keys on holds, confirms, PSP charges and refunds; the database refuses oversells; holds expire safely even if the sweeper dies; Aurora replica in a second AZ, tasks in three REL 4 · REL 10 · REL 11 |
| Security | Card numbers go to the PSP's hosted fields, never to us; PSP key in Secrets Manager; WAF in front; the app's database role can't alter the schema or drop constraints SEC 3 · SEC 5 |
| Performance Efficiency | Hold ≈ 15 ms (30 ms P99); search from a replica behind a 5 s cache; the whole data set fits in memory PERF 1 · PERF 3 |
| Cost Optimization | About $660 a month, derived, and about 0.005% of bookings COST 6 |
| Operational Excellence | Light this round: alarms on deadlocks (should be zero), oldest due hold, confirm errors and refunds caused by expired holds OPS 8 |
| Sustainability | Skipped this round: three small tasks and two small instances. |
R1.11 Round 1 Rubric and Follow-Ups
What a strong mid-level (L5) answer shows
- Models inventory as a count per room type per night, and says why (guests book types).
- Makes check-and-claim one atomic step, adds a check constraint, and names the isolation level and why it's enough.
- Locks nights in a fixed order and explains why that prevents deadlocks.
- Makes holds expire with the database's clock, and separates "the hold is powerless" from "the rooms come back".
- Uses idempotency keys, including for refusals, and per-attempt keys at the PSP.
- Treats a night as a hotel-local calendar date and computes it from the hotel's time zone.
Follow-up questions
-
"Why store
total_roomsandreserved_roomsinstead of just anavailablecount?" Answer: because they change for different reasons. Guests changereserved_rooms; the hotel changestotal_rooms(a renovation, a new wing). With both, the constraintreserved_rooms <= total_roomssays exactly what must never happen, and a hotel can't quietly take away rooms that are already promised. With onlyavailable, a hotel lowering its count would have to guess how many are promised. -
"A guest books Oct 15 to 18. Which rows change, and why not Oct 18?" Answer: Oct 15, 16 and 17. The guest sleeps three nights and leaves on the morning of the 18th, so that night's room is free for someone else. The range is half-open:
stay_date >= check_in AND stay_date < check_out. Getting this wrong by one either blocks a room for a night nobody uses, or sells a night twice. -
"It's 23:30 in Honolulu and 05:30 the next day in New York. A guest in New York books 'tonight' at the Honolulu hotel. Which night?" Answer: we compute it from the hotel's zone, not the guest's: in
Pacific/Honoluluit's still the earlier date, so "tonight" is that date's night, and it's still bookable. The guest's own zone only matters for how we display times to them.
Interview gotchas from this round's wrong answers
| Gotcha | Why it's wrong |
|---|---|
| "Read the free count, then write the new count" | A lost update: two guests read the same count and both claim the last room. |
| "Serializable isolation solves it" | It does, by aborting; the real fix is locking the row where the invariant lives, and Read Committed is enough for that. |
| "Retry on deadlock" as the design | Each deadlock costs a second of locked rows; lock in a fixed order instead. |
| "A timer expires the hold" as the only guard | Timers are late or die; the confirm must check expires_at with the database's clock itself. |
| "Add 24 hours to get tomorrow" | Local days are 23 or 25 hours across daylight-saving changes; move by calendar dates in the hotel's zone. |
| "Book first, charge after" | A failed charge leaves a confirmed booking nobody paid for. |
Round 2 · Senior · "500K Hotels, Search at 50K QPS, and Flash Sales"
~40 min · Senior SDE (L6) · 1 region, 3 AZs · 50,000 searches/s and 500 holds/s at peak · 1M bookings/day · search P95 < 150 ms · hold P99 < 80 ms · 99.99%
R2.0 Where We Left Off
This is what the candidate says aloud in the first 60 seconds of Round 2. If you're starting here, it's everything you need from Round 1.
Round 1 in 60 seconds. "We built booking for a 50-hotel chain: 1,000 bookings a day, and a promotion peak of about 20 holds and 1,000 searches a second. Inventory is one row per room type per night, with
total_roomsandreserved_rooms, and nights are calendar dates in each hotel's own time zone, computed with the time-zone database. A hold locks its nights withSELECT ... FOR UPDATEin date order, re-checksreserved + n <= totalin a conditional update, and a check constraint refuses any oversell. Read Committed is enough because the invariant lives on one row that every writer locks. A hold row withexpires_atis checked against the database's clock, a sweeper withSKIP LOCKEDreturns expired rooms, and the hold path releases expired holds lazily before saying 'sold out'. Idempotency keys are a unique constraint on the hold row. Confirm charges first, with a per-attempt PSP key, then flips the hold to confirmed. One Aurora writer and replica, three Fargate tasks, about $660 a month. Open costs: the hold can expire while payment is running, search reads the booking database, and one database serves everything."
Architecture v1, compact
Synthesizing vector architecture diagram...
Round 1 in one picture: the writer owns the invariant, the replica serves search, and the PSP is called only for a valid hold.
Round 1 step summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 1.1 | Overlap queries | Count per room type per night, hotel-local dates | Rows for every night |
| 1.2 | Last room sold twice | Lock, conditional update, check constraint, Read Committed | Row locks |
| 1.3 | Deadlocks | Fixed lock order | A rule for every path |
| 1.4 | Abandoned holds | expires_at on the DB clock; sweeper; lazy release | Rooms look taken up to 15 min |
| 1.5 | Double click | Idempotency key as a unique constraint | 30 days of hold rows |
| 1.6 | When is it booked? | Charge, then confirm conditionally | Hold can expire mid-payment |
Open costs: the hold/payment gap; search on the booking database; one writer for everything.
R2.1 The Scope Raise
Interviewer: "The chain's system worked, and we're now an online travel agency. 500,000 hotels worldwide, about 5 room types each, bookable up to two years ahead. 50,000 searches a second at peak, like 'hotels near me with a pool, under $200'. 500 holds a second at peak and a million bookings a day. Next February a famous New Orleans hotel opens its Mardi Gras rooms at a set time, and last year 100,000 people showed up in 3 seconds. Payments sometimes finish after the hold has expired, and those guests are furious. And some hotels are asking to overbook a little, because guests don't always show up."
We ask back before fixing anything.
| We ask | Interviewer answers | What it changes in the design |
|---|---|---|
| How fresh must search results be? | A few seconds old is fine. But nobody may pay for a room we don't have. | Search and booking can be different systems with different truths: search from a copy, booking from the source (step 2.1). |
| What does a search look like? | A map area or a radius around a point, dates, guests, and filters like pool, stars and price. Twenty results a page. | We need a geo and filter index, which a booking database is bad at (step 2.1). |
| How big is a flash sale, and do we know when? | 100,000 people in 3 seconds for about 400 rooms. The start time is announced a week ahead. | A gate in front of the database, and capacity scheduled before the start (step 2.2). |
| How long can paying take? | Usually seconds. A bank challenge (3-D Secure) can take a few minutes. | Payment can outlive a 15-minute hold; we need a fence before the charge (step 2.3). |
| How fast are we growing? | Hotels 40% a year; bookings double in two years. | One writer will run out; pick a shard key now (step 2.4). |
| Which hotels overbook, and who decides who gets turned away? | Some, on some nights, up to 5%. The hotel decides who is moved to another hotel. | An explicit allowance per room type per night, not a fudged room count (step 2.5). |
| Group bookings? | Up to 9 rooms, possibly of different types, all or nothing. | A hold with several lines, locked in one global order (R2.8). |
Scope change
| Round 1 | Round 2 | |
|---|---|---|
| Hotels | 50 | 500,000 worldwide |
| Inventory | 91,250 rows | 1.825 billion rows (500,000 × 5 × 730) |
| Searches | ~1,000/s at a promotion peak | 50,000/s peak (10,000/s average), geo and filters |
| Holds and bookings | ~20 holds/s peak; 1,000 bookings/day | 500 holds/s peak; 1M bookings/day |
| Special events | A member promotion | Flash sales: 100K users in 3 s |
| Rules | Never oversell | Never oversell beyond an explicit, per-night overbooking allowance |
| Survive | A task or a database failover | Losing an AZ |
| Targets | 99.9% | Search P95 < 150 ms, hold P99 < 80 ms (both measured at our load balancer); 99.99% (4.4 min/month) |
The "Not yet" list from R1.2 comes back: geo search, flash sales and overbooking are now in scope. Other channels are still out.
R2.2 What Breaks in the Round 1 Design
| Round 1 choice | What breaks at the new scope |
|---|---|
| Search reads the booking database | 50,000 searches a second, each over dozens of hotels × room types × nights, is millions of row reads a second. A database is also the wrong tool for "within 2 km, with a pool". |
| One writer, reached by everyone at once | In a flash sale, 100,000 requests queue for row locks on the same few rows. Connection pools fill up and every other hotel's bookings time out too. |
| Charge, then confirm | Payment regularly outlives the hold. The sweeper frees the room, someone else takes it, and the first guest has paid for nothing. |
| One database for every hotel | Fine today (R2.6 shows the math), but not at 4× in two years, and one failover or one bad flash sale touches every hotel. |
reserved_rooms <= total_rooms, no exceptions | Hotels that want to overbook will "fix" it by typing a bigger total_rooms. Then nobody knows the real room count. |
| A 5-second cache in each task | 150 tasks each caching 500,000 hotels × 25 months is neither small nor fresh. |
R2.3 New Requirements and API Additions
Geo and filter search, with pagination.
httpGET /v1/search?lat=29.9511&lng=-90.0715&radius_km=3&check_in=2027-02-06&check_out=2027-02-09&guests=2&rooms=1&amenities=pool&max_nightly=20000¤cy=USD&page_size=20 HTTP/1.1 Host: api.example-travel.com
httpHTTP/1.1 200 OK Content-Type: application/json { "results": [ { "hotel_id": "htl_77", "name": "Hotel on Canal", "distance_km": 0.4, "stars": 4, "from_nightly_minor": 18900, "currency": "USD", "rooms_left_hint": "3 left" } ], "next_page_token": "eyJzYSI6WzAuNDEsNzcwMDNdfQ", "as_of": "2027-01-20T16:00:02Z" }
next_page_token encodes where the last page stopped in the index's sort order (distance, then hotel ID), so page 2 continues from there instead of re-running the search with an offset. Availability can change between pages; a hotel can appear on page 1 and be sold out by the time the guest reaches it. That's acceptable for search; the hold is the check that matters.
Holds with several lines, and an extension.
httpPOST /v1/holds HTTP/1.1 Idempotency-Key: 9c1d4e7a-2b6f-4c8e-a3d5-7f0b1e2c9d64 Content-Type: application/json { "hotel_id": "htl_77", "check_in": "2027-02-06", "check_out": "2027-02-09", "lines": [ { "room_type": "DLX_KING", "rooms": 2 }, { "room_type": "SUITE", "rooms": 1 } ], "gate_token": "gt_v1.eyJzYWxlIjoibWcyNyJ9.3f9a" }
gate_token is only required while a flash sale is running for that hotel (step 2.2).
httpPOST /v1/holds/hld_01JQ7N2B5X/extend HTTP/1.1
The checkout page calls this when the guest starts paying. It sets expires_at to at least 5 minutes from now, at most twice per hold, and never past 25 minutes after the hold was created (step 2.3). It returns the new expires_at, or 410 if the hold already expired.
Overbooking allowance (hotel admin API).
httpPUT /v1/hotels/htl_77/room-types/DLX_KING/overbooking HTTP/1.1 Content-Type: application/json { "from": "2027-03-01", "to": "2027-03-31", "allowance": 3 }
It returns, per night, the allowance actually set. That can be higher than asked on nights that are already overbooked (step 2.5).
Flash-sale waiting room.
| Call | What it does |
|---|---|
POST /v1/flash-sales/mg27/queue | Joins the line. Returns a signed ticket with a position number. |
GET /v1/flash-sales/mg27/serving | Returns the highest position now allowed in. Cached at the edge for 1 second. |
POST /v1/flash-sales/mg27/claims | With an admitted ticket, the nights and the rooms wanted: returns a gate_token valid for 120 seconds, or "sold out" for the nights that ran out. |
R2.4 Design Evolution: Splitting Search From Booking, and Guarding the Seams
Step 2.1: 50,000 Searches a Second Crush the Booking Database
The problem: a search for "within 3 km of the French Quarter, Feb 6 to 9, with a pool" must find the hotels in that circle, keep the ones with a pool, check room availability for three nights, and return 20 results in under 150 ms. At 50,000 a second. What would you do?
Two traps with a cache like this, and our answers.
- A cold cache. If a Valkey shard loses its data (both copies, which is rare), 50,000 searches a second would all miss at once and fall through to the database. That's a stampede. We don't let misses fall through freely. Within each task, concurrent misses for the same key share one refill (single-flight). Across tasks, a refill first takes a short lock key (
SET lock:<key> <task> NX PX 2000), and others wait briefly instead of querying. Refills write withSET ... NX, so a newer CDC write that landed meanwhile wins. And while a shard is being rebuilt from the Aurora replicas (a bulk job, about 12.5 million monthly records), search degrades: hotels on that shard show "check availability" instead of counts, rather than sending 50,000 queries a second to the database. - "Why not cache forever and skip the TTL question?" We almost do: entries change only when CDC says the data changed, so there's no short TTL forcing constant refills. But a pipeline can silently drop something (a bug, a connector restarted from the wrong position), and "forever" would then mean "wrong forever". So every monthly record also carries a 24-hour TTL with ±2 hours of random jitter, so they don't all expire together, and a drift checker samples 1% of hotels an hour and compares them with the database. The TTL bounds how long a silent error can live; CDC keeps the data fresh.
Why CDC, and not the booking service writing to the cache itself? If the service commits a hold and then updates the cache, a crash or a dropped network call between the two leaves the cache wrong with no record of it: a dual write. CDC can't miss a committed change, because it reads the same log the database uses to commit. We also didn't choose an outbox table polled every 500 ms: polling adds a steady query load on the writer, the outbox needs constant deletes (dead rows and vacuum work on our hottest database), and freshness is capped by the poll interval. The cost of reading the log is a replication slot, a bookmark that makes the writer keep WAL until the connector has read it; if the connector stalls, WAL piles up on the writer's storage. We alarm on slot lag (R2.8).
Synthesizing vector architecture diagram...
Two dependent round trips to Valkey, one to OpenSearch. If fewer than 20 hotels survive the bitmaps, the service asks OpenSearch for the next 100 using the page token (R2.6 budgets for it).
Primitives: Geospatial Indexing: Geohash, Quadtree and S2 · Change Data Capture and the Outbox Pattern · Distributed Cache Patterns and Eviction
Drills: The product page that melted Redis (the cold-cache stampede and "why not cache forever" are the two bullets above) · The dual-write that broke search consistency (the dual-write failure and "CDC versus polling an outbox" are the paragraph above)
Step 2.2: Mardi Gras: 100,000 Users in 3 Seconds
The problem: the Hotel on Canal has 400 rooms in 5 types and opens bookings for Mardi Gras weekend (nights Feb 6, 7 and 8, 2027) at 10:00 New Orleans time on Nov 10, 2026. That's 2026-11-10T16:00Z (the city is on standard time, UTC−6, after Nov 1). 100,000 people arrive within 3 seconds, about 33,000 requests a second, all for the same 15 inventory rows.
What would you do?
Synthesizing vector architecture diagram...
The database sees about one hold per room, plus one per returned room, plus a few refusals that pull the counters down to its truth. Everyone else is answered by the gate. Dotted arrows are the return path that keeps the gate from underselling.
Synthesizing vector architecture diagram...
A gate token's life. A token that never became a real sale ends in RETURNED, guarded by a set membership so its rooms come back once, unless the database proved those rooms didn't exist (REFUSED), in which case the counter is corrected down instead.
Primitive: Distributed Rate Limiting (the admitter is a rate limiter with a queue in front of it)
What if the Valkey primary fails mid-sale? Its replica takes over, but replication is asynchronous, so the last few writes can be lost. A lost decrement means one token too many (safe: the database refuses the hold, and the min correction lowers the counter). A lost return means one room too few (the 10-second reconcile sets it back to the truth). Either way, the database never oversells.
Step 2.3: Payment Finished at 15:02, but the Hold Expired at 15:00
The problem: a guest's hold expires at 15:00:00. At 14:59:58 they click Pay. The bank shows a 3-D Secure challenge, and the charge succeeds at 15:02:10. Meanwhile, at 15:00:05, the sweeper expired the hold and released the rooms, and another guest took the last one at 15:01. The first guest has paid for a room that is gone. What would you do?
The same race, with the fence (all times from the database's clock):
Synthesizing vector architecture diagram...
The sweeper ran at 15:00:05 and simply didn't see the hold. Even without the extension, the fence at 14:59:58 would have succeeded (2 seconds were left) and protected the charge the same way; the extension only gives the guest more time to type.
Synthesizing vector architecture diagram...
Round 2's hold lifecycle. The sweeper only acts on HOLD; only the resolver can end a CONFIRMING hold, and only after the PSP confirms nothing was charged.
Why a paused server can't break this. Picture the booking service freezing for 20 seconds (a long garbage-collection pause) right after it read "the hold is valid", then waking up and writing. With an in-memory or external lock, that write would land after the lease expired. Here, every write carries its own condition (status = 'HOLD' AND expires_at > now(), or status = 'CONFIRMING'), checked by the database at the moment of the write. The database is the storage, so the check happens where the data lives: a late writer finds 0 rows and stops. That's the job a fencing token does in lock-based designs, done here by the row itself. And we don't need an external lock manager at all: conditional writes on the row (a compare-and-set) are enough, because every writer goes through the same database. The trade-off is contention: under a stampede, compare-and-set losers would retry. Step 2.2's gate is what keeps that contention away.
Primitive: Distributed Locks and Leases
Drill: The GC pause that corrupted shared storage (why an expiring lease doesn't protect storage, and conditional writes instead of an external lock, are the paragraph above)
Step 2.4: One Writer Won't Last Forever
The problem: R2.6 shows one Aurora writer carries today's peak of about 3,750 row writes a second. But bookings double in two years, a flash sale on one hotel shares that writer with every other hotel, and one failover pauses booking for all 500,000 hotels at once. What would you do?
Synthesizing vector architecture diagram...
A hotel's whole booking life happens on one cluster. Anything that needs many hotels reads a copy built from the change stream.
Primitive: Database Sharding and Partition Keys
Drill: One customer, one shard, one outage (a big chain landing is point 4; cross-tenant queries are the "What it costs us" paragraph)
Step 2.5: Hotels Want 5% Overbooking
The problem: a 200-room hotel sees about 8% of guests cancel late or not show up. On a "sold out" night, 16 rooms sit empty. The hotel asks to sell up to 10 extra rooms on some nights. How do we allow that without losing our invariant? What would you do?
The inventory row for Round 2:
sqlCREATE TABLE room_inventory ( hotel_id BIGINT NOT NULL, room_type_no SMALLINT NOT NULL, stay_date DATE NOT NULL, -- hotel-local night total_rooms SMALLINT NOT NULL CHECK (total_rooms >= 0), reserved_rooms SMALLINT NOT NULL DEFAULT 0 CHECK (reserved_rooms >= 0), overbook_allowance SMALLINT NOT NULL DEFAULT 0 CHECK (overbook_allowance >= 0), version INT NOT NULL DEFAULT 0, rate_minor INT NOT NULL CHECK (rate_minor > 0), PRIMARY KEY (hotel_id, room_type_no, stay_date), CONSTRAINT never_oversell CHECK (reserved_rooms <= total_rooms + overbook_allowance) ) PARTITION BY RANGE (stay_date); -- monthly partitions; old months are detached and dropped
Round 2 Step Summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 2.1 | Search crushes the database | OpenSearch for geo and filters; Valkey bitmaps per night and records per hotel-month; fed by CDC with versioned set-if-newer | A second, slightly stale copy; a pipeline to run |
| 2.2 | 100K users in 3 s | Waiting room; pre-allocated Valkey counters with an atomic multi-night claim; token returns on every exit path; raise-only reconcile | Counters to keep honest; scheduled capacity |
| 2.3 | Payment outlives the hold | Fence first (HOLD → CONFIRMING), charge second; extension with caps; resolver cancels before releasing | One more state and a resolver |
| 2.4 | One writer won't last | Shard by hotel_id: 1,024 buckets on 4 clusters; trips read model in DynamoDB | Cross-hotel queries go to copies |
| 2.5 | Hotels want to overbook | Explicit overbook_allowance, constraint uses it, walk report | Occasional walked guests, chosen knowingly |
R2.5 Architecture v2
Two paths share the front door and nothing else.
Synthesizing vector architecture diagram...
Follow a booking down the right side: the gate (only during a sale), the shard map, one hold transaction on one cluster. Every change it makes flows back up through CDC into the search cache and the trips table. The search path never writes to the booking database, and the booking path never trusts the cache.
The pieces that are new since Round 1:
- Search service: stateless Fargate tasks. For each search: OpenSearch for candidates, Valkey for availability, then a page of 20.
- OpenSearch: 30 data nodes, 10 per AZ. The hotel index is small (500,000 documents of about 5 KB, about 2.5 GB), so it's one primary shard with replicas on every node; each node can answer any query, and adding nodes adds query capacity.
- Valkey (ElastiCache): 4 shards, each a primary and two replicas in the three AZs. It holds the night bitmaps, the hotel-month records and the flash-sale keys. Search reads from replicas as well as primaries.
- Booking service: 12 Fargate tasks. It runs the gate, holds, extends, confirms and cancels, plus each shard's sweeper and resolver loops.
- Aurora: 4 clusters, each a writer and a replica in another AZ that Aurora promotes on failure.
- CDC: one Debezium connector per cluster on MSK Connect, one MSK topic with 48 partitions keyed by
hotel_id, and 3 updater tasks. - Payments: the payment platform from the payment loop, called with our per-attempt key.
The hold tables for Round 2 (lines for group bookings, the new states and the attempt counter):
sqlCREATE TABLE holds ( hold_id TEXT PRIMARY KEY, guest_id TEXT NOT NULL, idempotency_key TEXT NOT NULL, request_hash BYTEA NOT NULL, hotel_id BIGINT NOT NULL, -- one hotel per hold: always one shard check_in DATE NOT NULL, check_out DATE NOT NULL, total_minor BIGINT NOT NULL, status TEXT NOT NULL CHECK (status IN ('HOLD','CONFIRMING','CONFIRMED','EXPIRED','RELEASED','REJECTED')), pay_attempt SMALLINT NOT NULL DEFAULT 0, extensions SMALLINT NOT NULL DEFAULT 0 CHECK (extensions <= 2), gate_token_id TEXT UNIQUE, -- a gate token can create one hold expires_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (guest_id, idempotency_key), CHECK (check_out > check_in) ); CREATE INDEX holds_due ON holds (expires_at) WHERE status = 'HOLD'; CREATE INDEX holds_confirming ON holds (expires_at) WHERE status = 'CONFIRMING'; CREATE TABLE hold_lines ( hold_id TEXT NOT NULL REFERENCES holds(hold_id) ON DELETE CASCADE, room_type_no SMALLINT NOT NULL, rooms SMALLINT NOT NULL CHECK (rooms > 0), PRIMARY KEY (hold_id, room_type_no) );
The extension caps live in the extend statement itself, not in a table constraint:
sqlUPDATE holds SET expires_at = LEAST(GREATEST(expires_at, now() + interval '5 minutes'), created_at + interval '25 minutes'), extensions = extensions + 1 WHERE hold_id = $1 AND status = 'HOLD' AND expires_at > now() AND extensions < 2;
We deliberately don't write "expires_at never past 25 minutes" as a CHECK: the fence's GREATEST(expires_at, now() + 5 minutes) can legitimately go past it for a CONFIRMING hold, and a declined card then moves that hold back to HOLD, which such a constraint would refuse. And hold_lines cascades on delete, so the 30-day cleanup can delete old holds in one statement per batch.
Trace 1: a geo search. Covered by the sequence in step 2.1: OpenSearch returns 100 candidates in about 30 ms, one Valkey pipeline filters them by the three night bitmaps, a second reads 20 hotel-month records, and the page goes back with as_of and a page token.
Trace 2: a flash-sale hold.
Synthesizing vector architecture diagram...
The database saw one transaction for this guest, and about one per room across the whole sale, plus one per returned room. The 99,600 people who got no room never reached it.
Trace 3: a late payment, fenced correctly. Shown in step 2.3: the fence at 14:59:58 moved the hold to CONFIRMING, the sweeper at 15:00:05 didn't select it, and the charge that succeeded at 15:02:10 confirmed the booking. No refund, and no second guest sold the same room.
R2.6 Numbers and Cost
Inventory rows.
Row size. The Round 1 layout plus overbook_allowance (2 bytes), which fits in what was padding:
| Part | Bytes |
|---|---|
| Row header (23, padded to 24) | 24 |
hotel_id 8 + room_type_no 2 + padding 2 + stay_date 4 | 16 |
total_rooms 2 + reserved_rooms 2 + overbook_allowance 2 + padding 2 + version 4 + rate_minor 4 | 16 |
| Row: 56, already a multiple of 8 | 56 |
| Line pointer | 4 |
| Total per row | 60 |
We set the table's fillfactor to 90: pages are filled only to 90% on insert, leaving room so that an update to reserved_rooms can write the new row version into the same page. That's a HOT update (heap-only tuple): no index entry changes, which matters for a table updated all day. The heap becomes GB.
Primary-key index. Each leaf entry is an 8-byte index tuple header plus the key (hotel_id 8 + room_type_no 2 + padding 2 + stay_date 4 = 16), so 24 bytes, plus a 4-byte line pointer: 28 bytes. B-tree leaves are filled to 90% by default:
Inventory total: about 180 GB (an estimate: real tables also carry dead row versions between vacuums, so we budget about 220 GB). Split over 4 clusters, about 55 GB each, most of it far-future nights that are rarely read.
Traffic.
- Searches: 50,000/s peak. We assume the daily average is a fifth of that, 10,000/s: about 864 million searches a day, or 864 per booking.
- Holds: we assume 2 per booking again, so 2 million a day, 23.1/s on average and 500/s at peak (the daily peak plus flash sales).
- Row writes at peak: holds are 500 hold inserts + inventory updates. We assume half confirm around the same time (250/s, each 1 hold update + 1 booking insert = 500) and half are released (250/s, each 1 hold update + 3 inventory updates = 1,000):
Add extends, cancels and the calendar roller (500,000 hotels × 5 types = 2.5 million new rows a day, about 29 a second) and we plan for about 3,750 row writes a second at peak. Our planning figure for one db.r6g.2xlarge writer is 1,000 holds a second, about 7,500 row writes (an assumption we load-test). So one writer would do today; we run 4 for the reasons in step 2.4, and each has room to double.
- Bookings: 1M a day, 11.6/s on average.
- CDC events at peak: about 2,500 inventory row changes a second (1,500 from holds, 750 from releases, plus cancels), each becoming one versioned record write and one bitmap update in Valkey.
Holds and bookings storage. Hold rows live 30 days: storage from a retention period is writes per day × days kept, so million rows, at about 300 bytes with lines and indexes, about 18 GB. Bookings are about 1.5 KB each with indexes: 1.5 GB a day, about 550 GB a year. We keep one year of past bookings plus all future ones in Aurora and export older ones to S3. Total in Aurora after a year: about 750 GB across the 4 clusters.
Availability cache. A hotel-month record holds 5 types × 31 nights × 9 bytes (1 count + 4 price + 4 version) = 1,395 bytes, plus about 75 bytes of key and object overhead, about 1.47 KB. A 730-night window touches 25 calendar months:
About 18.5 GB per copy, 55 GB for three copies over 12 nodes: about 4.6 GB per node, on nodes with 26 GiB.
Valkey operations. Each search sends about 25 commands (3 BITFIELD_RO bitmap reads, about 22 hotel-month reads when a stay crosses a month boundary): million commands a second at peak. We plan 200,000 commands a second per cache.r7g.xlarge node (an assumption to load-test). All 12 nodes serve reads; losing an AZ leaves 8: million, 78% busy.
OpenSearch. We plan 2,500 geo-and-filter queries a second per r6g.2xlarge.search data node on this small in-memory index (an assumption to load-test): nodes. To survive losing one of three AZs, the remaining two-thirds must carry peak: nodes, 10 per AZ. The index can't grow its capacity in seconds, so we size it for peak.
Search fleet. We plan 250 searches a second per vCPU: vCPUs; with AZ headroom vCPUs, or 150 tasks of 2 vCPUs (50 per AZ) at peak. The fleet scales with traffic; we assume it averages 60 tasks over a month (never below 30).
Booking fleet. About 1,000 requests a second at peak (500 holds, 250 confirms, 250 extends), at 200 per vCPU: 5 vCPUs, 7.5 with AZ headroom. We run 12 tasks of 1 vCPU (4 per AZ) for flash-sale margin. The waiting-room endpoints scale up separately before each announced sale: vCPUs for about an hour.
Bandwidth. A search response is about 4 KB compressed (an assumption). On average, MB/s. Over a 30.4-day month (2,626,560 s), that's about 105 TB leaving AWS. At peak, MB/s, 1.6 Gbps.
Latency budget, search P95 (at our load balancer; dependent steps add):
| Step | P95 |
|---|---|
| WAF and load balancer | 3 ms |
| OpenSearch: 100 candidates | 40 ms |
| Valkey: bitmaps, then 20 records (two pipelines) | 4 ms |
| A second candidate round, for searches where fewer than 20 survive (we assume more than 5% of searches need it, so P95 includes it) | 44 ms |
| Assemble and serialize | 5 ms |
| Total | 96 ms, under 150 ms |
Latency budget, hold P99: load balancer 2 ms + gate check (sale only) 2 ms + hold transaction (4 statements at about 1 ms, a durable commit of a few ms, and up to about 25 ms of lock waiting at P99) about 35 ms + service 5 ms ≈ 45 ms, under 80 ms.
Availability. 99.99% of a 30.4-day month is minutes. One Aurora failover (tens of seconds) uses a real part of it, which is one more reason to shard: a failover spends a quarter of the hotels' budget, not all of it.
Security edge. AWS WAF bills $0.60 per million requests. Our average of about 10,500 requests a second is billion requests a month, or about $16,600. AWS Shield Advanced costs $3,000 a month (with a one-year commitment) plus a tiered data-transfer fee for load balancers ($0.05/GB for the first 100 TB a month, then $0.04/GB), and it covers standard WAF charges for the resources it protects, up to 50 billion requests a month. That's \3{,}000 + 100{,}000 \text{ GB} \times $0.05 + 5{,}000 \text{ GB} \times $0.04 = $8{,}200, and it adds DDoS response help. We choose Shield Advanced. Bot Control isn't covered, so we scope it to the booking and flash-sale paths only: about 100 requests a second, 263 million a month, about \265 at $1 per million after the first 10 million.
Monthly cost (us-east-1 on-demand prices, 730 hours a month):
| Item | Math | Monthly |
|---|---|---|
| Aurora instances | 4 clusters × 2 × db.r6g.2xlarge ($1.038/h) × 730 h | ≈ $6,060 |
| Aurora storage and I/O | ~750 GB × $0.10 ≈ $75, plus I/O ≈ $150 (estimate) | ≈ $225 |
| OpenSearch | 30 × r6g.2xlarge.search ($0.669/h) × 730 h ≈ $14,650, plus 3 dedicated cluster-manager nodes and storage (estimate) | ≈ $15,100 |
| Valkey | 12 × cache.r7g.xlarge at about $0.35/h × 730 h | ≈ $3,070 |
| Fargate | search: 60 average tasks × (2 × $0.04048 + 4 GB × $0.004445)/h × 730 h ≈ $4,325; booking: 12 × $0.0494/h × 730 h ≈ $432; updaters: 3 tasks ≈ $108 | ≈ $4,865 |
| MSK and Debezium | 3 × kafka.m7g.large ($0.204/h) ≈ $447, storage ≈ $30, 4 connectors × 1 MSK Connect unit × $0.11/h ≈ $321 | ≈ $800 |
| DynamoDB trips | ~37M writes and ~150M reads a month, on demand | ≈ $50 |
| Application Load Balancer | $16 fixed + about 180 capacity units (≈ 180 GB/h processed) × $0.008/h × 730 h | ≈ $1,070 |
| Shield Advanced, WAF, Bot Control | from above: $8,200 + $265 | ≈ $8,470 |
| Data transfer out | 105 TB: 10 TB × $0.09 + 40 TB × $0.085 + 55 TB × $0.07 (list tiers) | ≈ $8,150 |
| NAT gateways | 3 × $0.045/h × 730 h, plus a little processing | ≈ $100 |
| CloudWatch, logs, alarms | an estimate | ≈ $1,500 |
| Total | ≈ $49.5K/month |
The biggest lines are search (OpenSearch plus Valkey plus the search fleet, about $23K) and the edge (security plus data transfer, about $16.6K). The booking path, the part that has to be right, is about $7K. That's the read/write split in money.
Against the business. 1M bookings a day at an assumed $450 a stay is $450M a day in bookings. At an assumed 15% commission, that's about $67.5M a day. Infrastructure is a rounding error; conversion rate and oversells are what cost money.
R2.7 Trade-Offs
Four ways to decide "is there a room?", with figures marked rough:
| Pessimistic locks in Aurora (chosen) | Optimistic, version check | In-memory counter (Valkey script) | DynamoDB conditional writes / TransactWriteItems | |
|---|---|---|---|---|
| Protection against overselling | Durable: locks plus a check constraint | Durable, if every write checks the version | Not durable: an async replica can lose the last writes, so it can't be the truth | Durable per item; a multi-night stay needs a transaction |
| Multi-night atomicity | One transaction over N rows | One transaction, retried on any version change | One script over keys in one slot | TransactWriteItems: up to 100 items per call, all or nothing |
| One hot room-type night (rough) | Writers queue; about 5 ms of lock time each, so a few hundred holds a second on one row | Collisions abort and retry; worse than locks under contention | Tens of thousands of decisions a second on one slot | A hot item's partition takes up to 1,000 write units a second, and a transactional write costs 2 units per KB; conflicting transactions are cancelled, so real rates are lower |
| Deadlocks | Prevented by one lock order | None (no locks held) | None (single-threaded) | None; conflicting transactions fail fast |
| Where it fits | The source of truth | Low-contention updates (hotel settings) | The flash-sale gate in front of the truth | A good choice if the rest of the system were on DynamoDB |
We use two of them together: an in-memory gate that says "no" cheaply, and pessimistic locks that say "yes" durably.
| Choice | We chose | What we give up |
|---|---|---|
| Cache staleness vs cost | Change-driven updates through CDC, a 24-hour jittered TTL as a safety net | A second or two of staleness, and a pipeline to run. A short TTL instead would mean constant refills from the database and still-stale data between them. |
| Gate vs queue for flash sales | Waiting room plus a gate | A pure queue of hold requests is simpler and fair, but everyone waits for an answer that is "no" for 99.6% of them. The gate answers in a millisecond. |
| Sweeper vs a timer per hold | Sweeper per shard over a partial index, plus the lazy release | A timer per hold (a Step Functions workflow, as some designs use) would cost 2 million executions a day, and Standard workflows' default StartExecution rate in us-east-1 (300 a second, raisable) is below our 500 holds a second; Express workflows stop at 5 minutes, shorter than a hold. Correctness never depended on the timer anyway. |
| Shard now vs later | 4 clusters now, 1,024 buckets | Four clusters cost more than one (about $4,500 a month more) and cross-hotel queries need copies. In exchange, a failover or a hot sale touches a quarter of the hotels, and moving buckets later is routine. |
R2.8 Failure Modes
| Failure | What you'd see | How the design responds |
|---|---|---|
| The flash-sale stampede | 33,000 requests a second on one hotel | The waiting room admits 2,000 a second; the gate answers "sold out" in a millisecond; the shard sees about one hold per room plus one per returned room. Other hotels on the shard don't notice. |
| The hold/payment race | A payment that runs past expires_at | Fenced before the charge (step 2.3). The metric "refunds because the hold was gone" should be near zero; a rise means the fence is being skipped somewhere. |
| Partial failure in a group booking | 2 Deluxe Kings and 1 Suite, and one night has only 1 Deluxe King | One transaction locks every line's nights in (room_type_no, stay_date) order and checks all of them. Any shortfall rolls everything back and the response names the short night and type. Nothing partial is ever held. |
| Cache drifts from the database | The drift checker finds a hotel whose cached counts differ | The updater that owns that hotel's partition re-reads the hotel's rows from the cluster's writer and writes them with their versions; set-if-newer means an older event arriving later can't undo the repair. A drift rate above 0.1% of sampled hotels pages someone. |
| The CDC connector stalls | Replication-slot lag grows; search gets staler; WAL piles up on the writer | Alarm at 5 minutes of lag. Search keeps working (stale). Booking is unaffected. If the slot's retained WAL threatens the writer's storage, we drop and recreate the slot and rebuild that shard's cache from the replica. |
| Losing an AZ | Tasks, some cache nodes, an OpenSearch third and maybe a writer disappear | Aurora promotes the replicas of affected clusters (tens of seconds; holds retry with the same keys). two-thirds of the search fleet (100 of 150 tasks at peak), 20 of 30 OpenSearch nodes and 8 of 12 Valkey nodes carry peak. Valkey promotes replicas for shards that lost a primary. |
R2.9 Production Gotchas
| Gotcha | Why it hurts | What we do |
|---|---|---|
| A row per physical room | 500,000 hotels × ~200 rooms × 730 nights is about 73 billion rows, 40 times ours, for a question guests don't ask | Counts per type per night |
| Polling the database for expirations the naive way | A cron that scans the whole holds table and releases thousands of holds in one big transaction locks inventory rows for seconds and releases in lumps | A partial index on due holds, small batches, SKIP LOCKED, one short transaction per hold, and the lazy release as a backstop |
Overbooking by editing total_rooms | Nobody knows the real room count; walks come as surprises | An explicit allowance, a constraint that names it, a daily walk report |
| Locking nights in request order | Deadlocks under load | One global order: (hotel, type, night) |
| No extension or fence at checkout | Guests are charged for rooms released under them | Fence before the charge; extend when checkout opens, with caps |
| Treating the search cache as truth | Holds that look fine and then fail at the desk | The hold always runs on the database |
R2.10 Pillar Check
| Pillar | What Round 2 adds |
|---|---|
| Reliability | Search degrades (stale or "check availability") instead of failing; the gate and waiting room shed load before the database; shards limit a failover to a quarter of hotels; AZ loss sized (20 of 30 OpenSearch nodes, 8 of 12 Valkey nodes, 2 of 3 fleet thirds) REL 5 · REL 7 · REL 10 · REL 11 |
| Security | Shield Advanced and WAF at the edge, Bot Control on booking paths; signed waiting-room tickets and gate tokens; least-privilege roles (search can't reach the booking database) SEC 3 · SEC 5 |
| Performance Efficiency | Search P95 ≈ 96 ms and hold P99 ≈ 45 ms, derived; the right store for each question (OpenSearch for geo, bitmaps for "any room", Aurora for truth) PERF 1 · PERF 3 |
| Cost Optimization | ≈ $49.5K/month derived; Shield Advanced chosen because it's cheaper than per-request WAF at this volume; Bot Control scoped to where bots matter; the search fleet scales with traffic COST 5 · COST 6 |
| Operational Excellence | Alarms on slot lag, drift rate, oldest due hold, stuck CONFIRMING, refunds after release; flash-sale capacity scheduled from the announced start time; walk reports OPS 8 · OPS 10 |
| Sustainability | Graviton instances throughout; the search fleet follows demand; past inventory partitions are dropped and old bookings move to S3 SUS 2 · SUS 4 |
R2.11 Round 2 Rubric and Follow-Ups
What a senior (L6) answer adds over L5
- Splits search from booking and says exactly which one is allowed to be stale, and why the hold never trusts the cache.
- Feeds the cache from CDC with absolute values and versions, not increments, and handles cold starts without a stampede.
- Designs a flash-sale gate that returns tokens on every exit path, with a repair that can only raise counters.
- Fences the hold before charging, releases a
CONFIRMINGhold only after cancelling the PSP attempt, and caps extensions. - Shards by the key every transaction shares (
hotel_id), with buckets that can move, and moves cross-hotel queries to copies. - Treats overbooking as a policy with its own column, constraint and procedure, and can put a number on its risk.
Follow-up questions
-
"Why not keep holds only in Valkey and write to the database at confirm?" Answer: because then the truth about who holds which room would live in a store that can lose its last writes in a failover. Two guests could hold the same room after a Valkey failover, and one of them would learn it at the confirm step, after paying attention for 15 minutes. We use Valkey to say "no" fast; only the database says "yes".
-
"The gate says sold out, but search shows the room free. Who's right?" Answer: neither is the truth; the database is. Search is a second or two behind, and the gate is conservative while tokens are outstanding. When outstanding tokens expire or holds fail, the gate gets the rooms back, and search shows the database's count after CDC. For a guest, "sold out, stay in line" is the honest answer until then.
-
"A guest extends twice, then the bank challenge takes 8 minutes. What happens?" Answer: the fence moved the hold to
CONFIRMINGbefore the challenge started, so it can't expire while the guest is in the challenge. When the fence's deadline passes, the resolver asks the PSP; if the payment is still pending, the resolver waits until the PSP gives a final answer or it successfully cancels the attempt. We never release the rooms of a payment that might still succeed.
Interview gotchas from this round's wrong answers
| Gotcha | Why it's wrong |
|---|---|
| "Read replicas for search" | Replicas copy the whole database and are bad at geo; search needs its own index and cache. |
| "Update the cache after the commit, in the service" | A dual write: a crash in between leaves the cache wrong forever. |
| "Decrement the cache on each event" | At-least-once consumers replay; write absolute values guarded by version. |
| "A gate that only decrements" | Every abandoned token undersells; return tokens on every exit path. |
| "Extend the hold, and that's enough" | The race moves to the new deadline; fence before charging. |
| "Shard by date" | Multi-night stays span shards, and next weekend lands on one. |
Round 3 · Architect · "A Global Marketplace With Shared Inventory"
~45 min · Principal (L7) · 3 home regions, each with a disaster-recovery copy · 10M listings · 200,000 searches/s at peak · 2,000 holds/s · search P95 < 200 ms worldwide
R3.0 Where We Left Off
This is what the candidate says aloud in the first 60 seconds of Round 3. If you're starting here, it's everything you need from Round 2.
Round 2 in 60 seconds. "We built an online travel agency in one region: 500,000 hotels, 1.825 billion inventory rows of about 60 bytes, about 180 GB with the primary key. 50,000 searches a second go to a separate path: OpenSearch for geo and filters, and a Valkey cache with one bitmap per night and a record per hotel per month, fed by CDC from Aurora with versioned set-if-newer writes. 500 holds a second go to the booking path: shard by
hotel_idover 1,024 buckets on 4 Aurora clusters, holds locked in one global order, and a check constraint that now includes an explicit overbooking allowance. Flash sales pass a waiting room and a Valkey gate with pre-allocated counters, and every unused token comes back exactly once. Confirm fences the hold intoCONFIRMINGbefore charging, and a stuck payment's rooms are released only after the PSP attempt is cancelled. About $49.5K a month. Open costs: our database is the only truth, prices are baked into the cache, and everything is in one region."
Architecture v2, compact
Synthesizing vector architecture diagram...
Round 2 in one picture: search reads copies, booking writes the truth, and CDC connects them.
Round 2 step summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 2.1 | Search crushes the database | OpenSearch + Valkey bitmaps and records, via CDC with versions | Seconds of staleness; a pipeline |
| 2.2 | 100K users in 3 s | Waiting room + gate counters with token returns | Counters to reconcile |
| 2.3 | Payment outlives the hold | Fence first, charge second; capped extension | A state and a resolver |
| 2.4 | One writer won't last | Shard by hotel_id, 1,024 buckets | Cross-hotel queries on copies |
| 2.5 | Overbooking | Explicit allowance in the constraint | Walked guests, chosen |
Open costs: our database assumes it's the only seller; prices sit inside the availability cache; one region.
R3.1 The Scope Raise
Interviewer: "We're becoming a global marketplace. Hosts now list 9.5 million unique homes, one of each, next to our 500,000 hotels. Most hotels also sell the same rooms on other sites and manage all of them through a channel manager, which will sync with us. Prices change by demand, sometimes several times a day. Guests pay in their own currency. Every listing picks its own cancellation policy. Users everywhere expect fast search, and we must survive losing a region."
We ask back before fixing anything.
| We ask | Interviewer answers | What it changes in the design |
|---|---|---|
| How do hotels tell us about rooms sold elsewhere? | Through their channel manager: a third-party system that sends availability and rates to every site the hotel sells on, and receives each site's reservations. Messages arrive within seconds to minutes. | Our database is no longer the only seller. We need a sync protocol, a buffer, and a process for the oversells that still happen (step 3.1). |
| How often do prices change? | Hotels' revenue systems reprice every few hours; hosts use automatic pricing. A single hotel can send thousands of rate changes in one burst. | Rates change far more often than availability and must stop being baked into the availability cache (step 3.2). |
| What currencies? | Listings price in their own currency; guests see and pay in one of 40. | A price quoted at hold time, in the guest's currency, guaranteed for the hold (step 3.2; the money side is the payment loop). |
| How do homes differ from hotel rooms? | One unit. Hosts block dates, set minimum stays, allowed check-in days and a cleaning night between stays. | Same model with total = 1, plus rules checked at hold time (step 3.3). |
| Where are listings and guests? | Listings: 40% Americas, 35% Europe and Africa, 25% Asia-Pacific. Guests search anywhere, often far from home. | Search near every guest; booking in one place per listing (step 3.4). |
| Cancellation rules? | Flexible, moderate, strict, non-refundable; we revise their terms a few times a year. Weather events can force refunds for a whole area. | Versioned policy objects evaluated at cancel time, and a mass-cancellation path (step 3.5, R3.8). |
| What if a region fails? | Search must keep working. Booking for listings in that region may pause briefly, and we may lose at most seconds of bookings. | Home regions with disaster-recovery copies, a fenced failover and a stated RPO (step 3.6). |
Scope change
| Round 2 | Round 3 | |
|---|---|---|
| Listings | 500,000 hotels | 10M listings: 9.5M homes + 0.5M hotels |
| Calendars | 2.5M room types × 730 nights | 12M calendars: homes × 365 nights, hotel types × 730 nights |
| Inventory rows | 1.825 billion | 5.29 billion, plus 5.29 billion nightly rates |
| Sellers of the same room | Only us | Us and other sites, synced through channel managers |
| Traffic | 50K searches/s, 500 holds/s, 1M bookings/day | 200K searches/s, 2,000 holds/s, 4M bookings/day |
| Prices | Set by hotels, rarely changed | Dynamic; quoted at hold time; 40 guest currencies |
| Footprint | 1 region, 3 AZs | 3 home regions (us-east-1, eu-central-1, ap-southeast-1), each with a DR copy |
| Targets | Search P95 < 150 ms | Search P95 < 200 ms worldwide; cross-channel oversells minimized and handled; RPO ≤ 20 s |
R3.2 What Breaks in the Round 2 Design
| Round 2 choice | What breaks at the new scope |
|---|---|
| Our database is the truth about free rooms | A hotel's last room can be sold on another site a second before we sell it. Our constraint can't see sales it doesn't know about. |
| Nightly price stored in the inventory row and in the availability cache | Every repricing burst rewrites inventory rows (fighting holds for row locks) and floods the availability pipeline with changes that aren't availability. |
| The price shown in search is the price charged | If the price changes between search and pay, we either charge a different price than we showed, or honor stale prices forever. Neither is a rule. |
| One region | Guests in Sydney wait for Virginia on every search, and a regional outage stops everything. |
| One flat cancellation rule | Listings pick different policies, and changing a policy's terms must not change what earlier guests were promised. |
| Every calendar is a hotel room type | Homes have rules that hotels don't: minimum stays, check-in days, a cleaning night. |
R3.3 New Requirements and API Additions
Channel sync, in. The channel manager sends us absolute availability per room type and night, never "minus one":
httpPOST /v1/channel/cm_acme/availability HTTP/1.1 Authorization: Bearer <channel manager credential> Content-Type: application/json { "hotel_id": "htl_77", "stream": "availability", "seq": 918224, "our_last_reservation_seq": 5531, "items": [ { "room_type": "DLX_KING", "from": "2027-03-12", "to": "2027-03-14", "available": 2 } ] }
seq numbers this hotel's availability messages only, so a gap means we missed one. our_last_reservation_seq says which of our reservation messages the channel manager had applied when it computed available.
Channel sync, out. For every booking, change or cancellation at a channel-managed hotel, we push a reservation message with our own per-hotel sequence (seq: 5532) and wait for its acknowledgment. Pull: GET /v1/channel/cm_acme/hotels/htl_77/snapshot compares full availability, and either side can ask for it.
Rates and quotes. Pricing writes rates (PUT /v1/listings/{id}/rates with date ranges and amounts in minor units). A hold's response now carries a quote:
json{ "hold_id": "hld_01K0B7S2QW", "expires_at": "2027-01-20T16:15:00Z", "quote": { "quote_id": "qt_7Hc2", "nights": [ { "date": "2027-03-12", "listing_minor": 21000 }, { "date": "2027-03-13", "listing_minor": 24000 } ], "listing_currency": "EUR", "listing_total_minor": 45000, "guest_currency": "JPY", "guest_total_minor": 73350, "fx_rate": "163.00", "valid_until": "2027-01-20T16:15:00Z" }, "cancellation": { "policy": "moderate", "version": 4, "full_refund_until": "2027-03-07T14:00:00Z" } }
Amounts are in each currency's minor unit, which ISO 4217 defines per currency: cents for EUR, but none for JPY, so ¥73,350 is 73350 and €450.00 is 45000.
Policy objects. GET /v1/policies/moderate/versions/4 returns the rules as data (step 3.5).
Currency display. Search shows prices converted at a reference rate refreshed every few minutes and labeled "approximately"; only the quote is binding.
R3.4 Design Evolution: Truth We Don't Own, Prices That Move, and a World of Regions
Step 3.1: The Same Room, Sold Here and on Another Site at Once
The problem: the Hotel on Canal has 2 Deluxe Kings left for Mar 12. It sells them on our site and on two others, through its channel manager. At 16:00:00 a guest on another site books one. At 16:00:02, before the channel manager has told us, we sell the other one, and a third guest books the "last" one with us at 16:00:03. The hotel now has three bookings for two rooms. What would you do?
The constraint must still allow the sync. Our CHECK (reserved_rooms <= total_rooms + overbook_allowance) stays as it is. The channel condition lives only in the hold's WHERE clause, never in a CHECK: a message from the channel manager reports sales that already happened elsewhere, and a constraint that refused to store that fact wouldn't prevent the oversell, it would hide it.
Synthesizing vector architecture diagram...
The rare case the buffer can't stop: two sales elsewhere inside the sync window. Without the buffer we'd also have sold at 16:00:03, making four bookings for two rooms; with it, there is one oversell, found within seconds by the rejected push and handled as a case instead of a surprise at the front desk. In the single-sale scenario from the problem above, the buffer prevents the oversell entirely.
Primitive: Change Data Capture and the Outbox Pattern (our reservation pushes)
Step 3.2: Prices Change Hourly, and Caches Show Old Prices
The problem: a hotel's revenue system raises Mardi Gras prices by 30% at 09:00. Our availability cache still shows yesterday's price. A guest holds at the old price shown in search; what do we charge? And hosts' automatic pricing rewrites millions of nightly prices every day. What would you do?
Synthesizing vector architecture diagram...
The search price was a cached "from" price; the hold made the binding quote from the shard's rates and a live FX quote; the charge used exactly that quote. The rate could change at 16:05 and this guest would still pay 73,350 JPY.
The rates and the FX quote are fetched before the hold transaction opens, so no row lock is ever held across a network call. The transaction then locks the nights, checks availability, and stores the quote with the hold. A rate that changes between the read and the lock doesn't matter: the quote is our offer at that moment, and it's what we guarantee.
Step 3.3: A Unique Home Is One Unit
The problem: a host lists a cottage. There's one of it. The host blocks some dates for family, wants a minimum stay of 2 nights, check-in only on Saturdays in July and August, a cleaning night between guests, and at least one day's notice before arrival. What would you do?
Go deeper: when you really do need specific units. If a hotel must sell specific rooms (room 1204 with the view), PostgreSQL can enforce "no two stays overlap for the same room" directly with an exclusion constraint: EXCLUDE USING gist (room_id WITH =, daterange(check_in, check_out) WITH &&) (the = on a plain column needs the btree_gist extension). The database then refuses an overlapping insert atomically, without write skew. We keep counts because guests book types and because counts feed the same bitmaps, caches and channel sync; the exclusion constraint is the right tool when the unit is the product.
Step 3.4: Search Must Be Fast Worldwide
The problem: a guest in Sydney searches Paris. Round 2's single region in Virginia is about 200 ms of round trip away, before any work. We need search P95 under 200 ms for guests everywhere, at 200,000 searches a second. What would you do?
Synthesizing vector architecture diagram...
Searches stay near the guest; holds travel to the one shard that owns the listing. Every region's search stack is built from every home region's change stream.
Primitive: Cloud Disaster Recovery and Multi-Region Active-Active
Drill: The booking that existed in Frankfurt but not in Virginia (deciding on a lagging copy is the wrong answer above, which point 2 fixes; synchronous replication for all writes is the "Why not" paragraph)
Step 3.5: Cancellations and Refunds by Policy
The problem: a guest cancels a Paris cottage 4 days before arrival. The listing uses the "moderate" policy. Last month we changed "moderate" from 5 days to 7 days for a full refund. What does this guest get back, who computes it, and what if the refund to their card fails? What would you do?
Deadlines are computed in local time, not by subtracting hours. A New York hotel's policy says "full refund until 15:00, 2 days before check-in". For check-in on Nov 2, 2026, the deadline is Oct 31, 2026 at 15:00 America/New_York, which is daylight time (UTC−4): 2026-10-31T19:00Z. Clocks go back on Nov 1, so "48 hours before 15:00 on Nov 2" (2026-11-02T20:00Z minus 48 hours = 2026-10-31T20:00Z) is 16:00 local on Oct 31: an hour later than the policy says. We compute the local date first, attach the local time, and convert once.
Both books balance on every branch. Inventory and money are separate ledgers, and each must stay consistent however a cancellation ends:
| What happens | Inventory | Money (journals via the payment platform) |
|---|---|---|
| Cancel inside the full-refund window | Nights released once, in the cancel transaction | Refund owed = full amount; settled when the PSP confirms the refund |
| Cancel later, 50% tier | Nights released once | 50% owed to the guest; the other 50% stays payable to the host or hotel, minus our fee |
| Cancel request retried | No change: the booking is already CANCELLED | Same idempotency key, so the same single refund |
| Refund fails (closed card) | No change: the stay is still cancelled | Still owed to the guest in a refunds-payable account; paid another way, then settled |
| A settled refund is returned later by the card network | No change | A reversing journal re-opens the payable; review queue |
| Host cancels a confirmed stay | Nights released; the calendar blocked if the host asks | Full refund to the guest; the host's penalty is a separate journal |
The journals themselves are the payment loop's double-entry ledger (its step 1.5); this system only decides the amounts and asks.
Synthesizing vector architecture diagram...
A refund's life after the cancel has committed. The cancel never waits for it and never rolls back because of it; the refund is owed until one of the paths reaches SUCCEEDED.
Primitive: Two-Phase Commit and Saga Orchestration
Drill: The flight booking that charged without a seat (a compensating refund that fails is points 4 and 5 and the refund lifecycle; why a saga and not 2PC is the second wrong answer: the PSP can't join our transaction, and 2PC would hold locks across its network call)
Step 3.6: A Region Is Gone
The problem: eu-central-1 becomes unreachable. It is the home of 3.5 million listings: their inventory, holds and bookings live on its 4 shards. Search for them runs in all three regions. What keeps working, what stops, and what might we lose? What would you do?
Synthesizing vector architecture diagram...
Two fences, either of which alone prevents two writers: the lease (old services stop within 10 s of the flip because they can't reach a majority) and Aurora's RPO setting (a cut-off primary stops committing within about 20 s). The new region writes only after both.
Round 3 Step Summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 3.1 | Sold here and elsewhere at once | Absolute channel-manager counts with sequences; sold_since_cm; outbox pushes; a buffer when few are left; oversell cases; gap checks per stream | Some oversells still happen; an ops process |
| 3.2 | Prices change hourly | Rates in their own table and cache (10-min TTL); a binding quote at hold time with FX; price floors and change limits | Quote management; twice the stored rows |
| 3.3 | Unique homes | Same model with total = 1; blocking sets total = 0; rules checked at hold; cleaning night as a reserved night | More rules at hold time |
| 3.4 | Fast search worldwide | Home region per listing (one writer); full search stacks in 3 regions via MSK Replicator; continent-routed index | Far guests' holds are slower |
| 3.5 | Cancellations and refunds | Immutable policy versions snapshotted on the booking; cancel in one transaction; refund as a retried saga step; nightly reconciliation | Versioning; a refund pipeline |
| 3.6 | A region is gone | Search from other regions; pause, or fail over with a majority-read writer lease plus Aurora's RPO cap; reconcile escaped replies | 10–15 min pause for a third of listings; RPO ≤ 20 s |
R3.5 Global Architecture
One home region in detail; the other two are the same shape.
Synthesizing vector architecture diagram...
Everything that decides a sale sits on the shards. Everything guests browse is a copy built from the shards' change stream, in every region. Channel managers, pricing and cancellations all change the shards through the same locked, conditional paths as a guest's hold.
Trace 1: a channel oversell detected and resolved. The sequence in step 3.1: two sales elsewhere and one of ours inside the sync window, the channel manager rejects our push within seconds, an oversell case opens, and the hotel relocates one guest under the written policy. The metric "oversell cases per 10,000 channel-managed bookings" moves by one.
Trace 2: a price quoted and honored. The sequence in step 3.2: search showed a cached "from" price in JPY, the hold read the shard's rates and an FX quote and stored a binding quote of ¥73,350, the hotel repriced at 16:05, and the confirm at 16:09 charged exactly ¥73,350.
Trace 3: a region outage. The sequence in step 3.6: eu-central-1 is cut off at 14:00. Search in the other regions keeps serving its listings with bookings paused. At 14:05 on-call flips the flags; eu-central-1's services stop by 14:05:10 and the cut-off primary had already paused commits once its lag passed 20 seconds. At 14:05:15 the failovers start; a few minutes later eu-west-1 is writing. Reconciliation then walks the loss window against PSP charges and channel managers.
R3.6 Numbers and Cost
Calendars and rows.
Homes' calendars open a year ahead, hotels' two years (our choice).
Inventory row size. Round 2's row loses rate_minor (4 bytes; rates moved out) and gains cm_available (2), sold_since_cm (2) and cm_seq (BIGINT, 8, aligned to 8):
| Part | Bytes |
|---|---|
| Header, padded (homes' rows carry a small null bitmap for the unused channel columns; the header still pads to 24 or 32) | 24 |
Keys: listing_id 8 + room_type_no 2 + padding 2 + stay_date 4 | 16 |
Counts: total 2 + reserved 2 + allowance 2 + padding 2 + version 4 + cm_available 2 + sold_since_cm 2 | 16 |
cm_seq | 8 |
| Row (64) + line pointer (4) | 68 |
Homes' rows come out the same: a 32-byte header with the null bitmap, plus 28 bytes of non-null data, padded to 64. So about 68 bytes for every row (an estimate).
Inventory: about 565 GB.
Nightly rates. A rate row is the header (24) + keys (16) + rate_minor 4 + version 4 = 48 bytes + a 4-byte line pointer = 52 bytes (the currency lives on the listing, not on every night):
Bookings and holds. 4M bookings a day at about 1.5 KB is 6 GB a day, about 2.2 TB a year kept hot. Holds: 8M a day kept 30 days is 240M rows at about 300 bytes, about 72 GB.
Aurora in total: about TB, over 12 clusters split by listing share: us-east-1 5, eu-central-1 4, ap-southeast-1 3, about 275 GB each.
Holds. 2,000 a second at peak worldwide. The busiest region's share is a second over 5 clusters: 160 per cluster, far under Round 2's planning figure of 1,000.
Channel sync. We assume 100 messages a day per channel-managed hotel: million a day, about 580 a second on average and 2,900 at a 5× peak. We assume 20% are availability (10M a day, each touching about 3 nights: 30M inventory updates a day, about 350 a second) and 80% rates (40M a day, each about 10 nights: 400M rate rows a day). Adding hosts' automatic pricing, which we assume changes 10% of home-nights a day (M), rate writes total about 750M a day: 8,700 a second on average, about 725 per cluster. Rates, not bookings, are now the biggest write load, which is exactly why they're in their own table, written in batches.
Search traffic. 200,000 a second at peak, split by where guests are: us-east-1 80,000, eu-central-1 70,000, ap-southeast-1 50,000. The average is a fifth of peak, 40,000 a second.
OpenSearch. 10M listings × about 5 KB is about 50 GB per region. With continent routing, a query searches about a third of the listings; we plan 2,000 queries a second per r6g.2xlarge.search node (an assumption, lower than Round 2's 2,500 for the larger index). Sized per region, with AZ headroom, rounded to whole nodes per AZ:
| Region | Peak/s | Nodes needed | ÷ 2/3 for AZ loss | Per AZ | Nodes |
|---|---|---|---|---|---|
| us-east-1 | 80,000 | 40 | 60 | 20 | 60 |
| eu-central-1 | 70,000 | 35 | 52.5 | 18 | 54 |
| ap-southeast-1 | 50,000 | 25 | 37.5 | 13 | 39 |
| Total | 153 |
Valkey. Per copy, per region: availability records (hotels: months × about 850 B ≈ 10.6 GB; homes: months × about 230 B ≈ 28.4 GB), rates records (hotels ≈ 8.7 GB, homes ≈ 24.6 GB) and night bitmaps (10M bits × 730 nights ≈ 0.9 GB): about 73 GB, 220 GB with three copies. Each search now reads availability and rates records: about 45 commands. At 200,000 commands a second per node and AZ headroom:
| Region | Commands/s at peak | Nodes (÷ 200K ÷ 2/3) | Shards × 3 | Memory per node |
|---|---|---|---|---|
| us-east-1 | 3.6M | 27 | 9 × 3 = 27 | ≈ 8.1 GB |
| eu-central-1 | 3.15M | 23.6 | 8 × 3 = 24 | ≈ 9.2 GB |
| ap-southeast-1 | 2.25M | 16.9 | 6 × 3 = 18 | ≈ 12.2 GB |
| Total | 69 nodes |
Search fleets. Four times Round 2: 600 tasks at peak across regions, averaging about 240.
Latency, search P95 worldwide. Round 2's server time (96 ms) stays about the same per region. Add the guest's round trip to the nearest of three regions: under about 100 ms for most guests, so under 200 ms end to end. Guests farther than that from all three (parts of South America and Africa, for example) will exceed it; if we measure the SLO there, a fourth region is the fix, not a faster server.
Latency, a far guest's hold. About 45 ms of work plus one round trip to the listing's home region: roughly 200 to 300 ms for a guest on another continent. Searches stay local.
RPO and RTO. RPO normally under a second, capped at about 20 seconds by rds.global_db_rpo. RTO 10 to 15 minutes, mostly the human decision.
Bandwidth and requests. MB/s on average, about 420 TB a month out, and billion requests a month.
Monthly cost (us-east-1 on-demand prices for every region first; the adjustment for other regions follows):
| Item | Math | Monthly |
|---|---|---|
| OpenSearch | 153 × $0.669/h × 730 h ≈ $74,700, plus 3 cluster-manager sets and storage (estimate) | ≈ $75,900 |
| Valkey | 69 × cache.r7g.xlarge at about $0.35/h × 730 h | ≈ $17,630 |
| Aurora instances | 12 clusters × (writer + replica + DR secondary) = 36 × $1.038/h × 730 h | ≈ $27,280 |
| Aurora storage, I/O, replication | 3.3 TB in the home regions and 3.3 TB in the DR regions × $0.10 ≈ $660; I/O and replicated write I/O with batched rate writes (estimate) ≈ $1,250 | ≈ $1,900 |
| Fargate | search ≈ 240 average tasks ≈ $17,300; booking 36 tasks ≈ $1,300; channel gateway, pricing, policy, updaters and warm DR tasks ≈ $2,300 | ≈ $20,900 |
| MSK, connectors, MSK Replicator | 3 clusters ≈ $1,340 + storage ≈ $180 + 12 connectors ≈ $960; MSK Replicator is billed per replicator-hour plus per GB processed: we budget $2,000 (an estimate, not a derived figure) | ≈ $4,500 |
| Load balancers | about 4 × Round 2 | ≈ $4,300 |
| Shield Advanced and WAF | $3,000 + $17,800 transfer fee (100 TB × $0.05 + 320 TB × $0.04); WAF requests above the 50 billion a month Shield covers (55 billion) are budgeted at WAF's $0.60 per million, $33,000 (the actual overage price may be lower); Bot Control on booking paths ≈ $1,000 | ≈ $54,800 |
| Data transfer out | per region at list tiers: 168 TB ≈ $12,200; 147 TB ≈ $11,090; 105 TB ≈ $8,150 | ≈ $31,440 |
| Cross-region event copies | about 9 TB a month × $0.02/GB | ≈ $200 |
| DynamoDB, NAT, CloudWatch | trips tables ≈ $200; NAT ≈ $300; logs and metrics (estimate) ≈ $6,000 | ≈ $6,500 |
| Total at us-east-1 prices | ≈ $245K/month |
Other regions cost more. Frankfurt and especially Singapore charge more than us-east-1 for instances and data transfer. They carry about 60% of this spend; we add about 15% to that share (an estimate, to be replaced by the pricing calculator), about $22K, for a budget of about $267K a month.
Where the money goes. Search (OpenSearch, Valkey and search fleets) is about $111K, the edge (Shield, WAF and data transfer) about $86K, and booking (Aurora, booking fleets, channel gateway) about $32K. The system that must never be wrong is the cheap part; the system that must be fast everywhere is the expensive part.
Against the business. 4M bookings a day at an assumed $450 a stay is $1.8B a day; at an assumed 15% commission, about $270M a day. The infrastructure is about 0.003% of commission. Conversion (fast search, honest prices) and oversells (relocation costs, lost trust) are the real levers.
R3.7 Trade-Offs
| Choice | We chose | What we give up |
|---|---|---|
| Channel buffer vs sell-through | A buffer of 1 only when 3 or fewer rooms are left, per the channel manager | Sometimes we don't sell a hotel's last room even when nobody else would have. A bigger buffer cuts oversells further and loses more sales; no buffer maximizes sales and multiplies oversells. We tune it per hotel from measured oversell cases. |
| Quoted vs live prices | A binding quote made at hold time, valid for the hold | The hotel sometimes sells at a price that changed a few minutes ago. Live pricing at payment would surprise guests after they typed their card, and cost more trust than those minutes cost money. |
| Home region vs global writes | One writer per listing, in its home region | Far guests' holds cross an ocean, and a region failure pauses a third of listings for 10 to 15 minutes. Multi-writer would be faster for them but could sell one night twice, and last-writer-wins would erase a booking. |
| Build vs buy: channel connectivity | Integrate with the major channel managers through one gateway and one internal protocol; buy connectivity for the long tail | Each direct integration is ours to maintain. Building our own channel manager for hotels would put us in a different business, competing with the partners that bring us inventory. |
| Strong search freshness vs cost | Seconds of staleness, three regional copies | A room can look free for a few seconds after it's taken, and the hold says so. Making search consistent with booking would put every search on the shards, which is where we started in Round 1. |
Back to the opening question: how do we let everyone search freely, while guaranteeing we never sell a room we don't have? The answer is now: search reads copies, and only the owner of a listing sells it. Search is regional, cached and seconds stale, built from each shard's change stream. Each listing has exactly one writer, where a locked, conditional update and a check constraint guard reserved ≤ total + allowance for every night, and holds end safely by the database's clock. Where others also sell the room, we can't guarantee it anymore, so we say so, keep a buffer where it's scarce, and turn each oversell into a detected, handled case. And when a region fails, we fence before we promote, and reconcile what escaped.
R3.8 Failure Modes
| Failure | What you'd see | How the design responds |
|---|---|---|
| A channel manager outage | No messages from one channel manager for 10+ minutes; its hotels' last update ages; pushes queue | Its hotels go into a stale mode: the buffer grows from "1 when 3 or fewer left" to "20% of cm_available, at least 1" (our choice), because others may be selling while we can't hear. Our pushes wait in the outbox. When it's back, the gateway replays pushes in sequence order and pulls a snapshot for every affected hotel before normal buffers return. |
| A pricing bug | Prices for a city drop 90% in one update; or a burst of rates hits floors | Floors and the change limit hold suspicious writes for review instead of publishing them. If a bad price got through, we freeze that pricing source (one configuration change), restore the previous rates from the rates table's history, and list holds and bookings quoted at the bad price. Whether to honor them is a business and legal decision; the system's job is to make the list exact. |
| A region outage | Health checks fail; replication lag alarms; that region's listings stop updating in search | Step 3.6: pause, then a fenced failover if it lasts, then reconciliation of the loss window. |
| A mass cancellation (a hurricane closes a city's hotels for a week) | Tens of thousands of cancellations in hours | First, a stop-sell on the area's listings for the dates (a separate table the hold checks; we don't zero total_rooms, because the check constraint would rightly refuse while bookings exist). Then ops applies a force-majeure policy version to the affected bookings, and the normal cancel path releases nights and queues refunds. The refund worker paces calls to the PSP's rate limits, so refunds take hours, not seconds, and none is lost. Any booking created after the stop-sell timestamp is included in the sweep. |
| Cross-region copy lags | MSK Replicator lag grows; one region's search shows older availability for another region's listings | Search keeps serving (stale). Holds are unaffected: they go to the home region. We alarm at 60 seconds of lag and can route that continent's searches to the home region's stack while it catches up. |
R3.9 Runbook and Incident Response
Golden signals, per region and per shard OPS 8 · REL 6
| Signal | Alarm | Severity | First action |
|---|---|---|---|
Invariant breaches: rows where reserved_rooms > total_rooms + overbook_allowance | any, checked every 5 min | P1 | Stop sales on that listing; this is impossible with the constraint in place, so suspect tampering or a disabled constraint |
| Oversell cases (channel) per 10,000 channel-managed bookings | 2× the 7-day baseline | P2 | Check the channel managers' message lag and gap counts; widen buffers for the affected hotels |
| Hold conflicts: share of holds refused, and P99 lock wait | refusals 2× baseline, or lock wait P99 > 50 ms | P2 | A flash sale without a gate, or a hot listing; check the gate and the shard |
Expiry lag: oldest HOLD past expires_at | > 60 s | P2 | The sweeper is stalled; the lazy release keeps holds correct meanwhile |
Stuck CONFIRMING past its deadline | > 50, or oldest > 15 min | P2 | The PSP may be failing slowly; see the payment loop's runbook |
| Refunds because the hold was already released | > 0 per hour | P2 | Something is charging without the fence |
| Cache staleness: CDC slot lag, and MSK Replicator lag | > 5 min slot lag, > 60 s replicator lag | P2 | Check the connector or replicator; search is stale, booking is fine |
| Search P95 per region | > 200 ms for 10 min | P2 | OpenSearch node CPU and Valkey command rate; check for a node lost to an AZ problem |
| Channel sync lag: age of the last message per channel manager, and sequence gaps | > 10 min, or any gap not repaired in 5 min | P2 | Stale mode for its hotels; request snapshots |
Aurora Global replication lag (AuroraGlobalDBReplicationLag) | > 5 s for 5 min | P2 | A lagging secondary means a bigger loss in a failover, and past 20 s the primary pauses commits |
Flash-sale procedure REL 7
- A week ahead: register the sale (hotel, types, nights, start time in the hotel's zone, converted to UTC once), load-test the gate, and schedule the waiting-room fleet and search capacity to be up 30 minutes before the start.
- 10 minutes before: the pre-allocation job copies free counts into the sale's Valkey keys and turns on "gate required" for that hotel's holds.
- During: watch admitted rate, gate "sold out" rate, token returns and the shard's lock waits. If the shard struggles, lower the admission rate, not the gate's correctness.
- After sell-out: keep the line open for returns for 30 minutes, then close it and turn "gate required" off only when no tokens are outstanding.
Oversell procedure OPS 10
- The case lists the bookings on the oversold night and where each came from.
- The hotel chooses who moves (the default is the most recent booking), within a response time we agree with it.
- We offer that guest a comparable nearby room or a full refund plus compensation, under the written relocation policy, and record the outcome on the booking.
- Afterwards, look at why: sync lag, a missed message, a buffer too small for that hotel. Adjust the hotel's buffer.
Regional failover procedure REL 13
- Confirm it's the region, not us: several services and AWS health signals agree. Pause bookings for its listings (they return
503). - If it will last: flip each shard's writer flag in every reachable region's copy (command 3), then wait 15 seconds.
- Fail over each shard (command 4), then check the new writers take holds.
- Reconcile the loss window: PSP charges and refunds, confirmation codes sent, channel manager snapshots.
- When the old region returns, its clusters rejoin as secondaries. Switching back is a planned switchover (command 5), with the same flag steps.
Go deeper: CLI playbook
Plain commands an on-call engineer runs, one at a time. Replace the names and ARNs with real ones.
text# 1. Alarms currently firing for booking in a region aws cloudwatch describe-alarms --region eu-central-1 --state-value ALARM --alarm-name-prefix booking- # 2. Replication state of one shard's global database aws rds describe-global-clusters --global-cluster-identifier inv-eu-02 # 3. Write shard eu-02's writer flag (epoch 8, eu-west-1) into one region's copy; repeat for each reachable region aws ssm put-parameter --region us-east-1 --name /booking/shards/inv-eu-02/writer --value "8:eu-west-1" --type String --overwrite # 4. Unplanned failover of one shard (may lose the replication lag, at most about 20 s) aws rds failover-global-cluster --global-cluster-identifier inv-eu-02 --target-db-cluster-identifier arn:aws:rds:eu-west-1:111122223333:cluster:inv-eu-02-euw1 --allow-data-loss # 5. Planned switchover back (waits for replication, no data loss) aws rds switchover-global-cluster --global-cluster-identifier inv-eu-02 --target-db-cluster-identifier arn:aws:rds:eu-central-1:111122223333:cluster:inv-eu-02-euc1 # 6. State of a shard's CDC connector aws kafkaconnect describe-connector --region eu-central-1 --connector-arn arn:aws:kafkaconnect:eu-central-1:111122223333:connector/inv-eu-02-cdc/1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d-2 # 7. State of the cross-region copy of Europe's change topics into us-east-1 aws kafka describe-replicator --region us-east-1 --replicator-arn arn:aws:kafka:us-east-1:111122223333:replicator/eu-to-us/1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d-2 # 8. Health of a region's search domain aws opensearch describe-domain-health --region ap-southeast-1 --domain-name listings-search # 9. State of a region's availability cache aws elasticache describe-replication-groups --region eu-central-1 --replication-group-id avail-cache
Audit queries (read-only, run against a shard's replica):
sql-- Should always return nothing: the check constraint makes it impossible. SELECT listing_id, room_type_no, stay_date, total_rooms, overbook_allowance, reserved_rooms FROM room_inventory WHERE reserved_rooms > total_rooms + overbook_allowance; -- Channel-managed nights where our unconfirmed sales exceed what the channel manager says is free. SELECT listing_id, room_type_no, stay_date, cm_available, sold_since_cm FROM room_inventory WHERE cm_available IS NOT NULL AND sold_since_cm > cm_available; -- Holds the sweeper should have released a minute ago, and payments stuck past their deadline. SELECT count(*), min(expires_at) FROM holds WHERE status = 'HOLD' AND expires_at < now() - interval '60 seconds'; SELECT hold_id, pay_attempt, expires_at FROM holds WHERE status = 'CONFIRMING' AND expires_at < now() - interval '5 minutes';
R3.10 Pillar Check
| Pillar | What Round 3 adds |
|---|---|
| Reliability | One writer per listing; search survives a region loss; fenced failover with two independent fences (majority-read writer lease, Aurora's RPO cap); RPO ≤ 20 s, RTO 10–15 min; reconciliation of escaped replies; failover drills each quarter REL 10 · REL 12 · REL 13 |
| Security | Channel managers authenticate per partner and can only write their own hotels; guest personal data stays in the listing's home region and its DR copy; search copies carry no guest data; refunds and price overrides need a second person SEC 3 · SEC 7 |
| Performance Efficiency | Search served from the nearest of three regions; continent-routed indexes; far guests pay one round trip at hold time only PERF 3 · PERF 4 |
| Cost Optimization | ≈ $245K/month at US prices, budget ≈ $267K; search and the edge are 80% of it; Shield Advanced covers the first 50 billion WAF requests; data transfer priced per region at its own tiers; build vs buy argued for channel connectivity COST 5 · COST 8 · COST 11 |
| Operational Excellence | Golden signals with first actions; flash-sale, oversell and regional-failover procedures; overbooking and channel buffers recorded as business trade-offs with owners OPS 1 · OPS 8 · OPS 10 |
| Sustainability | Three home regions chosen by where listings and guests are, not one copy of everything everywhere; old inventory partitions dropped, old bookings in S3; Graviton throughout SUS 1 · SUS 4 |
R3.11 Round 3 Rubric and Follow-Ups
What an architect (L7) answer adds over L6
- Admits the database is no longer the only truth, and designs sync, buffers and oversell handling as one system with measurable outcomes.
- Uses absolute values and per-stream sequence numbers for sync, and gap checks that count only the messages they audit.
- Separates rates from availability, and makes the quote at hold time the only binding price.
- Keeps one model for hotels and homes, turning host rules into checks on rows we lock.
- Puts one writer per listing in a home region, and explains why multi-writer would double-sell.
- Fails over with fences that hold even if the old region is alive, states the RPO and RTO, and reconciles every reply that escaped before replication.
- Treats cancellation policies as versioned data and refunds as a durable saga step, and shows both inventory and money balance on every branch.
Follow-up questions
-
"A channel manager goes silent for an hour. What do you do?" Answer: its hotels go into stale mode after 10 minutes: bigger buffers, because other sites may be selling while we can't hear. Our own sales still happen and queue in the outbox. When it returns, we replay our pushes in order and pull snapshots before relaxing buffers. If it stays down for a long time, the hotel can choose to stop selling its last rooms through us, which is safer than overselling.
-
"Why not DynamoDB global tables for inventory, and let every region take holds?" Answer: global tables check condition expressions against the local copy and, by default, resolve conflicting writes with last-writer-wins, so two regions could each sell the last night, and one booking would be silently overwritten. The multi-Region strong-consistency mode avoids that, but it runs in exactly three Regions and doesn't support transactions, and a multi-night hold is a transaction across several rows. One writer per listing is simpler and correct.
-
"How do you move a listing's home region, say a chain moving its headquarters?" Answer: as a planned migration, not a failover: freeze holds for those listings briefly, copy their inventory, rates, holds and bookings to a shard in the new region, verify counts and versions, flip the directory and the listing's routing, and unfreeze. Existing holds either finish first or are carried over with their
expires_at. Listing IDs that carry a region prefix keep working through an alias in the directory.
Interview gotchas from this round's wrong answers
| Gotcha | Why it's wrong |
|---|---|
| "Our database is the truth" with channel managers | Other sites sell the same rooms and tell us seconds later; plan for oversells. |
| "Channel updates as deltas" | Lost or repeated deltas drift forever; take absolute counts with sequence numbers. |
"Put the channel condition in a CHECK" | The constraint would refuse to record sales that already happened elsewhere. |
| "Charge the live price at payment" | Guests are charged a different number than they agreed to; quote at hold time. |
| "Any region can take any booking" | Two regions sell the same night; last-writer-wins deletes a booking. |
| "Fail over automatically on failed health checks" | A cut-off region may still be selling; fence first, then promote. |
| "48 hours before check-in" | Across a daylight-saving change, that's an hour off; compute the local date and time, then convert. |
Loop Closer: Interview Strategy for All Three Rounds
How to Run Each 60-Minute Round
| Time | Round 1 | Round 2 | Round 3 |
|---|---|---|---|
| 0–5 min | Scoping: types or rooms, window, hold length, payment, cancels, time zones | Restate Round 1 in 60 seconds | Restate Round 2 in 60 seconds |
| 5–15 min | Requirements and API (holds with idempotency keys, half-open date ranges) | Scope raise → what breaks | Scope raise → what breaks |
| 15–40 min | Steps 1.0–1.6: counts per night → atomic claim → lock order → expiring holds → idempotency → confirm | Steps 2.1–2.5: search path via CDC → flash-sale gate → fence first → shard by hotel → overbooking | Steps 3.1–3.6: channel sync → quotes → unique homes → home regions → policies and refunds → region failover |
| 40–50 min | Numbers, cost against bookings, trade-offs | Numbers (row size, cache, fleets), cost, trade-off table | Numbers per region, cost with regional uplift, build vs buy |
| 50–60 min | Failures and pillar check | Failures and pillar check | Failures, runbook, pillar check |
For how to spend a single 45-minute round, see the 45-minute interview blueprint.
The Two Sentences That Matter Most
- Opening a round: "Search can be a few seconds stale; booking can never oversell. So I'll build two paths: search reads copies, and only the database that owns a hotel's calendar can sell a night, with a locked, conditional update and a check constraint."
- When time runs out mid-payment: "The timer only gives rooms back; correctness comes from conditions checked by the database's clock. So I fence the hold before charging, and I never release rooms whose payment might still succeed."
Well-Architected Review Sheet
Interviewers rarely ask "which pillar is this?". They ask the pillar's question in plain words. Rehearse one sentence per row.
| Pillar | Question you'll hear | One-sentence answer | Round | Backed by |
|---|---|---|---|---|
| Reliability | "What if the guest double-clicks?" (REL 4) | The idempotency key is a unique constraint on the hold row, so a retry gets the same hold, and refusals are stored too. | 1 | Step 1.5 |
| "What if 100,000 people arrive at once?" (REL 5) | A waiting room admits 2,000 a second and a gate with pre-allocated counters answers "sold out" in a millisecond, so the database sees about one hold per room, plus returns. | 2 | Step 2.2 | |
| "You know the sale starts next Tuesday. Then what?" (REL 7) | We schedule the waiting-room fleet and search capacity to be up 30 minutes before, and pre-load the gate 10 minutes before. | 2–3 | Step 2.2, R3.9 | |
| "What if an AZ fails?" (REL 11) | Aurora promotes each cluster's replica; 20 of 30 OpenSearch nodes, 8 of 12 Valkey nodes and two-thirds of the fleets carry peak. | 2 | R2.8 | |
| "What if a region fails?" (REL 13) | Search keeps working elsewhere; bookings pause, then fail over behind a majority-read writer lease and Aurora's 20-second RPO cap; we reconcile what escaped. | 3 | Step 3.6 | |
| Security | "Who can change a hotel's inventory?" (SEC 3) | Only the booking service's role and the channel gateway for its own partners' hotels; search can't reach the booking databases. | 2–3 | R2.10, R3.10 |
| "How do you stop bots from hoarding flash-sale rooms?" (SEC 5) | Shield Advanced and WAF at the edge, Bot Control on the booking and sale paths, and signed tickets and gate tokens that expire. | 2 | Step 2.2, R2.6 | |
| "Where does guest data live?" (SEC 7) | With the booking, in the listing's home region and its DR copy; search copies carry no guest data. | 3 | R3.10 | |
| Performance | "Where does search latency go?" (PERF 1) | About 40 ms per OpenSearch round, 4 ms of Valkey, and budget for a second round: 96 ms at P95 on our side. | 2 | R2.6 |
| "Why not search the booking database?" (PERF 3) | Different questions need different stores: geo in OpenSearch, "any room tonight" in bitmaps, the truth in Aurora. | 2 | Step 2.1 | |
| "How is search fast in Sydney?" (PERF 4) | Every region has a full search stack built from every home region's change stream; only holds travel. | 3 | Step 3.4 | |
| Cost | "What does it cost?" (COST 5) | About $660, $49.5K and $267K a month; in each round, far below the business it serves, and mostly spent on search, not booking. | 1–3 | R1.7, R2.6, R3.6 |
| "Why pay for Shield Advanced?" (COST 5) | At 27.6 billion requests a month, per-request WAF costs about $16.6K; Shield Advanced covers it for $3,000 plus a tiered $0.05/GB then $0.04/GB transfer fee, about $8.2K. | 2 | R2.6 | |
| "Why not build your own channel manager?" (COST 11) | That's a different business that competes with partners who bring us inventory; we integrate with a gateway and buy connectivity for the long tail. | 3 | R3.7 | |
| Operations | "How do you know nobody was oversold?" (OPS 8) | A constraint that makes it impossible on our side, an audit that must return nothing, and oversell cases counted per 10,000 channel bookings. | 1–3 | R3.9 |
| "What happens when a guest is oversold?" (OPS 10) | A case opens with the bookings involved; the hotel chooses who moves; the guest is relocated or refunded under a written policy. | 3 | Step 3.1, R3.9 | |
| Sustainability | "Where is the footprint?" (SUS 4) | Three full search copies and years of bookings; past inventory partitions are dropped and old bookings move to S3. | 2–3 | R2.10, R3.10 |
Rubric Across Levels
| Dimension | L5 (Round 1) | L6 (Round 2) | L7 (Round 3) |
|---|---|---|---|
| Inventory model | Counts per room type per hotel-local night | Plus an explicit overbooking allowance; row size and storage derived | One model for hotels and homes; host rules as locked counts; rates split out |
| Never oversell | Locked, conditional update, check constraint, Read Committed explained, fixed lock order | The same, sharded by hotel_id so every transaction stays local | One writer per listing; channel oversells buffered, detected and handled |
| Holds | Expiry by the database clock; sweeper plus lazy release; idempotency keys | Fence first, charge second; capped extensions; release only after cancelling the PSP attempt | Quotes guaranteed for the hold; holds survive failover only if replicated, and escaped replies are reconciled |
| Search | A replica behind a short cache | OpenSearch plus Valkey bitmaps and records, fed by CDC with versions; cold-start and drift handled | Regional stacks from every home region's stream; continent routing |
| Peaks | Sized for a promotion | Waiting room and gate with token returns on every path | Scheduled capacity and runbooks per sale |
| Time and money | Hotel-local dates and cancellation deadlines computed in the zone | Payment integration with per-attempt keys | Versioned policies, refunds as a saga step, both books balanced on every branch, FX in the quote |
| Evolving under new scope | Builds from overlap queries one problem at a time | Opens with "what breaks", fixes the seams first | Changes the architecture's shape (regions, partners) without weakening "only the owner sells" |