Letting Go of a Hold Without Losing a Sale: Compare-and-Set Reservation Expiry
A cleanup job that frees abandoned reservations can free a good one if the customer confirms just as the job runs. This article proposes a compare-and-set approach. It is a conditional design recommendation, not a claim about any existing system.
The scenario
A reservation record has a status, an expiry time and a version number. Hypothetical record R7: status held, expires 10:15, version 3.
- 10:14:59 customer confirms; the system tries to set status confirmed where version is 3.
- 10:15:00 cleanup reads R7 as held and expired, then tries to set status released where version is 3.
Whichever write lands first raises the version to 4. The other write's condition then fails and changes nothing. That is the key rule: the release must name the exact state it saw, not just the id.
Design constraints
- Release only with a conditional update: status is held AND version equals the version read AND expiry is past.
- Confirmation uses the same version check, so only one of them can win.
- Confirmed is terminal for the cleanup job; it never targets that status.
- Count affected rows. Zero means the record changed; treat as skipped, not as failure.
- Log each outcome with the record id and the version seen.
Branches
- Held and expired, version unchanged: release succeeds.
- Confirmed before release: release affects zero rows; leave the booking.
- Payment or confirmation outcome is unknown (the external call did not answer): do not release automatically and do not repeat the external write. Mark pending_review so a person checks. Inventory stays held until resolved.
- Expiry extended by the customer: the new time means the stale cleanup read no longer matches the version.
- Clock disagreement: use one time source for expiry decisions and add a grace margin you choose and document.
Worked example
The job finds four expired holds: R1, R2, R3, R4.
- R1 is untouched, released.
- R2 was confirmed during the run, so the update affects 0 rows, skipped.
- R3 has an unknown payment result, set to pending_review, not released.
- R4 is untouched, released.
Totals: 2 released, 1 skipped, 1 pending_review, 4 accounted for. Capacity returns for R1 and R4 only.
Stop conditions
Halt the run if the skipped or pending_review share is unusually high against a limit you set, if the time source is unreachable, or if a release would affect a record whose status is not held.
Proposed acceptance tests (proposed, not performed)
- Confirm and expire the same record concurrently, repeated many times; expect exactly one final state and never a released booking that was confirmed.
- Confirm just before cleanup reads; expect no release.
- Simulate an unknown payment result; expect pending_review and no repeated external call.
- Run cleanup twice in a row; expect the second run to change nothing.
- Extend an expiry mid-run; expect the stale release to be rejected.
These constraints reduce wrongful release in layers; they do not replace reviewing uncertain cases.