Skip to content

Commit 8919831

Browse files
committed
fix: check the deadline in an old ticket expiry
A retry with the same buyer and hold ID after the deadline creates a fresh hold. When the scheduler had already queued the old expiry, that expiry removed the fresh hold, because expire checked only the hold ID. The reminder now passes the stored deadline, and expire releases a seat only when the hold ID and the deadline both match, as SeatInventory does. A new test retries after the deadline and then delivers the old expiry; with the deadline check removed, it fails.
1 parent bc462f4 commit 8919831

3 files changed

Lines changed: 25 additions & 8 deletions

File tree

‎docs/guides/race-conditions.md‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ class EventTickets < SolidObjects::Actor
117117
deadline = HOLD_DURATION.from_now
118118
self.seats_available -= 1
119119
self.holds = holds.merge(buyer => { "hold_id" => hold_id, "expires_at" => deadline.to_i })
120-
schedule(at: deadline, key: buyer).expire(buyer:, hold_id:)
120+
schedule(at: deadline, key: buyer).expire(buyer:, hold_id:, expires_at: deadline.to_i)
121121
{ held: true, hold_id: }
122122
end
123123

@@ -133,8 +133,8 @@ class EventTickets < SolidObjects::Actor
133133
{ confirmed: true }
134134
end
135135

136-
def expire(buyer:, hold_id:)
137-
return seats_available unless holds.dig(buyer, "hold_id") == hold_id
136+
def expire(buyer:, hold_id:, expires_at:)
137+
return seats_available unless holds[buyer] == { "hold_id" => hold_id, "expires_at" => expires_at }
138138

139139
self.holds = holds.except(buyer)
140140
self.seats_available += 1
@@ -163,7 +163,7 @@ The actor owns the lifecycle:
163163
- The `hold` and `confirm` methods first call the private `release_expired_holds` method. This method removes each hold at or past its deadline and returns its seat.
164164
- The stored deadline is the rule. After the deadline, the actor rejects confirmation, even before the reminder runs. A stopped or slow runtime process does not extend a hold.
165165
- The `confirm` method moves the hold to `sold` and cancels the reminder with `unschedule`.
166-
- The `expire` method releases the seat only when the `hold_id` still matches. An expiry for an old hold does nothing. The reminder cleans up the hold if no other call releases it first.
166+
- The reminder passes the stored deadline: `expire(buyer:, hold_id:, expires_at:)`. The `expire` method releases the seat only when both the hold ID and the deadline still match the stored hold. An expiry for an old hold does nothing. An expiry that the scheduler queued before a retry created a fresh hold with the same hold ID also does nothing. The reminder cleans up the hold if no other call releases it first.
167167
- A retry of `hold` with the same IDs returns the same result while that hold exists. A retry of `confirm` with the same IDs returns the same result after the confirmation. These retries make no further changes.
168168
- The `reject` method ends the call with a business result. The caller receives `SolidObjects::Rejected`. The runtime does not retry a rejection.
169169

@@ -185,6 +185,7 @@ result = EventTickets.ref(event.id.to_s).hold(
185185
- **Retries:** Two identical holds and two identical confirmations sell one seat.
186186
- **Confirmation after expiry:** The actor rejects the confirmation with the code `no_hold`.
187187
- **Past the deadline, before the reminder runs:** The test moves the clock 11 minutes forward and delivers no reminder. The actor rejects the confirmation with `no_hold`, and another buyer holds the seat.
188+
- **A retry with the same hold ID after the deadline:** the test moves the clock 11 minutes forward. The retry creates a fresh hold, and then the old expiry arrives. The fresh hold stays.
188189
- **Stale form:** The actor rejects the update with the code `stale_revision`. The next section explains this check.
189190

190191
## Serial execution does not stop a stale form

‎examples/guides/race_conditions/event_tickets.rb‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ def hold(buyer:, hold_id:)
2626
deadline = HOLD_DURATION.from_now
2727
self.seats_available -= 1
2828
self.holds = holds.merge(buyer => { "hold_id" => hold_id, "expires_at" => deadline.to_i })
29-
schedule(at: deadline, key: buyer).expire(buyer:, hold_id:)
29+
schedule(at: deadline, key: buyer).expire(buyer:, hold_id:, expires_at: deadline.to_i)
3030
{ held: true, hold_id: }
3131
end
3232

@@ -42,8 +42,8 @@ def confirm(buyer:, hold_id:)
4242
{ confirmed: true }
4343
end
4444

45-
def expire(buyer:, hold_id:)
46-
return seats_available unless holds.dig(buyer, "hold_id") == hold_id
45+
def expire(buyer:, hold_id:, expires_at:)
46+
return seats_available unless holds[buyer] == { "hold_id" => hold_id, "expires_at" => expires_at }
4747

4848
self.holds = holds.except(buyer)
4949
self.seats_available += 1

‎test/guides/race_conditions_test.rb‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,11 +65,12 @@ class RaceConditionsGuideTest < ActiveSupport::TestCase
6565
tickets = EventTickets.ref("event-stale")
6666
tickets.open_sales(seats: 1)
6767
tickets.hold(buyer: "ada", hold_id: "hold-1")
68+
first_deadline = tickets.snapshot.holds.dig("ada", "expires_at")
6869
run_due_reminders(now: 11.minutes.from_now)
6970
drain_solid_objects(roles: [ :actors ])
7071
tickets.hold(buyer: "ada", hold_id: "hold-2")
7172

72-
tickets.expire(buyer: "ada", hold_id: "hold-1")
73+
tickets.expire(buyer: "ada", hold_id: "hold-1", expires_at: first_deadline)
7374

7475
assert_equal 0, tickets.snapshot.seats_available
7576
assert_equal "hold-2", tickets.snapshot.holds.dig("ada", "hold_id")
@@ -115,6 +116,21 @@ class RaceConditionsGuideTest < ActiveSupport::TestCase
115116
end
116117
end
117118

119+
test "an old expiry queued after a retry with the same hold ID keeps the new hold" do
120+
tickets = EventTickets.ref("event-retry-after-deadline")
121+
tickets.open_sales(seats: 1)
122+
tickets.hold(buyer: "ada", hold_id: "hold-1")
123+
first_deadline = tickets.snapshot.holds.dig("ada", "expires_at")
124+
125+
travel 11.minutes do
126+
assert tickets.hold(buyer: "ada", hold_id: "hold-1").fetch("held")
127+
tickets.expire(buyer: "ada", hold_id: "hold-1", expires_at: first_deadline)
128+
129+
assert_equal 0, tickets.snapshot.seats_available
130+
assert_equal "hold-1", tickets.snapshot.holds.dig("ada", "hold_id")
131+
end
132+
end
133+
118134
test "serial execution alone does not stop a stale form, so a revision check does" do
119135
tickets = EventTickets.ref("event-details")
120136
tickets.update_details(title: "Spring show", base_revision: 0)

0 commit comments

Comments
 (0)