Surviving failure
The payment arrived after the seat hold expired
One database invariant must decide who owns the seat when clocks and callbacks disagree.
An independent note, with worked examples. Watch the source lessons.
One seat, two clocks and a late callback
Mina holds seat A-12 until 14:10 and starts payment at 14:09:58. The provider succeeds, but its callback reaches the booking service at 14:10:04. At 14:10:01, another request sees an expired hold and gives A-12 to Ravi. Both customers now have evidence that appears to promise the same seat.
Hello Interview’s Ticketmaster design uses a temporary reservation to keep checkout from holding inventory forever, and it explicitly raises the weakness of treating a Redis TTL as sufficient. Arpit Bhayani’s optimistic-locking lesson supplies the complementary primitive: make the attempted state change conditional, then handle the losing update. Our A-12 sequence extends those ideas to the payment-versus-expiry race.
Make the seat row the authority
Keep one authoritative row for each event seat: (event_id, seat_id, state, hold_id, hold_expires_at, order_id, version). The invariant is that this row names at most one current hold or sold order. Acquiring an available or expired seat is one conditional database update, not a read followed by an unrelated write.
UPDATE seat
SET state = 'held', hold_id = :new_hold,
hold_expires_at = :db_now + :hold_length,
version = version + 1
WHERE event_id = :event AND seat_id = 'A-12'
AND (state = 'available'
OR (state = 'held' AND hold_expires_at <= :db_now));
Use a primary key on (event_id, seat_id) and a database that serializes conflicting row updates. The application checks the affected-row count: one means the hold was acquired, zero means it lost. Retry transaction-abort errors rather than treating them as success. Explicit row locking is another valid implementation. If confirmed sales live in a separate table, a unique constraint on (event_id, seat_id) there adds another enforcement point. A preliminary SELECT alone cannot enforce the transition.
Expiry is permission to transition, not proof of cleanup
An expiry timestamp does not require a perfectly punctual worker. Availability reads and acquisition updates can treat an old held row as reclaimable. A sweeper later turns expired rows back to available for tidy indexes and metrics. Both paths use the database clock and the same state predicate, so a delayed cleanup job does not extend ownership and an early application clock does not steal it.
Hello Interview’s reading of Shopify’s inventory work describes moving reservation authority alongside inventory in MySQL so the related changes can share a transaction. It also discusses rows claimed with database locking rather than a separate volatile lock. The video labels parts of its Redis cleanup and bounded-pool explanation as reconstruction, so the reusable point here is narrower: colocated authoritative state can enforce an invariant that two independent stores cannot commit atomically.
Fence expiry before asking for money
The booking service needs a policy before it calls the payment provider. In this example, a transaction may change Mina’s row from held to paying only when hold_id matches and the hold has not expired. It records a unique payment attempt and an outbox message in the same transaction. The expiry path may reclaim held rows, but it cannot reassign a paying seat until that attempt reaches its longer resolution deadline.
The worker sends the provider a stable idempotency key. A successful callback finalizes A-12 only if the row still carries Mina’s hold and payment attempt. Duplicate callbacks return the stored result. If the service cannot establish paying before 14:10, it must not begin a new charge. If an ambiguous provider outcome outlives the resolution deadline, reconciliation asks the provider before release. Should policy eventually release the seat and a success arrive later, the system records a compensation or refund; it does not overwrite Ravi’s newer ownership.
Use Redis around the invariant, not instead of it
Redis can still cache the seat map, track a waiting room, coalesce availability pushes, or provide a fast hint that A-12 is busy. A lock with a lease can reduce contention before the database update. It cannot prove that the seat was not sold after the lease expired, lost during failover, or bypassed by another writer. The conditional database transition remains the final admission decision.
Test the design by pausing it at boundaries: after acquiring the hold, after changing to paying, after the provider accepts money, before the callback commits, and while the expiry worker runs. For each pause, name the durable row that lets a retry decide whether to continue, reject, reconcile or compensate. The useful guarantee is modest and precise: the database never records two owners for A-12. Payment uncertainty is handled as a recoverable workflow instead of being mistaken for a lock.