You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit bc462f4
Browse filesBrowse the repository at this point in the historyBrowse files
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.
-`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.
111
117
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.
113
119
114
120
## Deadlines that survive a restart
115
121
116
122
Use durable reminders for persistent timers in Rails.
117
123
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.
119
125
120
126
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
127
@@ -130,6 +136,8 @@ Delivery is at least once, so `expire` checks the deadline before it changes any
130
136
- The actor rejects a third extension with the code `extension_limit`.
131
137
- A confirmation retry returns the same result. The hold moves to `confirmed` once, and the confirmation cancels the reminder.
132
138
- 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.
133
141
- A hold that falls due while the runtime is stopped expires after a restart.
134
142
135
143
See [the tests for this guide](../../test/guides/expiring_reservations_test.rb).
Copy file name to clipboardExpand all lines: docs/guides/ordered-jobs.md
+44-9Lines changed: 44 additions & 9 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -65,11 +65,12 @@ Sidekiq.configure_server do |config|
65
65
config.capsule("unsafe") do |cap|
66
66
cap.concurrency =1
67
67
cap.queues =%w[queue_a queue_b]# strict priority
68
-
# cap.queues = %w[queue_a,3 queue_b,1] # weighted
69
68
end
70
69
end
71
70
```
72
71
72
+
This snippet comes from the Sidekiq wiki.
73
+
73
74
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)).
74
75
75
76
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:
113
114
There is one `LedgerAccount` actor for each account ID.
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.
118
132
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.
@@ -138,6 +168,10 @@ class LedgerAccount < SolidObjects::Actor
138
168
end
139
169
```
140
170
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
+
141
175
The caller sends each entry with `async` and an idempotency key:
142
176
143
177
```ruby
@@ -153,17 +187,18 @@ The actor treats a business rejection and an exception differently:
153
187
-`reject` ends an entry with a business result, such as `insufficient_funds`. The runtime does not retry the rejection. The next entry runs.
154
188
- 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.
155
189
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.
157
191
158
192
`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.
159
193
160
194
You do not assign sequence numbers. The actor mailbox gives the order.
161
195
162
196
## What the tests prove
163
197
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.
165
199
- The actor rejects a withdrawal that is too large. The deposit after it applies.
166
200
- 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.
167
202
- The statement reminder runs after the test resets the caller process.
168
203
169
204
## Choose
@@ -178,7 +213,7 @@ You do not assign sequence numbers. The actor mailbox gives the order.
178
213
- 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.
179
214
- The generated policies deny every call. Write a policy. Pass `authorization_context:` to `async`. See [authorization policies](../authorization.md).
180
215
- 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).
- 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.
151
165
- 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.
153
167
- 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.
154
168
- The `reject` method ends the call with a business result. The caller receives `SolidObjects::Rejected`. The runtime does not retry a rejection.
155
169
@@ -170,6 +184,7 @@ result = EventTickets.ref(event.id.to_s).hold(
170
184
-**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.
171
185
-**Retries:** Two identical holds and two identical confirmations sell one seat.
172
186
-**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.
173
188
-**Stale form:** The actor rejects the update with the code `stale_revision`. The next section explains this check.
0 commit comments