Skip to content

Commit bc462f4

Browse files
committed
fix: enforce deadlines and record ledger entries
Review found three gaps in the guide examples, and Rails 7.1 and 7.2 found two in the tests. - EventTickets and SeatInventory now store each deadline and check it in the operation, so a hold past its deadline cannot be confirmed or extended and frees its seats even when the expiry reminder has not run. The reminder only cleans up. New tests travel past the deadline without delivering a reminder; both failed before the change. - SeatInventory rejects a seat count that is not a positive integer. A hold for -5 seats had added five seats to the show. - LedgerAccount kept the last 100 entry IDs in state, so a repeated entry after 100 newer entries changed the balance again. Each entry now writes a ledger_entries row through a commit action in the same transaction, with a unique index, and apply checks that row first. A test applies 102 entries and repeats the first; it failed before. - On Rails 7.1 and 7.2, ActiveJob::TestHelper ignores a queue adapter assigned inside the test, so the lost-job test raised nothing. It now replaces perform_later for that test. - On Rails 7.1 SQLite, two workers that registered their processes at the same time raised "database is locked". The test now creates both workers before the threads start. - The Sidekiq snippet no longer carries the commented-out weighted line. Codex with GPT-6 Astra revised the three affected guides from change lists, and the guides embed the new example files.
1 parent e94eb79 commit bc462f4

15 files changed

Lines changed: 209 additions & 60 deletions

‎docs/guides/expiring-reservations.md‎

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,8 @@ class SeatInventory < SolidObjects::Actor
4646
end
4747

4848
def hold(hold_id:, buyer:, seats:)
49-
return hold_result(hold_id) if holds.key?(hold_id)
49+
reject(:invalid_seats, "Hold at least one seat") unless seats.is_a?(Integer) && seats.positive?
50+
return hold_result(hold_id) if active_hold?(hold_id)
5051
return { status: "confirmed" } if confirmed.key?(hold_id)
5152
reject(:not_enough_seats, "Only #{seats_available} seats are left") if seats > seats_available
5253

@@ -59,8 +60,9 @@ class SeatInventory < SolidObjects::Actor
5960
end
6061

6162
def extend_hold(hold_id:)
62-
hold = holds[hold_id]
63-
reject(:no_hold, "The hold expired or does not exist") unless hold
63+
reject(:no_hold, "The hold expired or does not exist") unless active_hold?(hold_id)
64+
65+
hold = holds.fetch(hold_id)
6466
reject(:extension_limit, "The hold cannot be extended again") if hold.fetch("extensions") >= MAX_EXTENSIONS
6567

6668
deadline = Time.at(hold.fetch("expires_at")) + EXTENSION
@@ -73,10 +75,9 @@ class SeatInventory < SolidObjects::Actor
7375

7476
def confirm(hold_id:)
7577
return { status: "confirmed" } if confirmed.key?(hold_id)
78+
reject(:no_hold, "The hold expired or does not exist") unless active_hold?(hold_id)
7679

77-
hold = holds[hold_id]
78-
reject(:no_hold, "The hold expired or does not exist") unless hold
79-
80+
hold = holds.fetch(hold_id)
8081
self.holds = holds.except(hold_id)
8182
self.confirmed = confirmed.merge(hold_id => hold.fetch("seats"))
8283
unschedule(:expire, key: hold_id)
@@ -92,8 +93,13 @@ class SeatInventory < SolidObjects::Actor
9293

9394
private
9495

96+
def active_hold?(hold_id)
97+
holds.key?(hold_id) && holds.dig(hold_id, "expires_at") > Time.current.to_i
98+
end
99+
95100
def seats_available
96-
capacity - holds.values.sum { |hold| hold.fetch("seats") } - confirmed.values.sum
101+
held_seats = holds.each_key.select { |hold_id| active_hold?(hold_id) }.sum { |hold_id| holds.dig(hold_id, "seats") }
102+
capacity - held_seats - confirmed.values.sum
97103
end
98104

99105
def hold_result(hold_id)
@@ -103,19 +109,19 @@ end
103109
```
104110

105111
- `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.
112+
- `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`. `hold` rejects a seat count that is not a positive integer with the code `invalid_seats`. Without this check, a hold for -5 seats adds seats to the show.
113+
- `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`. It accepts only an active hold: a hold whose stored deadline is still in the future. After the deadline, it rejects the request with the code `no_hold`, even before the expiry reminder runs. A stopped or slow runtime process does not extend a hold.
114+
- `confirm` moves the hold to `confirmed` and cancels the reminder with `unschedule`. A confirmation retry returns the same result. `confirm` accepts only an active hold whose stored deadline is still in the future. After the deadline, it rejects the request with the code `no_hold`, even before the expiry reminder runs.
115+
- The stored deadline is the rule. `expire` removes the old hold from the state only if the deadline in the message still matches the hold. After an extension, an expiry for the old deadline does nothing.
116+
- `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. A hold past its deadline no longer counts against the seats. `seats_left` and new holds see the seats again at the deadline.
111117

112-
All state stays bounded by the show capacity.
118+
Active holds and confirmed seats are bounded by the show capacity. An expired hold stays in the state until its reminder runs.
113119

114120
## Deadlines that survive a restart
115121

116122
Use durable reminders for persistent timers in Rails.
117123

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.
124+
`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. The deadline check in `confirm` and `extend_hold` does not wait for the reminder.
119125

120126
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.
121127

@@ -130,6 +136,8 @@ Delivery is at least once, so `expire` checks the deadline before it changes any
130136
- The actor rejects a third extension with the code `extension_limit`.
131137
- A confirmation retry returns the same result. The hold moves to `confirmed` once, and the confirmation cancels the reminder.
132138
- A confirmation after expiry receives the rejection code `no_hold`.
139+
- Past the deadline, before the reminder runs: the test moves the clock 16 minutes forward and delivers no reminder. The actor rejects the confirmation and the extension with `no_hold`, and both seats are free.
140+
- A hold for zero seats or for -5 seats is rejected with `invalid_seats`, and the free seats do not change.
133141
- A hold that falls due while the runtime is stopped expires after a restart.
134142

135143
See [the tests for this guide](../../test/guides/expiring_reservations_test.rb).

‎docs/guides/ordered-jobs.md‎

Lines changed: 44 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -65,11 +65,12 @@ Sidekiq.configure_server do |config|
6565
config.capsule("unsafe") do |cap|
6666
cap.concurrency = 1
6767
cap.queues = %w[queue_a queue_b] # strict priority
68-
# cap.queues = %w[queue_a,3 queue_b,1] # weighted
6968
end
7069
end
7170
```
7271

72+
This snippet comes from the Sidekiq wiki.
73+
7374
Solid Queue accepts `key`, `to`, `duration`, and `on_conflict` for concurrency controls. It requires `key` and sets `to` to 1 by default. A blocked job stays blocked until another job finishes or the duration expires. These controls do not guarantee execution order. Solid Queue does not use queue order to unblock jobs ([checked October 9, 2026](https://github.com/rails/solid_queue#concurrency-controls)).
7475

7576
If one serial queue is fast enough for your volume, it is the simplest choice. If order does not matter, `limits_concurrency` is enough for one job at a time per customer.
@@ -113,21 +114,50 @@ This approach has two costs:
113114
There is one `LedgerAccount` actor for each account ID.
114115

115116
```ruby
116-
class LedgerAccount < SolidObjects::Actor
117-
RECENT_ENTRY_LIMIT = 100
117+
class CreateLedgerEntries < ActiveRecord::Migration[7.1]
118+
def change
119+
create_table :ledger_entries do |table|
120+
table.string :account_id, null: false
121+
table.string :entry_id, null: false
122+
table.string :kind, null: false
123+
table.integer :amount_cents, null: false
124+
table.timestamps
125+
end
126+
add_index :ledger_entries, [ :account_id, :entry_id ], unique: true
127+
end
128+
end
129+
```
130+
131+
The migration creates a `ledger_entries` table with a unique index on the account ID and the entry ID. `LedgerEntry` is a plain Active Record model for this table.
118132

133+
```ruby
134+
SolidObjects.register_commit_action(:record_ledger_entry) do |arguments, _context|
135+
LedgerEntry.create!(
136+
account_id: arguments.fetch("account_id"),
137+
entry_id: arguments.fetch("entry_id"),
138+
kind: arguments.fetch("kind"),
139+
amount_cents: arguments.fetch("amount_cents")
140+
)
141+
end
142+
```
143+
144+
The initializer registers the commit action `record_ledger_entry`. A commit action writes application rows inside the actor transaction. The entry row and the new balance commit together or not at all. A commit action needs Solid Objects and `ActiveRecord::Base` to share one connection pool.
145+
146+
Commit actions can run again after a database rollback. Keep them deterministic, bounded, and database-only. Actor handlers can read application records. They cannot write them directly.
147+
148+
```ruby
149+
class LedgerAccount < SolidObjects::Actor
119150
attribute :balance_cents, default: 0
120-
attribute :recent_entry_ids, default: -> { [] }
121151
attribute :statement_balance_cents, default: nil
122152

123153
def apply(entry_id:, kind:, amount_cents:)
124-
return balance_cents if recent_entry_ids.include?(entry_id)
154+
return balance_cents if LedgerEntry.exists?(account_id: actor_id, entry_id:)
125155

126156
change = (kind == "deposit") ? amount_cents : -amount_cents
127157
reject(:insufficient_funds, "The balance is too low for this withdrawal") if balance_cents + change < 0
128158

129159
self.balance_cents += change
130-
self.recent_entry_ids = (recent_entry_ids + [ entry_id ]).last(RECENT_ENTRY_LIMIT)
160+
commit_action(:record_ledger_entry, account_id: actor_id, entry_id:, kind:, amount_cents:)
131161
schedule(at: 1.day.from_now, key: "daily").close_statement
132162
balance_cents
133163
end
@@ -138,6 +168,10 @@ class LedgerAccount < SolidObjects::Actor
138168
end
139169
```
140170

171+
`apply` first checks `LedgerEntry.exists?` for the account and the entry. If the row exists, `apply` returns the balance and changes nothing. Calls for one account run one at a time, so this check and the insert cannot race.
172+
173+
The unique index provides another check. A duplicate insert fails the turn. The actor does not apply the entry twice.
174+
141175
The caller sends each entry with `async` and an idempotency key:
142176

143177
```ruby
@@ -153,17 +187,18 @@ The actor treats a business rejection and an exception differently:
153187
- `reject` ends an entry with a business result, such as `insufficient_funds`. The runtime does not retry the rejection. The next entry runs.
154188
- An exception causes the runtime to retry that message. The message holds back later messages for that account until it succeeds or moves to dead letters.
155189

156-
A repeated enqueue with the same idempotency key creates one message. A repeated delivery runs the method again. `apply` checks `recent_entry_ids` and returns without a change if the list contains the entry ID.
190+
A repeated enqueue with the same idempotency key creates one message while the first message row exists. A repeated delivery runs the method again. In both cases, the `ledger_entries` row stops a second change. This record does not expire.
157191

158192
`schedule(at: 1.day.from_now, key: "daily")` keeps one statement reminder for each account. Each new entry moves the reminder. The database stores the reminder, and the reminder runs after a restart.
159193

160194
You do not assign sequence numbers. The actor mailbox gives the order.
161195

162196
## What the tests prove
163197

164-
- The tests enqueue five entries for two accounts in one mixed order. Two workers process them. Each account applies its own entries in enqueue order. The balances are 25 and 0 cents.
198+
- The tests enqueue five entries for two accounts in one mixed order. Two workers process them. Each account applies its own entries in enqueue order. The tests read the order from the `ledger_entries` rows of each account. The balances are 25 and 0 cents.
165199
- The actor rejects a withdrawal that is too large. The deposit after it applies.
166200
- Two enqueues share the same idempotency key. One more direct delivery repeats the entry. The account applies the entry once.
201+
- The account applies 102 entries. Then the first entry arrives again. The balance does not change.
167202
- The statement reminder runs after the test resets the caller process.
168203

169204
## Choose
@@ -178,7 +213,7 @@ You do not assign sequence numbers. The actor mailbox gives the order.
178213
- Run `bundle exec solid_objects start`. Async messages and reminders run only while this process runs. The database keeps unfinished work in SQL while the process does not run.
179214
- The generated policies deny every call. Write a policy. Pass `authorization_context:` to `async`. See [authorization policies](../authorization.md).
180215
- A message that fails on every attempt moves to dead letters after five attempts by default. Later messages then run. An operator can retry a dead letter. See [dead letters, retry, and redrive](../operations.md#dead-letters-retry-and-redrive).
181-
- The actor remembers the idempotency keys of its last finished turns. It retains 64 keys by default. See [retention and backups](../operations.md#retention-and-backups).
216+
- The runtime remembers the idempotency keys of the last 64 finished turns for each actor. That window is not enough for money. The `ledger_entries` table is the durable record. See [retention and backups](../operations.md#retention-and-backups).
182217

183218
## Limits
184219

‎docs/guides/race-conditions.md‎

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -109,18 +109,22 @@ class EventTickets < SolidObjects::Actor
109109
end
110110

111111
def hold(buyer:, hold_id:)
112+
release_expired_holds
112113
return { held: true, hold_id: } if holds.dig(buyer, "hold_id") == hold_id
113114
return { held: false, reason: "already_held" } if holds.key?(buyer)
114115
return { held: false, reason: "sold_out" } if seats_available.zero?
115116

117+
deadline = HOLD_DURATION.from_now
116118
self.seats_available -= 1
117-
self.holds = holds.merge(buyer => { "hold_id" => hold_id })
118-
schedule(at: HOLD_DURATION.from_now, key: buyer).expire(buyer:, hold_id:)
119+
self.holds = holds.merge(buyer => { "hold_id" => hold_id, "expires_at" => deadline.to_i })
120+
schedule(at: deadline, key: buyer).expire(buyer:, hold_id:)
119121
{ held: true, hold_id: }
120122
end
121123

122124
def confirm(buyer:, hold_id:)
123125
return { confirmed: true } if sold.include?(hold_id)
126+
127+
release_expired_holds
124128
reject(:no_hold, "The hold expired or does not exist") unless holds.dig(buyer, "hold_id") == hold_id
125129

126130
self.holds = holds.except(buyer)
@@ -142,14 +146,24 @@ class EventTickets < SolidObjects::Actor
142146
self.title = title
143147
self.revision += 1
144148
end
149+
150+
private
151+
152+
def release_expired_holds
153+
expired_buyers = holds.select { |_buyer, hold| hold.fetch("expires_at") <= Time.current.to_i }.keys
154+
self.holds = holds.except(*expired_buyers)
155+
self.seats_available += expired_buyers.length
156+
end
145157
end
146158
```
147159

148160
The actor owns the lifecycle:
149161

150-
- The `hold` method takes a seat and schedules a reminder with `schedule(at:, key: buyer)`. The database stores the reminder.
162+
- The `hold` method takes a seat and stores the deadline, `expires_at`, beside `hold_id` in the hold. It schedules a reminder with `schedule(at:, key: buyer)`. The database stores the reminder.
163+
- 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.
164+
- 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.
151165
- The `confirm` method moves the hold to `sold` and cancels the reminder with `unschedule`.
152-
- The `expire` method releases the seat only when the `hold_id` still matches. An expiry for an old hold does nothing.
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.
153167
- 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.
154168
- The `reject` method ends the call with a business result. The caller receives `SolidObjects::Rejected`. The runtime does not retry a rejection.
155169

@@ -170,6 +184,7 @@ result = EventTickets.ref(event.id.to_s).hold(
170184
- **Stale expiry:** The first hold expires. The buyer holds again with a new hold ID. The old expiry arrives again, and the new hold stays.
171185
- **Retries:** Two identical holds and two identical confirmations sell one seat.
172186
- **Confirmation after expiry:** The actor rejects the confirmation with the code `no_hold`.
187+
- **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.
173188
- **Stale form:** The actor rejects the update with the code `stale_revision`. The next section explains this check.
174189

175190
## Serial execution does not stop a stale form

‎examples/guides/expiring_reservations/seat_inventory.rb‎

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ def open_show(capacity:)
1919
end
2020

2121
def hold(hold_id:, buyer:, seats:)
22-
return hold_result(hold_id) if holds.key?(hold_id)
22+
reject(:invalid_seats, "Hold at least one seat") unless seats.is_a?(Integer) && seats.positive?
23+
return hold_result(hold_id) if active_hold?(hold_id)
2324
return { status: "confirmed" } if confirmed.key?(hold_id)
2425
reject(:not_enough_seats, "Only #{seats_available} seats are left") if seats > seats_available
2526

@@ -32,8 +33,9 @@ def hold(hold_id:, buyer:, seats:)
3233
end
3334

3435
def extend_hold(hold_id:)
35-
hold = holds[hold_id]
36-
reject(:no_hold, "The hold expired or does not exist") unless hold
36+
reject(:no_hold, "The hold expired or does not exist") unless active_hold?(hold_id)
37+
38+
hold = holds.fetch(hold_id)
3739
reject(:extension_limit, "The hold cannot be extended again") if hold.fetch("extensions") >= MAX_EXTENSIONS
3840

3941
deadline = Time.at(hold.fetch("expires_at")) + EXTENSION
@@ -46,10 +48,9 @@ def extend_hold(hold_id:)
4648

4749
def confirm(hold_id:)
4850
return { status: "confirmed" } if confirmed.key?(hold_id)
51+
reject(:no_hold, "The hold expired or does not exist") unless active_hold?(hold_id)
4952

50-
hold = holds[hold_id]
51-
reject(:no_hold, "The hold expired or does not exist") unless hold
52-
53+
hold = holds.fetch(hold_id)
5354
self.holds = holds.except(hold_id)
5455
self.confirmed = confirmed.merge(hold_id => hold.fetch("seats"))
5556
unschedule(:expire, key: hold_id)
@@ -65,8 +66,13 @@ def expire(hold_id:, expires_at:)
6566

6667
private
6768

69+
def active_hold?(hold_id)
70+
holds.key?(hold_id) && holds.dig(hold_id, "expires_at") > Time.current.to_i
71+
end
72+
6873
def seats_available
69-
capacity - holds.values.sum { |hold| hold.fetch("seats") } - confirmed.values.sum
74+
held_seats = holds.each_key.select { |hold_id| active_hold?(hold_id) }.sum { |hold_id| holds.dig(hold_id, "seats") }
75+
capacity - held_seats - confirmed.values.sum
7076
end
7177

7278
def hold_result(hold_id)
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# rbs_inline: enabled
2+
3+
class CreateLedgerEntries < ActiveRecord::Migration[7.1]
4+
def change
5+
create_table :ledger_entries do |table|
6+
table.string :account_id, null: false
7+
table.string :entry_id, null: false
8+
table.string :kind, null: false
9+
table.integer :amount_cents, null: false
10+
table.timestamps
11+
end
12+
add_index :ledger_entries, [ :account_id, :entry_id ], unique: true
13+
end
14+
end

0 commit comments

Comments
 (0)