Hotel Reservation Failure & Scale Scenarios
How to prevent double booking in a hotel system: ten pressures applied to the design (races, hold-expiry timing, crashes, payment retries, stale search data, a second writer, hot rows and regional failure). Each traces the root cause, the response, how it is detected and the invariant it tests.
The Architecture Under Test and the Scenario Map
Every scenario below starts from the finished architecture of Complete Design and asks one question: which established mechanism absorbs this pressure, and what is left for operations to watch? The table maps each scenario to the stage that introduced its mechanism.
| # | Scenario | Pressure | Mechanism that handles it | Complete Design |
|---|---|---|---|---|
| 01 | Last-room race, partial stays, overlapping ranges | Concurrent claims on one scarce night | Stage 3: ordered conditional per-night allocation + CHECK | Ch.10–11 |
| 02 | Hold expiry vs. confirmation | A worker and a payment act on the same hold | Stage 4 hold-state fence + Stage 6 expiry deferral | Ch.12, Ch.14 |
| 03 | Capture succeeds, confirmation never commits | Crash between the provider and the confirmation transaction | Stage 7 reconciliation | Ch.14–15 |
| 04 | Late webhooks and client retries | Duplicate and out-of-order signals | Stage 5 guarded transitions + Stage 6 idempotent attempts | Ch.13–14 |
| 05 | Cancellation races and refund retries | Several writers on one reservation | Stage 8 guarded cancel + independently retried refund | Ch.16 |
| 06 | Modification saga | A change spanning allocation, payment and release | Stage 8 acquire-before-release saga | Ch.16 |
| 07 | Stale search, rate change, stop-sell | What is shown differs from what is true | Stage 1 staleness contract + Stage 9 restrictions | Ch.09, Ch.17 |
| 08 | Late, replayed and conflicting PMS batches | A second writer to the ledger | Stage 9 per-hotel cursors + guarded capacity | Ch.17 |
| 09 | Hot property; search read-model outage | Row contention; loss of the read path | Stage 10 short transactions + sharding; Stage 1 path separation | Ch.18, Ch.09 |
| 10 | Outbox backlog; primary and region failure | Infrastructure loss under the booking path | Outbox + idempotent consumers; standby; reconciliation | Ch.14–15, Ch.18 |
How each case is told. Pressure → root cause in the architecture → how the system responds (with the stage and chapter that built the mechanism) → detection and recovery → what to monitor and which invariant is being tested. Interleaving tables with t= offsets appear only where the order of events is the lesson.
The running example. Hotel HTL-204 sells a Deluxe room type RT-DLX with 5 rooms. A traveler wants nights 11-14 and 11-15 (₹14,560 including tax). On 11-14, 3 units are free; on 11-15, only 1 is. Short IDs appear only where they make a mechanism concrete: HLD-1/HLD-2 for competing holds, BKA-1 for a booking attempt, PAY-1 for its payment, RES-1 for the reservation, EVT-1 for its outbox event.
The Last-Room Race, Partial Stays and Overlapping Ranges
Several booking requests try to claim the same scarce room-night at nearly the same instant. In the naive design the availability decision is made in application memory, between a read and a write, so two requests can both win (Stage 2, Complete Design Ch.10). The established mechanism is Stage 3 (Ch.11): the Inventory Service allocates every night of a stay in one transaction, with one conditional UPDATE per night issued in ascending stay_date order, backed by CHECK (held + confirmed <= sellable). Three shapes of contention exercise three different parts of that mechanism.
1A — Two requests race for the last Deluxe room-night
Pressure. Request A and Request B ask for the same stay — RT-DLX, nights 11-14 and 11-15 — within a millisecond; both saw "1 Deluxe room left" in search. Root cause. One unit, two legitimate first attempts with different idempotency keys. Only the row lock on (HTL-204, RT-DLX, 2026-11-15) can decide between them.
| Time | Request A | Request B | Committed available (11-14 / 11-15) |
|---|---|---|---|
| t=0 ms | BEGIN; UPDATE 11-14 … WHERE available ≥ 1 → 1 row (row now locked by A) | 3 / 1 | |
| t=1 ms | UPDATE 11-15 … → 1 row | BEGIN; UPDATE 11-14 … → waits for A's lock | 3 / 1 |
| t=3 ms | INSERT hold HLD-1 (ACTIVE, TTL 600 s) + outbox row; COMMIT → 201 | (waiting) | 2 / 0 |
| t=3 ms | 11-14 lock granted; condition re-evaluated on the committed row → 1 row | 2 / 0 | |
| t=4 ms | UPDATE 11-15 … → condition false → 0 rows | 2 / 0 | |
| t=4 ms | ROLLBACK (its 11-14 change is undone too) → 409 naming 2026-11-15 | 2 / 0 |
How the system responds
Each conditional UPDATE takes the row lock and evaluates sellable - held - confirmed >= 1 against the latest committed row, so this works at ordinary READ COMMITTED. Because both transactions lock in the same ascending order, B queues behind A on the first shared row instead of interleaving. The application requires summed rows_affected to equal the number of nights; B's 0 on 11-15 rolls back its whole transaction. The loser gets a fast 409 INVENTORY_UNAVAILABLE naming the night, and the client offers an alternative such as RT-STD for the same dates. Idempotency keys play no part here: they stop the same request applying twice, not two different requests competing (Stage 3, Complete Design Ch.11).
Detection and recovery
Nothing to repair — this is the designed outcome. The client re-quotes an alternative rather than retrying the same night, because a retry against a night that is gone is wasted work.
What to monitor · invariant tested
409 rate and lock-wait time per (hotel, room type, night). Invariant: held + confirmed never exceeds sellable on any night; the CHECK constraint makes a violating commit impossible even for a buggy code path.
1B — One unavailable night inside a longer stay
Pressure. Right after HLD-1 commits, Request C asks for three nights, 11-13 to 11-15. The projection has not caught up and still shows the stay as available. Root cause. The interior night 11-15 now has 0 units while 11-13 and 11-14 have plenty. A design that allocates nights independently would commit two nights and fail the third, leaving a partial stay nobody asked for (Ch.10).
How the system responds
The same transaction updates 11-13 (1 row), 11-14 (1 row) and 11-15 (0 rows). Summed rows_affected is 2 of 3, so the whole transaction rolls back — including the two nights that matched — and the 409 names 2026-11-15. The mechanism does not depend on stay length: an N-night stay is N ordered statements and one all-or-nothing check (Ch.11).
Detection and recovery
Designed behaviour. The client offers explicit alternatives — shifted dates, or a different room type for the blocked night as a separate, traveler-chosen booking — never a silent partial commit.
What to monitor · invariant tested
The bug class to test for is checking each statement's success on its own instead of the sum. Invariant: a multi-night stay is allocated all nights or none.
1C — Overlapping, non-identical ranges and lock order
Pressure. Two transactions need the same two rows, 11-14 and 11-15, but one of them touches the nights in a different order — a code path that iterates nights from a set, or a single UPDATE … WHERE stay_date IN (…), which promises no lock order. Assume 11-15 again has one free unit. Root cause. Two transactions acquiring the same two row locks in opposite orders can each hold one lock and wait for the other.
| Step | Locks taken in request order | Locks taken in ascending stay_date order |
|---|---|---|
| 1 | Txn X locks 11-14; Txn Y locks 11-15 | Txn X locks 11-14; Txn Y requests 11-14 and waits, holding nothing X needs |
| 2 | X requests 11-15 and waits for Y; Y requests 11-14 and waits for X | X locks 11-15, takes the last unit, commits |
| 3 | Deadlock: the database aborts one transaction, which must retry | Y gets 11-14 (1 row), then 11-15 → 0 rows → rolls back → 409 naming 11-15 |
How the system responds
Every path that touches more than one night — hold, confirmation, expiry, cancellation, modification — issues one UPDATE per night sorted by stay_date (Ch.11). All transactions therefore acquire shared rows in the same global order, so a cycle cannot form: the later transaction holds nothing the earlier one needs, waits on the first shared row, then evaluates its condition against the committed result. Sorting also lets the application name the blocking night in the 409.
Detection and recovery
With the rule in place there is nothing to recover. Without it, the database's deadlock detector aborts one transaction; the system stays correct, but the abort, retry and extra latency cluster on exactly the popular nights where the row lock is already contended.
What to monitor · invariant tested
Deadlock count should be zero; a non-zero value means some path (a new admin tool, a modification swap) issues unordered or set-based updates. Invariant: no oversell and no deadlock, however the overlapping ranges relate.
Hold Expiry Races Confirmation
A hold's 600-second TTL is enforced by an Expiry Worker — a separate process — so its sweep and a payment confirmation can reach the same hold in the same second. Two actors want to make the final decision about one claim. The established mechanism (Stage 4, Complete Design Ch.12) is a compare-and-set on the hold row's own state: confirmation flips ACTIVE → CONSUMED, expiry flips ACTIVE → EXPIRED, and only the actor whose flip matched 1 row touches the room-night counters. Stage 6 (Ch.14) adds a deferral: the worker skips any hold whose booking attempt is PENDING_PAYMENT and younger than auth_timeout = 60 s.
2A — Confirmation commits just before the TTL
Pressure. The traveler holding HLD-1 completes payment at t = 598 s. Root cause. The worker's next sweep for holds past expires_at will find HLD-1 within seconds, and it does not know a confirmation just happened.
| Time | Confirmation path | Expiry Worker | HLD-1 |
|---|---|---|---|
| t=598 s | Capture succeeds; T3 runs UPDATE inventory_hold SET state='CONSUMED' WHERE hold_id='HLD-1' AND state='ACTIVE' → 1 row; both nights move held → confirmed; RES-1 inserted; COMMIT | CONSUMED | |
| t=600 s | TTL elapses | CONSUMED | |
| t=601 s | UPDATE … SET state='EXPIRED' WHERE hold_id='HLD-1' AND state='ACTIVE' → 0 rows; ROLLBACK, nothing released | CONSUMED |
How the system responds
The confirmation's first statement claimed the hold, so the worker's identical guard finds nothing to do. Earlier in the window the deferral would have skipped HLD-1 anyway while its attempt was in flight; the fence is the backstop that makes the outcome safe regardless of timing. A version column on the shared inventory row would be the wrong fence: every unrelated hold or booking on 11-15 changes it, turning normal traffic into false conflicts (Ch.12).
Detection and recovery
Nothing to recover. The worker's 0-row result is logged as a fence miss — expected, healthy noise.
What to monitor · invariant tested
Fence-miss rate per worker run. Invariant: expiry never releases inventory that now belongs to a reservation.
2B — Expiry wins, then a late payment success arrives
Pressure. The traveler submits payment late in the hold: attempt BKA-1 starts at t = 530 s and the provider is slow. Root cause. The deferral protects an attempt only for 60 s. At t = 590 s the orchestrator stops waiting and marks BKA-1 failed; from then on HLD-1 is an ordinary hold whose TTL is running out — but the provider may still complete the operation it received.
| Time | Event | HLD-1 | 11-15 unit |
|---|---|---|---|
| t=530 s | BKA-1 inserted (PENDING_PAYMENT); authorize sent with BKA-1 as the provider idempotency reference | ACTIVE (expiry deferred) | held by HLD-1 |
| t=590 s | auth_timeout: the orchestrator abandons the call and marks BKA-1 FAILED | ACTIVE, no longer deferred | held by HLD-1 |
| t=600 s | Expiry Worker: ACTIVE → EXPIRED (1 row); held − 1 on both nights in ascending order | EXPIRED | free |
| t=602 s | Request B: new hold HLD-2 takes the unit | EXPIRED | held by HLD-2 |
| t=605 s | Provider reports success for BKA-1 (late reply or webhook) | EXPIRED | held by HLD-2 |
| t=605 s | Confirmation fence WHERE hold_id='HLD-1' AND state='ACTIVE' → 0 rows: no reservation; reconciliation refunds | EXPIRED | held by HLD-2 |
How the system responds
The fence protects HLD-2: a stale success can never turn someone else's unit into this traveler's reservation. Money that moved is returned through Stage 7's branch (Ch.15): the attempt moves to PAYMENT_CAPTURED_BOOKING_FAILED → RECONCILING, the confirmation's fence matches 0 rows, and the payment is refunded (or the authorization voided if nothing was captured), ending REFUNDED. A product may first try a fresh conditional allocation of the same nights before refunding — a policy choice that still goes through the normal allocation, never around the fence.
Detection and recovery
The late success is caught by the webhook handler or by the reconciliation sweep that compares provider records with attempts already marked failed. The traveler is told the payment was refunded and is offered a fresh search.
What to monitor · invariant tested
Late-success-after-expiry count and refund latency; provider p99 authorization time against the 60 s auth_timeout. A longer TTL changes how often this happens, never whether it is safe. Invariant: a stale operation never resurrects an expired hold or steals a newer one; payment success alone never creates a booking.
Capture Succeeds, Confirmation Never Commits
The provider captures the payment, then the orchestrator crashes — or loses the reply — before the confirmation transaction (T3) commits. Money has moved in a system the platform does not control, and no distributed transaction spans the provider and the Booking DB. The established mechanism (Stages 6–7, Complete Design Ch.14–15): the booking attempt is written durably (T2) before any provider call, provider calls carry the attempt id as their idempotency reference, and a Reconciliation Worker resolves attempts stuck in PENDING_PAYMENT to CONFIRMED or REFUNDED.
3A — Crash between capture and confirmation; the hold is still active
Pressure. The orchestrator has inserted BKA-1 (key 3f9c2a71-book) for HLD-1 and calls the Payment Service. Root cause. The process dies after the provider captures ₹14,560 but before T3 starts. From inside the database this looks exactly like a slow payment: attempt PENDING_PAYMENT, hold ACTIVE, no reservation. Guessing either way is wrong — a new authorization can charge twice; writing a confirmation without the fence can oversell.
| Time | Event | Booking DB shows |
|---|---|---|
| t=0 s | T2: BKA-1 inserted, PENDING_PAYMENT | attempt pending, hold ACTIVE |
| t=0.4 s | Provider authorizes PAY-1 (reference BKA-1) | unchanged |
| t=0.9 s | Provider captures PAY-1 and replies | unchanged |
| t=0.91 s | Orchestrator crashes before T3 | no reservation |
| t=0–60 s | Expiry Worker defers HLD-1 (attempt younger than auth_timeout); its TTL has not elapsed anyway | hold ACTIVE |
| t≈2 min | Reconciliation sweep finds BKA-1 past its threshold; queries the provider by BKA-1 → CAPTURED | attempt → PAYMENT_CAPTURED_BOOKING_FAILED → RECONCILING |
| t≈2 min | Worker runs the normal T3: fence ACTIVE → CONSUMED matches, held → confirmed, RES-1, EVT-1 | CONFIRMED; the notification follows from the outbox |
How the system responds
Forward recovery with the same T3 the online path uses. Nothing is charged again: the worker queries the provider, it does not call authorize or capture again, and any retried call reuses BKA-1's reference. A traveler retrying with the same key meanwhile gets 202 PENDING_PAYMENT, then the final result (Ch.15).
Detection and recovery
Detected by the PENDING_PAYMENT sweep a few minutes after auth_timeout, not by a support ticket. The traveler sees the confirmation about two minutes late — degraded, but correct.
What to monitor · invariant tested
Count and age of the oldest PENDING_PAYMENT attempt; reconciliation outcomes by type. Invariant: capture and confirmation converge — every captured attempt ends CONFIRMED or REFUNDED.
3B — Reconciliation runs after the hold has expired
Pressure. Same crash, but the reconciliation sweep is backlogged (or the attempt began late in the hold), and HLD-1's TTL passes first. The attempt is older than 60 s, so the Expiry Worker no longer defers it: it expires HLD-1, and Request B's hold HLD-2 takes the 11-15 unit. Root cause. Confirming now would hand one unit to two travelers.
How the system responds
The worker marks the attempt PAYMENT_CAPTURED_BOOKING_FAILED → RECONCILING and runs T3; the fence on HLD-1 matches 0 rows, so the transaction rolls back. The worker refunds PAY-1 through the Payment Service with a stable refund reference, and the attempt ends REFUNDED. Policy may first try a fresh conditional allocation of the same nights; if that fails, refund. It never oversells to honour a payment (Ch.15).
Detection and recovery
The same sweep detects it; a slower comparison against the provider's settlement export also catches captures whose attempt was already marked failed. The traveler is told the payment was refunded and is offered alternatives.
What to monitor · invariant tested
Refunds caused by reconciliation, by cause; sweep lag. A growing backlog turns 3A outcomes into 3B outcomes, so sweep lag is the metric to alert on. Invariant: payment success is never treated as booking success.
Late Payment Signals, Duplicate Webhooks and Client Retries
Payment providers deliver webhooks at least once and not always in order, and clients retry after timeouts. The same intent arrives more than once, and some signals arrive after the state they describe has moved on. The established mechanisms: every state change is a guarded compare-and-set on the expected state (Stage 5, Complete Design Ch.13), and booking attempts are idempotent — unique on (principal_id, endpoint, idempotency_key), a request fingerprint hash(hold_id, amount, payment_method_token), and at most one PENDING_PAYMENT attempt per hold (Stage 6, Ch.14).
4A — A payment.failed webhook arrives after the reservation is confirmed
Pressure. RES-1 was confirmed after PAY-1's capture succeeded. Forty minutes later a payment.failed webhook for PAY-1 arrives — an out-of-order delivery of an earlier event, or a genuine reversal such as a fraud-review decision. Root cause. Because capture precedes confirmation, a reservation is never confirmed unpaid; this signal contradicts a decision made on correct information.
How the system responds
The handler verifies the webhook, deduplicates it by the provider's event id and asks the provider for the payment's current status rather than trusting the payload. The state machine has no automatic transition out of CONFIRMED driven by a payment event, so inventory is untouched. If the provider shows the charge is intact, the event is recorded and closed. If money really was reversed, the case goes to a payment-exception workflow whose outcome — collect payment again, or cancel through the normal guarded cancellation that releases the nights — is a policy decision, not a side effect of a webhook (Ch.13).
Detection and recovery
Detected when the handler finds no valid transition; the case lands in the exception queue with the provider's status attached.
What to monitor · invariant tested
Exception-queue depth and age; webhooks rejected as stale. Invariant: payment and booking state converge only through explicit, guarded, auditable transitions.
4B — The client retries after a lost response
Pressure. POST /v1/bookings with key 3f9c2a71-book succeeds on the server, but the mobile connection drops before the 201 arrives; the app resends the identical request three seconds later. Root cause. Without a durable record of the first request, the retry looks like a new booking and would authorize again.
How the system responds
The orchestrator inserts the attempt first and lets the unique constraint decide, so two retries landing on two servers in the same millisecond cannot both win. On the violation it loads BKA-1: same fingerprint and COMPLETED → the original 201 with RES-1; same fingerprint and still PENDING_PAYMENT → 202 Accepted without a new provider call. Provider calls carry BKA-1 as their idempotency reference, so even a retried authorize resolves to the same charge (Ch.14).
Detection and recovery
Logged as an idempotent replay, not an error. Nothing to recover.
What to monitor · invariant tested
Replay rate by client version — a spike usually means a client timeout set too low. Invariant: repeated create requests are idempotent.
4C — A retry with a new key, or the old key with a different body
Pressure. A buggy client generates a fresh Idempotency-Key on every retry; another reuses 3f9c2a71-book for a different hold or amount. Root cause. A unique key alone cannot recognise the first case, and blindly returning the stored result would be wrong in the second.
How the system responds
A new key for the same hold collides with the partial unique index (one PENDING_PAYMENT attempt per hold), so a second payment cannot start while BKA-1 is in flight; once BKA-1 completes, the hold is CONSUMED and is no longer payable. A reused key with a different body fails the fingerprint comparison and gets 409 IDEMPOTENCY_KEY_CONFLICT, never the earlier, unrelated result. Keys are scoped per principal and endpoint, so two travelers' keys cannot collide.
Detection and recovery
Counted per client as fingerprint conflicts and partial-index violations; these are client bugs to fix, not traffic to absorb.
What to monitor · invariant tested
Invariant: at most one payment per hold, and a key always maps to exactly one request.
Cancellation Races and Refund Retries
Over its lifetime a reservation has several writers — the traveler, workers, replayed messages — and refunds depend on an external provider that can fail. The established mechanism (Stage 8, Complete Design Ch.16): cancellation is a guarded transition WHERE state = 'CONFIRMED' AND version = :v, committed together with confirmed − 1 on each night (ascending order) and a refund.requested outbox event; the refund is its own record, retried idempotently and independently of the inventory release.
5A — A stale replay or a concurrent change after cancellation
Pressure. The traveler cancels RES-1 (key 3f9c2a71-cancel). A second later, a redelivered internal confirmation command for BKA-1 arrives, and a modification request read at version 1 is still in flight. Root cause. Each of these was valid when it was issued.
How the system responds
The cancellation committed CONFIRMED → CANCELLED with the per-night releases and the refund event. The replayed confirmation fails its first statement — HLD-1 is CONSUMED, not ACTIVE — and stops; it can neither resurrect the reservation nor move counters. The modification's guarded update expects state = 'CONFIRMED' AND version = 1 and matches 0 rows, so its saga compensates by releasing the night it had held. A second cancel with the same key returns the original result; with a new key its guarded update matches 0 rows and reports the current state, so nights are never released twice (Ch.16).
Detection and recovery
Rejected transitions are logged as stale, not shown to the traveler as errors.
What to monitor · invariant tested
Stale-transition counts by source — a sudden rise points at a misbehaving queue. Invariant: a cancellation and a stale replay can never both apply; cancel-after-modify works because a modified reservation stays CONFIRMED.
5B — The refund fails on its first attempt
Pressure. The refund for the cancelled RP-FLEX booking (₹14,560) hits a transient 5xx at the provider. Root cause. A refund is money movement with the same double-application risk as a charge.
How the system responds
The refund record carries a stable reference derived from the cancellation. A transient error schedules a retry with exponential backoff, always with the same reference, so the provider cannot apply it twice; after a bounded number of attempts it escalates to manual review. The nights were released when the cancellation committed and do not wait for the refund, and a failing refund never reopens the cancellation. Under RP-SAVER the cancellation is identical, but the refund resolves to zero and no provider call is made.
Detection and recovery
Refund age and retry count surface a stuck refund before it becomes a support case.
What to monitor · invariant tested
Oldest pending refund. Invariant: inventory release and refund are independent operations, each idempotent.
Modification as a Saga: Acquire Before Release
A traveler moves RES-1 from nights 11-14/11-15 to 11-15/11-16: drop 11-14, keep the scarce 11-15, add 11-16 — while other travelers keep booking. The change spans an allocation, possibly a payment and a release, and cannot be one transaction because a provider call may sit in the middle. The established mechanism (Stage 8, Complete Design Ch.16) is a saga that acquires before it releases: hold the added night as HLD-3, settle the price difference, then swap the nights in one transaction that keeps the reservation CONFIRMED with version + 1 and records MOD-1.
6A — The successful saga
| Step | Action | If this step fails |
|---|---|---|
| 1 | Hold 11-16 as HLD-3 through the normal conditional allocation, with restrictions checked | Nothing changed; the reservation is untouched |
| 2 | If the new stay costs more, capture the difference (provider reference derived from the modification) | Release HLD-3 (compensation) |
| 3 | One transaction: reservation guarded on CONFIRMED + version 1 → version 2; HLD-3 ACTIVE → CONSUMED; 11-14 confirmed − 1; 11-16 held − 1, confirmed + 1 (ascending order); MOD-1; outbox event | Roll back; release HLD-3 and refund or void the step-2 charge |
| 4 | If the new stay costs less, refund the difference — only now, after the swap committed | Retried idempotently like any refund |
In the running example the 11-16 rate equals the 11-14 rate, so the price delta is ₹0 and steps 2 and 4 are no-ops. RES-1 stays CONFIRMED with version 2, so it can still be cancelled or modified again. Capturing an increase before the swap means no one gets an extra night unpaid; refunding a decrease after the swap means a failed saga never pays out for a change that did not happen.
6B — Failure branch: the added night is gone
Pressure. Suppose 11-16 had sold out, or a concurrent cancellation landed during step 2. Root cause. The new stay is unavailable, but the traveler still owns the old one.
How the system responds
Step 1 returns 409 naming 2026-11-16; steps 2–3 never run and 11-14 was never released, so the traveler keeps the original stay exactly as it was. A new range that touches the stop-sold 11-20 or breaks a minimum stay fails the same way with 422 RESTRICTION_VIOLATED: a modification is validated like a new booking. If HLD-3 expires during a slow step 2, or the reservation's version moved, step 3's guards match 0 rows and the saga compensates (Ch.16).
Detection and recovery
Each saga step is recorded, so a crashed saga is resumed or compensated by a worker; an HLD-3 left ACTIVE after its saga ended is simply expired by the Expiry Worker.
What to monitor · invariant tested
Modification outcomes by failing step; compensations executed. Invariant: a modification ends in either the old stay or the new one, never in between.
Stale Search, Rate Changes and a Stop-Sell Mid-Booking
What a traveler sees and what is true diverge in three ways: the search projection lags, rates change, and hotels close dates. Search reads an outbox-fed projection that may lag about 60 seconds by design (Stage 1, Complete Design Ch.09), and hotels write rates and restrictions independently of travelers (Stage 9, Ch.17). The established mechanism: the booking path never decides from the projection; the hold transaction re-checks the authoritative room-night rows and the Restriction rows.
7A — Stale positive: search shows a unit that is gone
Pressure. HLD-1 took the last 11-15 unit 50 seconds ago; search still shows available = 1 with cache_freshness_seconds: 55. A new traveler quotes and tries to hold. Root cause. Projection lag, within the ~60 s contract.
How the system responds
The hold's conditional UPDATE on 11-15 matches 0 rows → 409 INVENTORY_UNAVAILABLE naming the night. Within seconds the projector applies the hold.created event and later searches show the night as sold out (Ch.09).
Detection and recovery
This is the contract, not an incident; the response states its own staleness.
What to monitor · invariant tested
Projection lag p99 against the 60 s SLO, and the 409 rate on holds that follow an "available" search — both rise together when the projection falls behind. Invariant: a search result is never a guarantee; only a committed hold or confirmation is.
7B — Stale negative and a rate change inside a quote
Pressure. Two quieter divergences. If HLD-1 had just expired, the ledger has a free unit but search still says sold out. Separately, the hotel edits the RT-DLX/RP-FLEX rate while a traveler's quote QTE-1 is inside its 10-minute window. Root cause. The projection updates only after the hold.expired event is relayed; and quotes are priced snapshots.
How the system responds
The stale negative is a missed sale bounded by the lag and corrects itself when the event is projected — revenue, not correctness. For the rate change, this design honours the quote until it expires: the hold records QTE-1, so the traveler pays the price shown. After expiry a booking against it gets 410 QUOTE_EXPIRED and re-quotes at the new price_version. Rates live in DailyRate, so the edit never touches an inventory row (Ch.17).
Detection and recovery
Both self-correct; nothing to repair.
What to monitor · invariant tested
Lag as above; quote expiries and re-quotes. Invariant: a quote is a time-boxed price promise, not an inventory claim.
7C — A stop-sell lands while a booking is in progress
Pressure. A traveler is checking out RT-DLX for nights 11-19 and 11-20 when the hotel's revenue manager stop-sells 11-20 for renovation; the traveler's quote is still valid. Root cause. A business rule changed between quote and hold, and the counts on 11-20 still show free units.
How the system responds
The stop-sell inserted a Restriction row; it did not set sellable = 0 or touch held or confirmed. The hold transaction reads restrictions for every night of the stay and returns 422 RESTRICTION_VIOLATED naming 11-20; the client offers other dates. Existing reservations covering 11-20 are untouched, and a hold granted before the stop-sell is not revoked — restrictions gate new holds. Modelled as sellable = 0, the stop-sell would be rejected by the CHECK whenever units were already held or sold, overwritten by the next PMS capacity sync, and stripped of its reason (Ch.17).
Detection and recovery
Designed behaviour; the 422 carries the reason, so the traveler is not left with a generic failure.
What to monitor · invariant tested
422 rate by restriction type. Invariant: a stop-sell blocks new sales immediately and never breaks a settled reservation.
PMS Sync: Late, Replayed and Conflicting Updates
The hotel's property-management system (PMS) also sells through other channels and pushes capacity, rate and restriction batches to the platform's PMS Connector. Batches arrive late, twice or out of order, and sometimes disagree with rooms the platform already sold: the platform is no longer the only writer of its ledger. The established mechanism (Stage 9, Complete Design Ch.17): batches carry a monotonic per-hotel cursor and apply only if newer, in one transaction that also advances the cursor; capacity changes are guarded (new sellable ≥ held + confirmed); and the platform ledger stays authoritative for bookings made on the platform.
8A — A late batch, then a replay
Pressure. With cursor 884 applied, a redelivered batch carrying cursor 883 arrives; then batch 885 arrives, and is delivered a second time. Root cause. The PMS and its transport retry and reorder independently of the platform.
How the system responds
883 ≤ 884: rejected as stale and never written. 885 > 884: applied as InventoryAdjustment and restriction changes, and the cursor advances to 885 in the same transaction. The duplicate 885 is ≤ 885 and is a no-op. Every applied batch also writes outbox events, so the projection follows (Ch.17).
Detection and recovery
Nothing to recover; ordering is enforced on arrival.
What to monitor · invariant tested
Rejected-stale and replay counts per hotel, and time since the last applied cursor — a silent PMS is its own incident. Invariant: PMS updates apply only in cursor order; an old batch never overwrites newer state.
8B — A PMS update conflicts with rooms the platform already sold
Pressure. On 11-15 the platform has confirmed = 4 and HLD-2 holds 1, against sellable = 5. The hotel sold a room on another channel, and the PMS now reports a capacity of 4. Root cause. Two systems sold the same physical room; the ledger cannot represent "4 sellable, 5 claimed".
How the system responds
The connector translates the PMS figure into an adjustment of sellable — never an overwrite of held or confirmed. Because 4 < held + confirmed = 5, the adjustment is not applied (the CHECK would reject it anyway) and is recorded as a sync conflict for operations; the rest of the batch applies and the cursor advances, so later batches are not blocked. Platform reservations are never cancelled by a PMS report. Resolution — relocating a guest, or the hotel freeing a room — belongs to the hotel under its own overbooking practice. The platform itself does not overbook; if it should, that is an explicit, modelled allowance, not a side effect of sync.
Detection and recovery
Detected at apply time by the capacity guard; surfaced as an open conflict with both views attached.
What to monitor · invariant tested
Open sync conflicts per hotel and their age. Invariant: an external update never shrinks sellable below held + confirmed. The cross-channel oversell window is bounded by sync latency, not eliminated — it sits outside the platform's ledger guarantee.
8C — A crashed onboarding import
Before a hotel takes its first booking, its room types, rate plans and an initial 400-day window of inventory and rates are imported as one idempotent, resumable batch keyed by an onboarding cursor (Ch.17). If the import crashes after 180 days, a retry skips what already committed and resumes, instead of duplicating room-night rows or losing its place. Invariant: bulk publication converges to the same state whether it runs once or is resumed after a crash.
A Hot Property and a Search Read-Model Outage
Two incidents that look alike on a dashboard — traffic is up, something is slow — with opposite blast radii. One is write contention on a single room-night row, handled by Stage 10 (Complete Design Ch.18); the other is a read-path outage that must never reach the booking path, which is why Stage 1 separated the paths (Ch.09).
9A — A conference puts 50–200 attempts on one room-night
Pressure. A city-wide conference opens registration; within seconds 50–200 hold attempts target (HTL-204, RT-DLX, 2026-11-15), while platform-wide booking traffic stays near its ~45 attempts/s. Root cause. One row, one lock: every multi-night request that covers 11-15 serializes on it. With a 2–5 ms critical section the row handles roughly 200–500 allocations per second — enough only if no transaction ever holds the lock across a network call. More application servers or more shards add no capacity to one row.
How the system responds
Ascending lock order turns the burst into a queue rather than deadlocks. The first transaction whose condition holds takes the last unit; the rest match 0 rows and get a fast 409 naming 11-15 — the correct outcome of genuine scarcity. T1 and T3 stay a few milliseconds with no provider call inside. Admission control per (hotel, room type) — a short queue or lock in front of the database, or a waiting room for flash sales — sheds doomed attempts before they reach the row; it is an optimization that can be removed without affecting correctness. Sharding by hotel_id keeps HTL-204's lock queues and connections away from hotels on other shards; it does not split the hot row (Ch.18).
Detection and recovery
Lock wait and 409 rate spike for one key while aggregate QPS looks unremarkable. Nothing to repair: once the night sells out, the projection shows it within its lag and attempts stop.
What to monitor · invariant tested
Lock-wait p99 per hot key, allocation transaction duration (must stay in milliseconds), connection-pool saturation per shard — tracked per (hotel, room type, night), not only in aggregate. Invariant: no oversell and no deadlock under row-concentrated contention.
9B — The search read model goes down; the Booking DB is healthy
Pressure. The Availability Read Model (search index + cache) becomes unavailable. Root cause. Peak search is ~1,800 QPS with ~5,400 availability reads/s — load the booking primary was never sized for. A naive fallback that queries the primary on a miss would turn a search outage into a booking outage.
How the system responds
The Search Service fails fast and degrades — fewer results, an "availability approximate" banner, or a search error — and never falls through to the Booking DB primary. Holds and bookings never read the projection, so they continue at normal latency. When the read model returns, the projector resumes from its last relay offset and replays the backlog; a full rebuild from the ledger is a last resort after total data loss, not routine (Ch.09).
Detection and recovery
Read-model health checks and search error rate. Any rise in read load on the booking primary means degradation is not engaging.
What to monitor · invariant tested
Search availability and booking latency side by side: the second must not move. Invariant: a search outage never degrades booking correctness or latency, because the booking path never reads the projection.
Outbox Backlog, Primary Failure and Region Failure
Infrastructure fails underneath the booking path: the event pipeline slows down or chokes on a bad event, and a database primary — or a whole region — is lost. The established mechanisms are the transactional outbox with deduplicating consumers (Stages 1 and 6, Complete Design Ch.09 and Ch.14), an in-region synchronous standby per shard with asynchronous cross-region replication (Stage 10, Ch.18), and reconciliation against payment and PMS records (Stage 7, Ch.15).
10A — Outbox backlog and a poison event
Pressure. The event broker slows down and the relay's unpublished backlog grows from near zero to thousands of rows; separately, one malformed adjustment event fails deserialization on every delivery. Root cause. Delivery depends on infrastructure outside the confirmation transaction.
How the system responds
Confirmed reservations are unaffected: RES-1 and EVT-1 committed together, so the event is delayed, never lost. What degrades is downstream: projection lag exceeds 60 s (more stale positives and negatives, Scenario 07), and confirmation emails and refund requests go out late. Consumers deduplicate by event_id, so the eventual drain produces no duplicate emails or refunds. The poison event is retried a bounded number of times and then dead-lettered so it cannot block the stream; the projection entry it would have updated stays stale until the next event for that row or the projector's drift check against the ledger repairs it.
Detection and recovery
Outbox lag (count and age of unpublished rows) and dead-letter depth alert first. The backlog drains by itself once the broker recovers; the dead-lettered event is fixed at its source (here, the PMS Connector's payload) and replayed.
What to monitor · invariant tested
Outbox age, projection lag, dead-letter depth. Invariant: a reservation's durability never depends on how quickly its event is published.
10B — Primary failure, then regional failure
Pressure. The primary of HTL-204's shard fails — first as a single database failure, then as the loss of the whole region. Root cause. The ledger's single writer disappears with transactions in flight.
In-region failover
The shard's synchronous standby is promoted: committed holds and reservations survive (RPO ≈ 0) and the booking path is back within minutes (RTO). The old primary is fenced so it can never accept writes again; during promotion the booking path fails closed with 503 rather than writing anywhere else. A request in flight either committed — and was replicated synchronously — or did not; the client's retry with the same key finds the existing attempt or starts it fresh. Search keeps serving from the projection throughout (Ch.18).
Cross-region failover
Replication to the other region is asynchronous, so promoting it can lose the last seconds of commits (RPO > 0): a hold, an attempt or even a confirmation may be missing, and its nights look free in the promoted ledger and can be sold again. A retry that references a lost hold gets 404 or 410 and must re-hold. The provider still has every capture, keyed by attempt reference, and the PMS has the hotel's view. After promotion, reconciliation compares provider captures and PMS records with the surviving ledger: a capture with no reservation is re-confirmed if its nights are still free, otherwise refunded, with the hotel handling any guest who arrives with a lost confirmation.
Detection and recovery
Health checks and promotion alerts; replication lag, because cross-region lag is the RPO you will actually get; then an audit of the incident window against the provider's settlement export, confirming every captured payment has exactly one CONFIRMED or REFUNDED outcome.
What to monitor · invariant tested
Cross-region replication lag, reconciliation backlog after failover. Invariant: the ledger cannot be oversold while it has a single writer and the CHECK holds — for the ledger that survives. Across an asynchronous regional failover that is not absolute; the guarantee becomes "every captured payment converges to CONFIRMED or REFUNDED after reconciliation". Active-active writes to the same hotel's inventory from two regions are outside this design.
- Every scenario resolves through a handful of mechanisms built once: ordered conditional per-night updates with a CHECK backstop, a compare-and-set on the hold row's state, idempotent booking attempts (scoped key, fingerprint, one pending attempt per hold), guarded state transitions, the transactional outbox with deduplicating consumers, and reconciliation against provider and PMS records
- Timers — the 600 s hold TTL, the 60 s auth_timeout, the ~60 s projection lag — change how often a race happens, never whether it is safe; the fence or the authoritative re-check decides
- Search-path failures cost display quality and sales; booking-path failures threaten correctness. Keep them apart: never fall back from the projection to the booking primary
- Sharding by hotel_id contains a hot property's blast radius; it does not parallelize one hot row, which serializes by nature — keep its transactions in milliseconds
- Payment success is not booking success: every captured payment ends CONFIRMED or REFUNDED, and reconciliation never oversells to honour a payment
- Guarantees are scoped: the platform ledger cannot be oversold while the invariant and CHECK hold; cross-channel PMS sales and asynchronous cross-region failover are separate boundaries handled by reconciliation