Two buyers reserve the last room. Availability never becomes negative, yet both bookings succeed. Later, a payment succeeds after its reservation expires. These two interactive timelines make the inventory, booking, and payment boundaries explicit.

This is a deterministic teaching model in your browser, with no database or payment connection. It illustrates invariants; a production implementation still needs transactions, durable deduplication, and recovery.

On this page
  1. The last-room race
  2. Put the invariant in a transaction
  3. Late payments and duplicate events
  4. Crash recovery checklist
  5. Interview follow-ups

Case 1: Two requests, one room-night

Capacity is 1 and availability is 1. Requests A and B both read availability before creating a reservation and writing back the old value minus one. Both can write 0: a nonnegative constraint passes, but two reservations have been accepted.

The invariant: available + valid reservations + sold units = capacity. Checking available ≥ 0 alone does not prevent a stale-value overwrite.

StepAvailableReserved
A reads 1 available10
B reads 1 available10
A writes 0; accepts booking01
B writes 0; also accepts booking02

Default: the unsafe schedule reserves 2 units of a capacity of 1. Run the atomic reservation to compare.

Commit inventory and the reservation together

For an existing room-night row, a conditional update succeeds only while inventory remains. Check the affected-row count. Insert the reservation in the same transaction; a separate order transaction would leave a gap between inventory and booking.

UPDATE room_inventory
SET available = available - 1
WHERE room_night_id = $1 AND available > 0
RETURNING available;

In PostgreSQL Read Committed, a competing update waits for the relevant transaction and rechecks its condition against the updated row. After the first decrement leaves zero, the second request must not treat a zero-row update as success. This supports the single-row condition here, not every possible cross-row check. PostgreSQL transaction isolation

  • Multiple nights: verify all required date rows exist, update them in a stable order, and roll back the entire transaction if any night is missing or any conditional update fails.
  • Duplicate requests: use a durable unique business request key. Return the original reservation result without another decrement.
  • Consistent locking: confirmation, cancellation, and expiry must follow a documented lock order. Retry whole transactions within a bound after deadlocks or serialization failures.
  • External payment: commit the short reservation transaction before the provider call. Do not hold inventory locks across the network request.

Case 2: Payment succeeds after the hold expires

The hold expires at second 10. It starts as RESERVED with 0 available units. This example chooses an explicit policy: once the database deadline is reached, do not confirm the old hold; queue a refund for a verified charge. Confirmation requires now < deadline.

The demo assumes the callback signature, payment identity, amount, currency, and booking association were already verified. Change the event order and inspect state, inventory, and refund-job counts.

EventBooking stateAvailableRefund jobsOutcome
10s: expiry workerHOLD_EXPIRED10Release once
11s: payment successHOLD_EXPIRED11Queue refund
12s: duplicate expiryHOLD_EXPIRED11No extra release
13s: repeated callbackHOLD_EXPIRED11No extra refund

Default: expiry wins. Release inventory once and queue one refund. Queued does not mean refunded.

From a model to a recoverable service

Commit the state decision, inventory change, business deduplication record, and outbox event in one database transaction. A refund worker consumes the durable job, calls the provider, and records pending, succeeded, or failed outcomes. These are implementation checks, not backend features implemented by this demo.

Failure windowRecovery decision
The expiry worker is late, and payment arrives after the deadlineThe callback handler checks the database deadline too. If still RESERVED, expire and release once atomically, then queue the refund.
A charge request times out with an unknown resultKeep the payment object and operation key. Retrieve the result or retry the same idempotent operation; do not create a second charge under a fresh key.
State commits, then the process crashes before publishingPersist an outbox event in that transaction. Recover and retry delivery, with consumers deduplicating business effects.
The refund succeeds, then the worker crashes before recording successReuse the refund operation key and reconcile the provider result. Define retention of deduplication records and when reconciliation is required.
Callbacks repeat or arrive out of orderCombine event-ID deduplication with state checks and a business operation key. An older event must not overwrite a terminal state.

Five follow-up questions

  1. Why does the first schedule oversell even with a nonnegative inventory CHECK constraint?
  2. A three-night stay fails to reserve its final night. How do the first two decrements roll back?
  3. The charge happens at second 9 but its callback arrives at second 11. Do you refund? State the product policy first; this example uses the database deadline at confirmation time.
  4. What should the user see while a refund queue is delayed? Which alerts distinguish pending from failed refunds?
  5. What happens if your deduplication record expires before or after the provider’s idempotency window?

See Stripe’s documentation on webhook verification, retries, and ordering and idempotent requests. The timelines, policy, and demos here are teaching examples, not a claim about any company’s production architecture.

Full designs: hotel booking · ecommerce checkout · DDIA transactions · 30-question roadmap

Frequently asked questions

Does nonnegative inventory guarantee no overselling?

No. Two requests can both read 1, write 0, and create separate reservations. Bind conditional inventory updates and booking records in one atomic transaction, and check the capacity invariant.

What if payment succeeds after a reservation expires?

Define the business policy first. This example queues an idempotent refund for a verified charge without reviving the expired hold. Reacquiring inventory is another option, but it must be a new reservation operation that can fail.

Is deduplicating only by webhook event ID enough?

Not always. Different events can refer to the same business effect. Also use booking state, payment identity, and business operation keys to prevent repeated inventory updates, confirmation, or refunds.