|
| 1 | +# Expiring reservations in Rails |
| 2 | + |
| 3 | +A reservation holds stock for a short time, until the buyer confirms it or the hold expires. The stock must never go below zero. A retry must not take stock twice. A late expiry must not cancel a newer state. Solid Objects (the gem solid_objects) puts the stock and its holds in one actor, with durable reminders for the deadlines. |
| 4 | + |
| 5 | +## Put the stock under the right identity |
| 6 | + |
| 7 | +An actor for each reservation cannot prevent an oversold show. Two reservations can take the same stock. Two reservation actors are two identities. They run at the same time, and neither actor sees the other actor's hold. |
| 8 | + |
| 9 | +Put the stock and all of its holds in the actor that owns the stock. This guide uses one actor for each show. |
| 10 | + |
| 11 | +## The plain SQL design |
| 12 | + |
| 13 | +- Store each hold in a table with its seats and an `expires_at` time. |
| 14 | +- Count free seats as the capacity minus confirmed seats minus holds whose `expires_at` is still in the future. |
| 15 | +- Insert a hold inside a transaction that locks the show row. Two holds cannot then both see the last seat. |
| 16 | +- This design needs no timer. An old hold no longer counts when its time passes. |
| 17 | + |
| 18 | +This design is not enough when an action must occur at the deadline. You need a timer for these actions: |
| 19 | + |
| 20 | +- Release a payment authorization. |
| 21 | +- Tell the buyer that the hold expired. |
| 22 | +- Update state that other code reads without the clock. |
| 23 | +- Start the next step of a workflow. |
| 24 | + |
| 25 | +The timer, a confirmation, and a client retry can all touch the same hold. Each path must take the same lock. Each path must handle a duplicate or late delivery. |
| 26 | + |
| 27 | +## The actor |
| 28 | + |
| 29 | +```ruby |
| 30 | +class SeatInventory < SolidObjects::Actor |
| 31 | + HOLD_DURATION = 15.minutes |
| 32 | + EXTENSION = 5.minutes |
| 33 | + MAX_EXTENSIONS = 2 |
| 34 | + |
| 35 | + attribute :capacity, default: 0 |
| 36 | + attribute :holds, default: -> { {} } |
| 37 | + attribute :confirmed, default: -> { {} } |
| 38 | + |
| 39 | + query :seats_left do |
| 40 | + seats_available |
| 41 | + end |
| 42 | + |
| 43 | + def open_show(capacity:) |
| 44 | + self.capacity = capacity if self.capacity.zero? |
| 45 | + seats_available |
| 46 | + end |
| 47 | + |
| 48 | + def hold(hold_id:, buyer:, seats:) |
| 49 | + return hold_result(hold_id) if holds.key?(hold_id) |
| 50 | + return { status: "confirmed" } if confirmed.key?(hold_id) |
| 51 | + reject(:not_enough_seats, "Only #{seats_available} seats are left") if seats > seats_available |
| 52 | + |
| 53 | + deadline = HOLD_DURATION.from_now |
| 54 | + self.holds = holds.merge( |
| 55 | + hold_id => { "buyer" => buyer, "seats" => seats, "expires_at" => deadline.to_i, "extensions" => 0 } |
| 56 | + ) |
| 57 | + schedule(at: deadline, key: hold_id).expire(hold_id:, expires_at: deadline.to_i) |
| 58 | + hold_result(hold_id) |
| 59 | + end |
| 60 | + |
| 61 | + def extend_hold(hold_id:) |
| 62 | + hold = holds[hold_id] |
| 63 | + reject(:no_hold, "The hold expired or does not exist") unless hold |
| 64 | + reject(:extension_limit, "The hold cannot be extended again") if hold.fetch("extensions") >= MAX_EXTENSIONS |
| 65 | + |
| 66 | + deadline = Time.at(hold.fetch("expires_at")) + EXTENSION |
| 67 | + self.holds = holds.merge( |
| 68 | + hold_id => hold.merge("expires_at" => deadline.to_i, "extensions" => hold.fetch("extensions") + 1) |
| 69 | + ) |
| 70 | + schedule(at: deadline, key: hold_id).expire(hold_id:, expires_at: deadline.to_i) |
| 71 | + hold_result(hold_id) |
| 72 | + end |
| 73 | + |
| 74 | + def confirm(hold_id:) |
| 75 | + return { status: "confirmed" } if confirmed.key?(hold_id) |
| 76 | + |
| 77 | + hold = holds[hold_id] |
| 78 | + reject(:no_hold, "The hold expired or does not exist") unless hold |
| 79 | + |
| 80 | + self.holds = holds.except(hold_id) |
| 81 | + self.confirmed = confirmed.merge(hold_id => hold.fetch("seats")) |
| 82 | + unschedule(:expire, key: hold_id) |
| 83 | + { status: "confirmed" } |
| 84 | + end |
| 85 | + |
| 86 | + def expire(hold_id:, expires_at:) |
| 87 | + return seats_available unless holds.dig(hold_id, "expires_at") == expires_at |
| 88 | + |
| 89 | + self.holds = holds.except(hold_id) |
| 90 | + seats_available |
| 91 | + end |
| 92 | + |
| 93 | + private |
| 94 | + |
| 95 | + def seats_available |
| 96 | + capacity - holds.values.sum { |hold| hold.fetch("seats") } - confirmed.values.sum |
| 97 | + end |
| 98 | + |
| 99 | + def hold_result(hold_id) |
| 100 | + { status: "held", expires_at: holds.dig(hold_id, "expires_at") } |
| 101 | + end |
| 102 | +end |
| 103 | +``` |
| 104 | + |
| 105 | +- `open_show` sets the capacity once. |
| 106 | +- `hold` takes seats and records a deadline 15 minutes from now. It schedules one reminder with the hold ID as its key. A retry with the same hold ID returns the same hold and takes no more seats. When too few seats remain, `hold` rejects the request with the code `not_enough_seats`. |
| 107 | +- `extend_hold` adds 5 minutes, at most two times. It schedules the reminder again with the same key, which moves the alarm. It rejects a third extension with the code `extension_limit`. |
| 108 | +- `confirm` moves the hold to `confirmed` and cancels the reminder with `unschedule`. A confirmation retry returns the same result. After expiry, `confirm` rejects the request with the code `no_hold`. |
| 109 | +- `expire` releases the seats only if the deadline in the message still matches the hold. After an extension, an expiry for the old deadline does nothing. |
| 110 | +- `seats_left` is a query. A query runs as an ordered read in the actor mailbox. `reference.snapshot` reads the committed state without a message row. |
| 111 | + |
| 112 | +All state stays bounded by the show capacity. |
| 113 | + |
| 114 | +## Deadlines that survive a restart |
| 115 | + |
| 116 | +Use durable reminders for persistent timers in Rails. |
| 117 | + |
| 118 | +`schedule(at:, key:)` stores the reminder in the database in the same commit as the state change. A reminder is one named alarm for each actor and key. A new schedule with the same key moves the alarm. `unschedule(:expire, key: hold_id)` cancels it. |
| 119 | + |
| 120 | +Reminders run only while `bundle exec solid_objects start` runs. A reminder that falls due while the process is stopped runs after the process starts again. A reminder runs an ordinary actor message, so it runs in order with the other calls for that show. |
| 121 | + |
| 122 | +Delivery is at least once, so `expire` checks the deadline before it changes anything. See [reminders](../reminders.md). |
| 123 | + |
| 124 | +## What the tests prove |
| 125 | + |
| 126 | +- Eight concurrent holds request one seat each against a capacity of five. Five succeed, and three receive the rejection code `not_enough_seats`. |
| 127 | +- A hold retry takes its seats once. |
| 128 | +- An extension moves the reminder to the new deadline. At 16 minutes, nothing runs. At 21 minutes, the expiry runs and the seats return. |
| 129 | +- An expiry for the old deadline changes nothing after an extension. |
| 130 | +- The actor rejects a third extension with the code `extension_limit`. |
| 131 | +- A confirmation retry returns the same result. The hold moves to `confirmed` once, and the confirmation cancels the reminder. |
| 132 | +- A confirmation after expiry receives the rejection code `no_hold`. |
| 133 | +- A hold that falls due while the runtime is stopped expires after a restart. |
| 134 | + |
| 135 | +See [the tests for this guide](../../test/guides/expiring_reservations_test.rb). |
| 136 | + |
| 137 | +## Payment and other side effects |
| 138 | + |
| 139 | +Do not call a payment provider inside the actor. An effect can run more than once. |
| 140 | + |
| 141 | +- Stage the payment with `emit`. |
| 142 | +- Pass `context.id` to the provider as the idempotency key. |
| 143 | +- Confirm the hold in the actor after the payment succeeds. |
| 144 | + |
| 145 | +See [the transactional outbox guide](transactional-outbox.md). |
| 146 | + |
| 147 | +## Run it in production |
| 148 | + |
| 149 | +- Authorization: the generated policies deny every call. Write a policy. Pass `authorization_context:` on each call. See [authorization policies](../authorization.md). |
| 150 | +- Run `bundle exec solid_objects start` beside the web process. |
| 151 | +- Every actor call creates a durable message row. Solid Objects keeps terminal message history for 30 days by default. See [retention and backups](../operations.md#retention-and-backups). |
| 152 | + |
| 153 | +## Limits |
| 154 | + |
| 155 | +- Solid Objects requires Ruby 3.3 or newer and Rails 7.1 or newer. |
| 156 | +- Delivery is at least once. Each operation must be safe to run again. |
| 157 | +- There are no transactions across actors. A reservation that spans two shows needs its own design. |
| 158 | +- One busy show runs its calls one at a time. Many shows run at the same time. |
| 159 | +- The gem is pre-1.0 and makes no production-ready claim. |
| 160 | + |
| 161 | +## More information |
| 162 | + |
| 163 | +- [The example file](../../examples/guides/expiring_reservations/seat_inventory.rb) |
| 164 | +- [The tests for this guide](../../test/guides/expiring_reservations_test.rb) |
| 165 | +- [Prevent race conditions in Rails](race-conditions.md) |
| 166 | +- [Correctness and delivery semantics](../correctness.md) |
| 167 | +- [Virtual actors in Ruby on Rails](../virtual-actors.md) |
0 commit comments