Hotel Reservation Architecture Deep Dive
Hotel reservation system architecture, mechanism by mechanism: per-night inventory rows and ordered conditional updates, PostgreSQL locking, fencing, unique indexes, outbox delivery, payment sagas and workers. Worked on one 2-night Deluxe stay where a single unit is left on one of the nights.
Room-Night Inventory: Counters, a Ledger, or Both
The invariant is defined per room-night (Complete Design, Chapter 06), so the first mechanical decision is how a room-night is stored: as a mutable counter row or as an append-only ledger of allocations. Every later section assumes the counter row; this one shows what that choice buys and how the design still keeps a ledger-quality audit trail.
Counter model (this design)
One room_night_inventory row per (hotel_id, room_type_id, stay_date) with sellable, held and confirmed, plus CHECK (held + confirmed <= sellable). Availability is sellable - held - confirmed: an O(1) read of one row. A single conditional UPDATE is both the check and the write (Deep Dive 02-03). The primary key is also the lock key and the range index for a stay: nights [check_in, check_out) are exactly check_out - check_in adjacent rows; the checkout date is never a night.
Ledger model (event-sourced alternative)
Every allocation is appended as an immutable row with a delta, and availability is a sum. Appends do not conflict, which is the problem: two concurrent appends can both pass a sum check, so the ledger still needs one lockable row per room-night.
BEGIN;
-- an aggregate cannot be locked, and locking existing event rows does not stop a concurrent INSERT,
-- so a per-room-night head row is the serialization point
SELECT sellable FROM room_night_head
WHERE hotel_id = 'HTL-204' AND room_type_id = 'RT-DLX' AND stay_date = '2026-11-15'
FOR UPDATE;
SELECT COALESCE(SUM(delta), 0) AS used FROM inventory_allocation_event
WHERE hotel_id = 'HTL-204' AND room_type_id = 'RT-DLX' AND stay_date = '2026-11-15';
-- application: sellable - used >= 1, else ROLLBACK
INSERT INTO inventory_allocation_event (hotel_id, room_type_id, stay_date, event_type, delta, actor_ref)
VALUES ('HTL-204', 'RT-DLX', '2026-11-15', 'HOLD_CREATED', 1, 'HLD-1');
COMMIT;This design keeps both: counters for decisions, rows for evidence
Every unit in held is explained by an ACTIVE inventory_hold covering that night, and every unit in confirmed by a CONFIRMED reservation night. Those rows are the ledger; the counters are a cache of them maintained in the same transactions, so drift is detectable with one query (run by reconciliation, Deep Dive 08):
-- run on a replica: one snapshot sees counters and rows consistently
SELECT * FROM (
SELECT i.stay_date, i.held, i.confirmed,
(SELECT count(*) FROM inventory_hold h
WHERE h.hotel_id = i.hotel_id AND h.room_type_id = i.room_type_id AND h.state = 'ACTIVE'
AND i.stay_date >= h.check_in AND i.stay_date < h.check_out) AS active_holds,
(SELECT count(*) FROM reservation_night rn JOIN reservation r USING (reservation_id)
WHERE r.hotel_id = i.hotel_id AND r.room_type_id = i.room_type_id
AND r.state = 'CONFIRMED' AND rn.stay_date = i.stay_date) AS sold_nights
FROM room_night_inventory i
WHERE i.hotel_id = 'HTL-204' AND i.stay_date >= current_date
) x
WHERE held <> active_holds OR confirmed <> sold_nights; -- any row returned is drift: alert, never auto-clampMulti-Night Atomicity: All Nights or None
Stage 0 allocated nights independently, which can leave a partial stay; Stage 3 (Complete Design, Chapter 11) fixes it with one conditional UPDATE per night inside one transaction. This section is about the code around those statements: how the application decides, what the database actually reports, and variants that look equivalent but are not.
function allocate(hotelId, roomTypeId, checkIn, checkOut):
nights = dates in [checkIn, checkOut), ascending # the checkout date is not a night
if len(nights) > MAX_STAY_NIGHTS: return 400 INVALID_REQUEST # bounds statements and lock time
BEGIN
if restrictionsViolated(hotelId, roomTypeId, nights): # Deep Dive 12
ROLLBACK; return 422 RESTRICTION_VIOLATED
for night in nights:
n = exec("UPDATE room_night_inventory SET held = held + 1
WHERE hotel_id = $1 AND room_type_id = $2 AND stay_date = $3
AND sellable - held - confirmed >= 1", hotelId, roomTypeId, night)
if n != 1:
ROLLBACK # also undoes the nights already incremented
return 409 INVENTORY_UNAVAILABLE(night) # the first blocking night, in date order
insert hold row (Deep Dive 04) and outbox event (Deep Dive 07)
COMMIT
return 201 holdA zero-row UPDATE is not an error
A conditional UPDATE that matches nothing succeeds with 0 rows affected; no exception, the transaction stays open. Code that only checks for errors will commit 11-14 after 11-15 failed. Read the affected-row count of every statement, require exactly 1, and stop at the first 0 so the 409 names the blocking night.
If request A takes the last 11-15 unit first, request B's UPDATE on 11-14 still matches, its 11-15 UPDATE matches 0 rows, and the ROLLBACK removes B's 11-14 increment: B never holds one night of a two-night stay.
Variants that look equivalent
| Variant | What goes wrong |
|---|---|
One UPDATE … WHERE stay_date IN (…) AND sellable - held - confirmed >= 1, then compare the row count | Atomic if a short count rolls back, but rows lock in the plan's scan order, which SQL does not promise (deadlock risk, Deep Dive 03), and the 409 cannot name the night |
SELECT availability, decide in the application, then unconditional UPDATE | The Stage 2 lost update: the decision is made on a value that may be stale by the time of the write |
| One transaction per night, with compensation on failure | A crash between nights leaves a partial claim visible until a compensator runs |
| No WHERE guard; rely on the CHECK constraint to fail | Correct, but each failure aborts the PostgreSQL transaction, so naming the night needs a savepoint per statement; a backstop becomes control flow |
When a stay cannot be one transaction
Nights in different databases would need a reserve-then-confirm protocol or a compensating saga. That never happens here: a stay belongs to one hotel and a hotel lives on one shard (Deep Dive 14). Only multi-hotel packages would need it.
Isolation Levels, Row Locks and Deadlock-Free Lock Ordering
Stage 2 (Complete Design, Chapter 10) showed the lost update, and Stage 3 fixed it with a conditional UPDATE at READ COMMITTED. This section explains why that is correct in PostgreSQL, what stronger isolation levels change, and the lock-ordering rule every multi-row transaction follows. Operations that arrive later need fencing, covered with holds (Deep Dive 04).
What happens when two UPDATEs meet on one row
| Time | Request A | Request B | Row 11-15 (committed) |
|---|---|---|---|
| t1 | UPDATE … SET held = held + 1 WHERE … AND sellable - held - confirmed >= 1 matches (5 - 0 - 4 = 1); takes the row lock | held 0, confirmed 4 | |
| t2 | Same UPDATE on 11-15: the row is locked, so it waits | held 0, confirmed 4 | |
| t3 | COMMIT | held 1, confirmed 4 | |
| t4 | Wakes, re-reads the newest committed version, re-evaluates the WHERE: 5 - 1 - 4 = 0, so 0 rows | held 1, confirmed 4 |
At READ COMMITTED, PostgreSQL re-evaluates the waiting statement's WHERE clause against the updated row version (its EvalPlanQual re-check) instead of using the snapshot the statement started with. If A had rolled back instead, B would re-evaluate against the original version and match. (MySQL InnoDB gets the same outcome because an UPDATE always reads the latest version under lock.) The check and the write happen under one row lock, with no application read in between.
Why not REPEATABLE READ or SERIALIZABLE
In PostgreSQL, a transaction at REPEATABLE READ or SERIALIZABLE does not re-check: once A commits, B's UPDATE fails with could not serialize access due to concurrent update (SQLSTATE 40001) and the whole transaction must be retried. Still correct, but on a hot row every loser becomes an abort and a retry instead of a clean 0-row answer. The conditional UPDATE needs nothing stronger than READ COMMITTED, so the booking path runs at that level.
Row locks last until COMMIT
A lock taken by an UPDATE is held until the transaction ends, not just for the statement. In a 2-night hold, 11-14 stays locked while the transaction waits on 11-15. Three consequences: no network call may run inside T1 or T3; the hot-row budget (2-5 ms per allocation, roughly 200-500 allocations/s on one room-night) is the whole transaction's duration; and waits should be bounded with SET LOCAL lock_timeout, so a pile-up on a conference night fails fast with a retryable error instead of exhausting the connection pool.
Lock ordering
Rule: every transaction that changes several room-night rows changes them in primary-key order (hotel_id, room_type_id, stay_date); for one stay that is ascending stay_date, whatever order the request listed.
Without the rule, Txn X holds 11-14 and waits for 11-15 while Txn Y holds 11-15 and waits for 11-14. PostgreSQL only looks for such cycles after a waiter has been blocked for deadlock_timeout (1 s by default), then aborts one transaction with SQLSTATE 40P01: a second of the hot row's queue lost, plus a retry. With a total order, a cycle is impossible, because a transaction only ever waits for a row greater than every row it already holds.
| Transaction | Lock sequence |
|---|---|
| Hold (T1) | Room-nights ascending, then inserts the new hold row |
| Confirmation (T3) | Hold row (fence) → room-nights ascending → booking attempt row |
| Expiry batch | Claimed hold rows → room-nights of the whole batch in key order |
| Cancellation | Reservation row → room-nights ascending |
| Modification swap | Reservation row → added-night hold → dropped and added nights together, in key order |
| PMS batch | Hotel's sync-cursor row → adjusted room-nights in key order |
The pattern is "owning row first, then room-nights in key order": flows contending for the same hold, reservation or cursor queue on that row before touching any room-night. Optimistic versions and SELECT … FOR UPDATE are compared in Trade-offs & Decisions, Decision 07.
Holds: Durable TTL, the Expiry Worker and Fencing
Stage 4 (Complete Design, Chapter 12) added holds: held + 1 plus an inventory_hold row with a 600-second TTL, and an Expiry Worker that returns abandoned holds. The TTL is a business rule enforced by a worker that races with confirmations and in-flight payments; this section covers the hold row, the worker's batches, payment deferral and the fence.
CREATE TABLE inventory_hold (
hold_id TEXT PRIMARY KEY, -- HLD-1
principal_id TEXT NOT NULL,
hotel_id TEXT NOT NULL,
room_type_id TEXT NOT NULL,
check_in DATE NOT NULL, -- nights are [check_in, check_out)
check_out DATE NOT NULL CHECK (check_out > check_in),
quote_id TEXT NOT NULL, -- QTE-1 (Deep Dive 09)
total_amount NUMERIC(12,2) NOT NULL,
state TEXT NOT NULL, -- ACTIVE | CONSUMED | EXPIRED | RELEASED
expires_at TIMESTAMPTZ NOT NULL, -- created_at + 600 s
payment_started_at TIMESTAMPTZ -- set by T2 when a booking attempt starts (Deep Dive 05)
);
CREATE INDEX hold_expiry_idx ON inventory_hold (expires_at) WHERE state = 'ACTIVE';
-- one live hold per traveler per stay: repeated clicks cannot hoard nights
CREATE UNIQUE INDEX one_active_hold_per_stay
ON inventory_hold (principal_id, hotel_id, room_type_id, check_in, check_out) WHERE state = 'ACTIVE';The worker loop
BEGIN;
-- 1. claim a small batch; SKIP LOCKED gives each worker instance disjoint holds
SELECT hold_id, hotel_id, room_type_id, check_in, check_out
FROM inventory_hold
WHERE state = 'ACTIVE' AND expires_at <= now()
AND (payment_started_at IS NULL OR payment_started_at < now() - interval '60 seconds') -- auth_timeout
ORDER BY expires_at
LIMIT 50
FOR UPDATE SKIP LOCKED;
-- 2. fence: flip only holds that are still ACTIVE
UPDATE inventory_hold SET state = 'EXPIRED'
WHERE hold_id = ANY(:claimed) AND state = 'ACTIVE';
-- 3. release: one statement per room-night touched by the batch, k = holds in the batch covering it,
-- issued in (hotel_id, room_type_id, stay_date) order
UPDATE room_night_inventory SET held = held - :k
WHERE hotel_id = :hotel AND room_type_id = :room_type AND stay_date = :night AND held >= :k; -- must affect 1 row
INSERT INTO outbox_event (...) VALUES (... 'hold.expired' ...); -- one per hold; feeds the projection
COMMIT;Why each line is there
SKIP LOCKED passes over holds another worker claimed or a confirmation (T3) is spending; the next tick sees their final state. Small batches, because room-night locks, possibly on hot nights, are held until COMMIT. Key-ordered releases keep the worker in the bookings' global lock order (Deep Dive 03). `held >= :k`, exactly 1 row: a mismatch is counter drift, so roll back and alert rather than clamp to zero.
Deferring holds whose payment is in flight
Stage 6 (Complete Design, Chapter 14) authorizes while the hold's clock runs; expiring a hold mid-authorization would turn late-in-TTL checkouts into refunds. So T2, which inserts the booking attempt, also stamps the hold (SET payment_started_at = now() WHERE state = 'ACTIVE' AND expires_at > now(); 0 rows → 410 HOLD_EXPIRED before any money moves), and the worker defers stamped holds for auth_timeout = 60 s.
The stamp lives on the hold row deliberately. When the worker's FOR UPDATE meets a hold row updated after its snapshot, PostgreSQL re-checks the WHERE clause against the new version, so a just-started payment is seen. A NOT EXISTS (SELECT … FROM booking_attempt …) predicate never touches the hold row, so nothing forces a re-check: an attempt committed after the worker's snapshot stays invisible, and the hold can expire as its payment starts.
The fence: the hold row's own state
Every path that spends a hold opens with a compare-and-set on that hold's row: confirmation ACTIVE → CONSUMED, expiry ACTIVE → EXPIRED, explicit abandon ACTIVE → RELEASED. Counters move only in the transaction that won the flip; the loser re-evaluates state = 'ACTIVE' after the lock wait (Deep Dive 03), matches 0 rows and stops.
Scenario A: the confirmation commits first
T3 commits at t=598 s (HLD-1 CONSUMED, nights held → confirmed). The worker's tick at t=601 s skips the row or matches 0 rows; nothing is released.
Scenario B: expiry commits first
The provider outlasted auth_timeout, so the deferral ended and the worker expires HLD-1 at t=600 s; at t=602 s request B's HLD-2 takes the freed 11-15 unit. A late capture's T3 at t=605 s matches 0 rows on HLD-1's fence and rolls back; reconciliation refunds (Deep Dive 08). It only addresses HLD-1's row, so it cannot touch HLD-2's unit.
Operating the worker
Primary signal: expiry lag, now() - min(expires_at) over non-deferred ACTIVE holds; growth means abandoned holds hide inventory past their TTL. An explicit abandon runs the same transaction with RELEASED, returning the unit immediately instead of after 10 minutes.
Idempotency Keys, Unique Constraints and Fingerprints
Stage 6 (Complete Design, Chapter 14) makes every write endpoint idempotent: a retried POST returns the original outcome, never a second hold or charge. A key is only as strong as the unique constraint behind it; booking_attempt (DDL in Complete Design, Chapter 14) carries two, and this section shows the insert-first protocol that tells their conflicts apart.
BEGIN; -- T2, before any provider call
INSERT INTO booking_attempt (booking_attempt_id, principal_id, endpoint, idempotency_key,
hold_id, request_fingerprint, state, created_at)
VALUES ('BKA-1', :principal, 'POST /v1/bookings', '3f9c2a71-book',
'HLD-1', :fingerprint, 'PENDING_PAYMENT', now())
ON CONFLICT DO NOTHING -- either unique index; no exception, transaction stays usable
RETURNING booking_attempt_id;
-- 0 rows returned: a conflict -> ROLLBACK and resolve it (table below)
-- 1 row returned: first request; stamp the hold for the expiry worker (Deep Dive 04)
UPDATE inventory_hold SET payment_started_at = now()
WHERE hold_id = 'HLD-1' AND principal_id = :principal
AND state = 'ACTIVE' AND expires_at > now(); -- 0 rows: ROLLBACK, 410 HOLD_EXPIRED
COMMIT;
-- :fingerprint = hash(hold_id, amount, payment_method_token); amount is the hold's total_amount| On conflict, look up by (principal, endpoint, key) | Response |
|---|---|
| Found, same fingerprint, COMPLETED | 201 with the original reservation (RES-1); no provider call |
| Found, same fingerprint, PENDING_PAYMENT or a reconciliation state | 202 with the attempt; never re-authorize |
| Found, same fingerprint, FAILED | The original failure; a new try needs a new key |
| Found, different fingerprint | 409 IDEMPOTENCY_KEY_CONFLICT |
| Not found | The per-hold partial index fired: another attempt for this hold, under a different key, is in flight; 409 naming that attempt, never a second authorization |
Why insert-first, and why ON CONFLICT
Read-then-insert lets two retries on two servers both see "no row"; only the unique index sees both. In PostgreSQL a violating INSERT aborts the transaction, so catch-then-SELECT needs a savepoint; ON CONFLICT DO NOTHING avoids that. If the conflicting row is still uncommitted, the INSERT waits for its transaction, so the follow-up lookup sees a committed row (or the INSERT succeeds if that transaction rolled back).
What the fingerprint covers
The same key with a different hold, amount or card is a client bug, rejected rather than answered with another request's result. A new card after a decline is a new key; the per-hold partial index admits it once the earlier attempt has left PENDING_PAYMENT.
Holds and cancellations need the same treatment
POST /holds with key 3f9c2a71-hold records its key in the same T1 as the counter updates (a unique (principal_id, endpoint, idempotency_key) record). Without it, a retried hold would increment held twice and hide a unit for 10 minutes. For -cancel, the guarded transition alone would answer a retry with "0 rows"; the key turns that into the original CANCELLED response.
Provider idempotency
Authorize, capture and refund calls carry references derived from the attempt (BKA-1-auth, BKA-1-capture), so a retried call resolves to the same charge and reconciliation can query exactly that reference (Deep Dive 08). Attempts are kept with the reservation, not as a 24-hour cache, so even a very late retry finds its outcome.
State Machine Mechanics: Where Each State Lives and How Transitions Are Guarded
Stage 5 (Complete Design, Chapter 13) named the booking lifecycle. In storage it spans three rows with different lifetimes, every transition is a compare-and-set on one of them, and the reservation row does not exist until T3 creates it, already CONFIRMED.
| Lifecycle state | Stored as | Written by |
|---|---|---|
| QUOTED | A quote row; no inventory effect | Pricing/Quote Service |
| HELD | inventory_hold.state = 'ACTIVE' | T1 (Inventory Service) |
| PENDING_PAYMENT | booking_attempt.state = 'PENDING_PAYMENT'; hold still ACTIVE, payment_started_at set | T2 (Booking Orchestrator) |
| CONFIRMED | Hold CONSUMED, attempt COMPLETED, reservation inserted as CONFIRMED, version 1, all in one commit | T3 |
| EXPIRED | inventory_hold.state = 'EXPIRED' | Expiry Worker |
| PAYMENT_FAILED | booking_attempt.state = 'FAILED'; the hold stays ACTIVE for another card until TTL or release | Booking Orchestrator |
| PAYMENT_CAPTURED_BOOKING_FAILED → RECONCILING → REFUNDED | booking_attempt.state | Reconciliation Worker |
| CANCELLED | reservation.state = 'CANCELLED', version + 1 | Cancellation |
| Modified | Reservation stays CONFIRMED, version + 1, plus a reservation_modification row | Modification saga |
-- attempt: PENDING_PAYMENT -> COMPLETED, inside T3 after the hold fence
UPDATE booking_attempt SET state = 'COMPLETED', reservation_id = 'RES-1'
WHERE booking_attempt_id = 'BKA-1' AND state = 'PENDING_PAYMENT'; -- 0 rows: reconciliation owns it -> ROLLBACK
-- attempt: PENDING_PAYMENT -> FAILED, on a decline
UPDATE booking_attempt SET state = 'FAILED'
WHERE booking_attempt_id = 'BKA-1' AND state = 'PENDING_PAYMENT';
-- reservation: CONFIRMED -> CANCELLED, with the version the caller read
UPDATE reservation SET state = 'CANCELLED', version = version + 1
WHERE reservation_id = 'RES-1' AND state = 'CONFIRMED' AND version = :v; -- 0 rows: changed meanwhileZero rows means stop, reload, answer
A duplicate retry, a stale webhook or a racing cancellation finds the row past its expected state; the caller reloads it and answers from its current state, never overwriting or retrying blindly. Late or reordered provider webhooks ("authorized" after "captured") are no-ops for the same reason, and provider event ids recorded with the transition catch exact duplicates.
Why reservations carry a version and holds do not
Holds and attempts only move forward into terminal states, so state is a sufficient fence. A reservation stays CONFIRMED through modifications, so only the version distinguishes "CONFIRMED v1 I read" from "CONFIRMED v2", e.g. a cancellation priced from v1 racing a modification that committed v2.
Transactional Outbox, Relay Semantics and Saga Compensation
Stage 1 introduced the outbox to feed the search projection, and Stage 6 reuses it for confirmations, notifications and refunds (Complete Design, Chapter 09; Complete Design, Chapter 14). Same-transaction writes remove "committed but never announced"; what remains is at-least-once, possibly reordered delivery, so correctness moves into consumers. Multi-step operations (modification, refunds) become sagas with explicit compensation.
CREATE TABLE outbox_event (
seq BIGSERIAL PRIMARY KEY, -- per-shard sequence
event_id TEXT NOT NULL UNIQUE, -- EVT-1; consumers deduplicate on it
aggregate_key TEXT NOT NULL, -- 'HTL-204:RT-DLX' or 'RES-1'; broker partition key
event_type TEXT NOT NULL, -- hold.created | reservation.confirmed | refund.requested | ...
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
published_at TIMESTAMPTZ
);
CREATE INDEX outbox_unpublished ON outbox_event (seq) WHERE published_at IS NULL;
-- relay iteration
BEGIN;
SELECT seq, event_id, aggregate_key, event_type, payload
FROM outbox_event WHERE published_at IS NULL
ORDER BY seq LIMIT 500 FOR UPDATE SKIP LOCKED;
-- publish each event (partition key = aggregate_key) and wait for broker acks
UPDATE outbox_event SET published_at = now() WHERE seq = ANY(:acked);
COMMIT;A published flag, not a high-water mark
seq is assigned at insert, but transactions commit out of order: seq 41 can commit after 42. A relay remembering "done up to 42" would skip 41 forever; polling published_at IS NULL cannot.
At-least-once by construction
The relay publishes, then marks; a crash in between republishes, whereas marking first would lose the event. Every committed change is published at least once, never exactly once.
Idempotent consumers
The projector applies absolute per-night values only if their seq is newer than the stored one (Deep Dive 10); deltas such as held + 1 would not be idempotent. Consumers with a database insert (consumer, event_id) into a processed-events table in the same transaction as their effect. External effects such as email pass event_id as the provider's idempotency key where supported, and otherwise accept a rare duplicate.
Ordering
Per-key order comes from the broker partition (aggregate_key); after redeliveries consumers rely on seq. seq is a valid per-night version: a second writer can update a room-night only after the first commits, and each transaction inserts its outbox row after its room-night updates, so the later writer draws the larger seq.
Sagas: modification and refunds
A modification (Complete Design, Chapter 16) is three local steps with a payment call in the middle, so it cannot be one transaction. The saga's progress is persisted on the reservation_modification row (MOD-1), so a crashed orchestrator resumes instead of restarting.
| Step | Action | If it fails |
|---|---|---|
| 1. Claim | Hold the added night 11-16 as HLD-3 through the normal T1, re-checking restrictions | 409/422: the saga ends FAILED; nothing changed |
| 2. Settle | Capture the price difference or refund it, under reference MOD-1-… | Provider declines: release HLD-3 (ACTIVE → RELEASED) |
| 3. Swap | One transaction: reservation version 1 → 2 guarded by version, HLD-3 → CONSUMED, nights 11-14 (dropped) and 11-16 (added) in key order, outbox event | Version moved (a concurrent cancel or modify) or HLD-3 expired: release HLD-3 if still ACTIVE, refund step 2's capture; the saga ends COMPENSATED and the reservation is untouched |
Compensations are forward actions
A compensation is a new guarded, idempotent action (release through the hold fence, refund with a stable reference), not a rollback, so a resumed saga can re-run it. The original nights are released only in step 3, so any failure leaves the traveler with the stay they had.
Refund after cancellation
The cancellation transaction writes refund.requested; the Payment Service refunds under a reference fixed at cancellation, as its own record (REQUESTED → SUCCEEDED, retried). A failing refund never reopens the cancellation; a redelivery cannot refund twice.
Reconciliation Workers: Detection Queries and Idempotent Repairs
Stage 7 (Complete Design, Chapter 15) closes the last window: the provider captured and the orchestrator died before T3 committed. Reconciliation is narrow indexed sweeps, one per discrepancy, repairing through the online path's guarded transitions.
CREATE INDEX attempt_pending_idx ON booking_attempt (created_at) WHERE state = 'PENDING_PAYMENT';
-- detection sweep: attempts in flight well past auth_timeout (60 s); no lock held while calling the provider
SELECT booking_attempt_id, hold_id FROM booking_attempt
WHERE state = 'PENDING_PAYMENT' AND created_at < now() - interval '2 minutes'
ORDER BY created_at LIMIT 100;
-- provider, queried by reference BKA-1, says CAPTURED:
UPDATE booking_attempt SET state = 'PAYMENT_CAPTURED_BOOKING_FAILED'
WHERE booking_attempt_id = 'BKA-1' AND state = 'PENDING_PAYMENT'; -- 0 rows: already resolved
-- resolver claims it
UPDATE booking_attempt SET state = 'RECONCILING'
WHERE booking_attempt_id = 'BKA-1' AND state = 'PAYMENT_CAPTURED_BOOKING_FAILED';
-- then runs T3 with the attempt guard `state = 'RECONCILING'`:
-- hold fence matches -> CONFIRMED; fence matches 0 rows -> refund under BKA-1-refund -> REFUNDEDWhy two states, and no double completion
PAYMENT_CAPTURED_BOOKING_FAILED records a fact and can come from several detectors (this sweep, and a slower settlement-export comparison that also catches captures on attempts already FAILED); RECONCILING records that one resolver owns the case. The online T3 guards the attempt with state = 'PENDING_PAYMENT' and the reconciliation T3 with state = 'RECONCILING', so a slow orchestrator and the worker can never both complete it.
| Discrepancy | Detection | Resolution |
|---|---|---|
| Attempt PENDING_PAYMENT past threshold | Partial index on created_at | Provider by reference: CAPTURED → resolve as above; AUTHORIZED only → capture and continue if the hold is ACTIVE, else void; DECLINED or not found → FAILED, hold released through its fence |
| Capture with no completed attempt | Settlement export joined to attempts by reference | → PAYMENT_CAPTURED_BOOKING_FAILED; the hold has usually expired by then, so typically REFUNDED |
Hold ACTIVE past expires_at; outbox rows unpublished | Expiry-lag and outbox-age queries | Worker or relay unhealthy: alert; never release by hand outside the fence |
| Counter drift | Deep Dive 01 audit query | Alert; repair under review in a guarded transaction |
| PMS capacity below held + confirmed | sync_conflict rows (Deep Dive 13) | Operations: relocate guests or correct the PMS; never shrink sellable below held + confirmed |
Idempotent repairs and policy
Every repair uses from-state guards and a stable provider reference, so a re-run cannot confirm or refund twice. If the hold expired, a product may first try a fresh conditional allocation before refunding; never skip the conditional update to honor a payment.
Quotes: Price Versions, Price Locks and Expiry
Stage 4 (Complete Design, Chapter 12) put a quote in front of the hold: the Pricing/Quote Service computes a reproducible price from cached DailyRate, tags it with a price_version, gives it a 10-minute expiry, and claims no inventory. The mechanics are in what the version means, what the quote locks, and what it does not.
-- QTE-1: price_version 7, nightly [6500, 6500], GST 1560, total 14560, expires_at = created_at + 10 min
-- inside T1: a hold can only be created from a live quote owned by the caller
SELECT total_amount, price_version FROM quote
WHERE quote_id = 'QTE-1' AND principal_id = :principal AND expires_at > now(); -- no row: 410 QUOTE_EXPIRED
-- the hold copies total_amount and price_version; the booking charges the hold's amountThe version makes a quote reproducible
Every rate edit bumps the price_version of the affected DailyRate rows. The quote stores nightly amounts, taxes and that version, so audit can show why ₹14,560 was charged after rates change.
A price lock, not an inventory lock
A rate edit inside the 10 minutes does not change an existing quote: the traveler pays the price shown, and the hotel accepts that bounded exposure (Complete Design, Chapter 17). After expiry the quote is refused with 410 QUOTE_EXPIRED and the client re-quotes at the current version. Because the quote claims no inventory, it costs nothing to abandon, and the unit can still sell out while the quote is valid; that surfaces as a 409 at hold time.
The lock moves to the hold
T1 copies the amount and version into the hold. From then on the hold's amount is what the booking charges, and it is part of the attempt fingerprint (Deep Dive 05), so a retry under the same key cannot change it. The hold's 600-second TTL bounds how long the price stays locked after the quote.
Restrictions are not locked
A stop-sell or minimum stay added after quoting still blocks the hold, because restrictions are re-read inside T1 (Deep Dive 12). A quote promises a price, never sellability.
The Availability Projection: Layout, Idempotent Updates and Lag
Stage 1 (Complete Design, Chapter 09) moved search onto an Availability Read Model updated incrementally from outbox events, with a lag of at most ~60 s. It is a projection, not a cache of query results: events update it, the booking path never invalidates it, and no allocation decision ever reads it.
# one entry per (hotel, room type): nightly availability across the 400-day horizon
avail:HTL-204:RT-DLX -> { start: 2026-10-20,
nights: [..., 3 /*11-14*/, 1 /*11-15*/, ...],
seq: [..., 9120, 9188, ...], # outbox seq last applied per night
as_of: 2026-10-19T09:14:00+05:30 }
# ~150,000 room types x 400 nights x ~20 bytes ≈ 1.2 GB
# full-stay check for [check_in, check_out): a minimum over a slice, not N network lookups
stay_available(HTL-204, RT-DLX, 11-14..11-16) = min(nights[i(11-14)], nights[i(11-15)]) >= rooms_requestedApplying an event
Each event carries, for every night it touched, the absolute availability after the change, plus its outbox seq. The projector writes a night only if the incoming seq is greater than the stored one. Duplicates and reordered deliveries become no-ops, and a lost event heals at the next change to that night, because the values are absolute. Deltas would require exactly-once, in-order delivery, which the relay does not provide (Deep Dive 07).
Hot nights and coalescing
A conference night can change 50-200 times within seconds; the projector applies only the highest seq per night in each batch, so writes scale with distinct nights changed.
Measuring and spending the lag
Lag per partition is now() minus the created_at of the oldest event not yet applied, with an alert as it approaches 60 s. Responses carry availability_as_of and cache_freshness_seconds (Complete Design, Chapter 07). Any result cache in front of search spends from the same budget: a 30 s result TTL on top of 40 s of projection lag means 70 s of staleness.
Drift repair and rebuild
A periodic per-hotel comparison against replica counts emits corrections. A full rebuild (bootstrap, schema change) loads a snapshot, then replays the outbox from a position safely before it; the seq check makes the overlap harmless. It is never the freshness mechanism.
Property-Local Calendar Dates
A room-night (Complete Design, Chapter 02) is a calendar night in the hotel's timezone. Mechanically, nights are DATE values, a stay is the half-open range [check_in, check_out), and conversion to an instant happens only at the edges: cancellation deadlines and "today" cut-offs.
-- nights of a stay; the checkout day is not a night
SELECT d::date AS stay_date
FROM generate_series(DATE '2026-11-14', DATE '2026-11-16' - 1, interval '1 day') AS d; -- 11-14, 11-15
-- number of nights (min-stay checks): date subtraction yields an integer
SELECT DATE '2026-11-16' - DATE '2026-11-14'; -- 2
-- "today" at the property, for same-day cut-offs and past-date rejection
SELECT (now() AT TIME ZONE 'Asia/Kolkata')::date;
-- RP-FLEX deadline: 24 h before the 14:00 local check-in, as an instant
SELECT ((DATE '2026-11-14' + TIME '14:00') AT TIME ZONE 'Asia/Kolkata') - interval '24 hours';The bug elapsed-time models produce
HTL-204 is in Asia/Kolkata, which has no daylight saving, so take a London hotel instead: a 2-night stay from 2026-10-24 to 2026-10-26. Store check-in as local midnight in UTC (2026-10-23T23:00Z, because BST is UTC+1) and add 48 hours: 2026-10-25T23:00Z. Clocks went back on 10-25, so that instant is 23:00 on 10-25 in London. The computed checkout lands a day early, and the stay silently loses a night.
Everything per local night
Nightly rates, taxes, minimum-stay and stop-sell checks are all evaluated per local date (Deep Dive 12). A minimum-stay rule is a statement about specific calendar nights; a model that miscounts which nights those are enforces it against the wrong ones.
Restriction Enforcement: Stop-Sell, Minimum Stay, Closed to Arrival
Stage 9 (Complete Design, Chapter 17) lets hotels block sales without touching counts. Restrictions are rows read inside the hold transaction, and a violation returns 422 RESTRICTION_VIOLATED, a different answer from 409 INVENTORY_UNAVAILABLE.
CREATE TABLE restriction (
hotel_id TEXT NOT NULL,
room_type_id TEXT NOT NULL,
stay_date DATE NOT NULL, -- one row per local night; ranges are expanded on write
restriction_type TEXT NOT NULL, -- STOP_SELL | MIN_STAY | CLOSED_TO_ARRIVAL
value INT, -- MIN_STAY: minimum nights
PRIMARY KEY (hotel_id, room_type_id, stay_date, restriction_type)
);
-- inside T1, before the conditional UPDATEs: everything that applies to [11-14, 11-16)
SELECT stay_date, restriction_type, value FROM restriction
WHERE hotel_id = 'HTL-204' AND room_type_id = 'RT-DLX'
AND stay_date >= '2026-11-14' AND stay_date < '2026-11-16';| Restriction | Rule | Example |
|---|---|---|
| STOP_SELL | Present on any night of the stay → reject | Any stay including 11-20 fails, even with free units |
| MIN_STAY | On the arrival night: check_out - check_in >= value (some hotels apply it to every night of the stay instead) | MIN_STAY 2 on 11-14: a 1-night stay arriving 11-14 fails; the 2-night stay passes |
| CLOSED_TO_ARRIVAL | Present on check_in → reject; staying through that night is allowed | A stay arriving 11-13 may include 11-14 |
Read without locks, and why that is safe
The restriction read is a plain READ COMMITTED read. A stop-sell committing a millisecond later does not block an in-flight hold; both commit, equivalent to the hold happening first, which matches the rule that a stop-sell governs new sales and never cancels existing holds. A stricter rule would need both to write a common row.
Checked often, decided once
Search shows restriction flags from the projection and quoting checks them so a traveler learns early, but only the read inside T1 decides. Modifications re-validate the new stay in their own T1 (Deep Dive 07).
PMS Sync: Cursor Ordering and Conflict Handling
Stage 9 (Complete Design, Chapter 17) made the hotel a second writer: the PMS Connector applies batches from the hotel's property-management system. There are two hazards: batches arrive late, duplicated or out of order, and a PMS can report capacity that contradicts rooms the platform has already sold.
BEGIN;
-- 1. cursor compare-and-set: serializes batches per hotel and rejects stale or replayed ones
UPDATE sync_cursor SET last_applied_cursor = 885
WHERE hotel_id = 'HTL-204' AND last_applied_cursor < 885; -- 0 rows (e.g. 883 arriving after 884): ROLLBACK, acknowledge
-- 2. per night, in key order: capacity never drops below what is held or sold
UPDATE room_night_inventory SET sellable = :reported
WHERE hotel_id = 'HTL-204' AND room_type_id = 'RT-DLX' AND stay_date = :night
AND :reported <= total AND :reported >= held + confirmed; -- 0 rows: a conflict
-- 3. conflicting nights are recorded, not applied
INSERT INTO sync_conflict (hotel_id, room_type_id, stay_date, cursor, reported_sellable) VALUES (...);
INSERT INTO inventory_adjustment (...) VALUES (...); -- audit of what was applied
INSERT INTO outbox_event (...) VALUES (... 'inventory.adjusted' ...);
COMMIT;Why a compare-and-set on the cursor
Reading the cursor and comparing in the application lets two concurrent deliveries both see 884 and both apply. The conditional UPDATE locks the cursor row: the second batch waits, re-evaluates (Deep Dive 03) and matches 0 rows; a replay of 885 is a no-op.
Absolute values, not deltas
"Apply only if newer" is safe when batches carry absolute per-night values: a skipped older batch is superseded. If a PMS sends deltas, a skipped batch is lost data; the connector must then require cursor = last + 1 and request a resend on a gap.
Who wins
For bookings made on this platform, the platform ledger is authoritative. A night reported below held + confirmed is not applied; the rest of the batch is and the cursor advances, so one conflict does not block the stream, and operations resolves it. Sales on other channels arrive only through later batches, so a cross-channel oversell window bounded by sync latency remains (Trade-offs & Decisions, Decision 12).