solid_objects 0.17.1 → 0.17.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +34 -0
- data/README.md +13 -1
- data/docs/agents.md +27 -0
- data/docs/guides/expiring-reservations.md +175 -0
- data/docs/guides/ordered-jobs.md +232 -0
- data/docs/guides/race-conditions.md +236 -0
- data/docs/guides/transactional-outbox.md +156 -0
- data/examples/guides/expiring_reservations/seat_inventory.rb +81 -0
- data/examples/guides/ordered_jobs/account.rb +23 -0
- data/examples/guides/ordered_jobs/apply_entry_job.rb +9 -0
- data/examples/guides/ordered_jobs/apply_entry_unordered_job.rb +7 -0
- data/examples/guides/ordered_jobs/create_ledger_entries.rb +14 -0
- data/examples/guides/ordered_jobs/ledger_account.rb +22 -0
- data/examples/guides/ordered_jobs/ledger_entry.rb +4 -0
- data/examples/guides/ordered_jobs/solid_objects.rb +10 -0
- data/examples/guides/race_conditions/event.rb +15 -0
- data/examples/guides/race_conditions/event_tickets.rb +66 -0
- data/examples/guides/transactional_outbox/checkout.rb +22 -0
- data/examples/guides/transactional_outbox/order.rb +17 -0
- data/examples/guides/transactional_outbox/outbox_message.rb +14 -0
- data/examples/guides/transactional_outbox/shipment_job.rb +8 -0
- data/examples/guides/transactional_outbox/solid_objects.rb +12 -0
- data/examples/quickstart/README.md +3 -0
- data/lib/solid_objects/version.rb +1 -1
- metadata +23 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 218b1090ca466db8385017c23f98120d78b605b1db03a7ad69e82156c7669fe5
|
|
4
|
+
data.tar.gz: f0deae94f1fa479882d3d47bc5205c30d30b30c3aadfee663959ad7e787ff265
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 97c597751ea4330ec9857faec8bc4ef8d3449891f29abd6d99fe13709e785971c42e6d3faf24ab02835685857104ad82e20df42088a0b84a32573a8519177368
|
|
7
|
+
data.tar.gz: db9450e6f7657b630cf6d6c2eb191a57614690ef7f34d3d47fea079a5bd388d1b7bc31b84ac22667b6cdd296f0a426e29f00acb20b3473fe53af0e656d146b28
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,39 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.17.3 - 2026-10-09
|
|
4
|
+
|
|
5
|
+
- Add four problem guides in `docs/guides/`: preventing race conditions in
|
|
6
|
+
Rails, running jobs in order for each customer, expiring reservations, and
|
|
7
|
+
saving state and queuing work together. Each guide reproduces the failure,
|
|
8
|
+
shows the plain Rails fix first, and then shows a Solid Objects actor where
|
|
9
|
+
it adds value. The README and the agent guide list them.
|
|
10
|
+
- Each guide embeds tested example files from `examples/guides/`. The tests in
|
|
11
|
+
`test/guides/` prove each claim: the race and its SQL fix, ordered entries
|
|
12
|
+
under two workers, a reminder that runs after a restart, a stale expiry that
|
|
13
|
+
changes nothing, an atomic actor turn, and an effect that runs twice with one
|
|
14
|
+
idempotency key. A document test fails when a guide does not embed the
|
|
15
|
+
current example files, contains an em dash or an en dash, or links to a
|
|
16
|
+
missing file.
|
|
17
|
+
- Add `activejob` to the development and test bundle for the guide examples.
|
|
18
|
+
Compatibility runs pin it to the Rails line under test.
|
|
19
|
+
|
|
20
|
+
## 0.17.2 - 2026-10-08
|
|
21
|
+
|
|
22
|
+
- The README names the agent guide at the start of Installation, and the agent
|
|
23
|
+
guide says that a reminder changes state only when it runs under
|
|
24
|
+
`solid_objects start`, so a query must not compute expiry from the clock.
|
|
25
|
+
- `docs/agents.md` now tells agents to install the current release instead of
|
|
26
|
+
a remembered version, says that step 5 is required before the first call,
|
|
27
|
+
shows the two arguments of `reject`, and lists four API mistakes from
|
|
28
|
+
agent-written code with the correct form. The gem description now states
|
|
29
|
+
the Ruby 3.3 and Rails 7.1 requirement, because agents in the discovery
|
|
30
|
+
evaluation claimed Rails 8.0.
|
|
31
|
+
- Claim the Context7 library: `context7.json` now carries the library `url` and
|
|
32
|
+
the maintainer `public_key`.
|
|
33
|
+
- Add the Context7 refresh workflow. A push to `main` that changes the README,
|
|
34
|
+
`context7.json`, `docs/`, or `examples/` asks Context7 to refresh the index.
|
|
35
|
+
It uses the `CONTEXT7_API_KEY` repository secret.
|
|
36
|
+
|
|
3
37
|
## 0.17.1 - 2026-10-08
|
|
4
38
|
|
|
5
39
|
- Name the category in the gem metadata and the README: Solid Objects is a
|
data/README.md
CHANGED
|
@@ -39,12 +39,14 @@ And so much more.
|
|
|
39
39
|
- [Good uses](#good-uses)
|
|
40
40
|
- [When a transaction is better](#when-a-transaction-is-better)
|
|
41
41
|
- [Guarantees and boundaries](#guarantees-and-boundaries)
|
|
42
|
+
- [Guides](#guides)
|
|
42
43
|
- [Read more](#read-more)
|
|
43
44
|
- [Status and license](#status-and-license)
|
|
44
45
|
|
|
45
46
|
## Installation
|
|
46
47
|
|
|
47
|
-
Solid Objects requires Ruby 3.3 or newer and Rails 7.1 or newer.
|
|
48
|
+
Solid Objects requires Ruby 3.3 or newer and Rails 7.1 or newer. Coding agents
|
|
49
|
+
should follow the [agent guide](docs/agents.md), which gives each step in order.
|
|
48
50
|
|
|
49
51
|
```bash
|
|
50
52
|
bundle add solid_objects
|
|
@@ -163,6 +165,16 @@ SQL and should be allowed to enjoy that.
|
|
|
163
165
|
Exactly once is not hiding in a more advanced configuration. Read the
|
|
164
166
|
[correctness contract](docs/correctness.md) before using important data.
|
|
165
167
|
|
|
168
|
+
## Guides
|
|
169
|
+
|
|
170
|
+
Each guide starts from a problem, shows the plain Rails fix first, and tests
|
|
171
|
+
every claim in [`test/guides/`](test/guides/):
|
|
172
|
+
|
|
173
|
+
- [Prevent race conditions in Rails](docs/guides/race-conditions.md)
|
|
174
|
+
- [Run jobs in order for each customer](docs/guides/ordered-jobs.md)
|
|
175
|
+
- [Expiring reservations](docs/guides/expiring-reservations.md)
|
|
176
|
+
- [Save state and queue work together](docs/guides/transactional-outbox.md)
|
|
177
|
+
|
|
166
178
|
## Read more
|
|
167
179
|
|
|
168
180
|
- [Five-minute Rails guide](https://solidobjects.dev/5min/rails)
|
data/docs/agents.md
CHANGED
|
@@ -30,6 +30,13 @@ Select a simpler tool in these cases:
|
|
|
30
30
|
The full checklist is in [Choosing Solid Objects](fit.md). The category guide
|
|
31
31
|
is [Virtual actors in Ruby on Rails](virtual-actors.md).
|
|
32
32
|
|
|
33
|
+
Problem guides compare the plain Rails fix with an actor and test each claim:
|
|
34
|
+
|
|
35
|
+
- [Prevent race conditions in Rails](guides/race-conditions.md)
|
|
36
|
+
- [Run jobs in order for each customer](guides/ordered-jobs.md)
|
|
37
|
+
- [Expiring reservations](guides/expiring-reservations.md)
|
|
38
|
+
- [Save state and queue work together](guides/transactional-outbox.md)
|
|
39
|
+
|
|
33
40
|
## 2. Package identity
|
|
34
41
|
|
|
35
42
|
| Item | Value |
|
|
@@ -64,6 +71,12 @@ bin/rails solid_objects:doctor
|
|
|
64
71
|
The generator adds an initializer and copies migrations into the application.
|
|
65
72
|
The doctor checks the configuration, the tables, and one real actor round trip.
|
|
66
73
|
|
|
74
|
+
Install the current release. `bundle add solid_objects` selects it. Do not pin
|
|
75
|
+
a version that you remember from earlier work; the API changed between
|
|
76
|
+
releases. The current version is on <https://rubygems.org/gems/solid_objects>.
|
|
77
|
+
|
|
78
|
+
The generated policies deny every call. Do step 5 before you call an actor.
|
|
79
|
+
|
|
67
80
|
The `json` gem 3.x works only with Active Support 8.1.4 or newer. On Rails
|
|
68
81
|
7.1, 7.2, 8.0, or 8.1 before 8.1.4, pin `gem "json", "~> 2"` in the
|
|
69
82
|
`Gemfile`. Without the pin, Active Support raises an `ArgumentError`, such as
|
|
@@ -153,11 +166,25 @@ Obey these rules in actor code:
|
|
|
153
166
|
- Use `schedule(at:, key:)` for delayed work. A reminder is one named alarm
|
|
154
167
|
for each actor and key. A new `schedule` with the same key moves the alarm.
|
|
155
168
|
- Use `reject(code, message)` for a business rule failure that must not retry.
|
|
169
|
+
It takes a code and a message, for example
|
|
170
|
+
`reject(:room_full, "The room is full")`.
|
|
156
171
|
- Do not write Active Record models directly in a handler. The runtime raises
|
|
157
172
|
`SolidObjects::ApplicationWriteForbidden`. Use `commit_action` for a short
|
|
158
173
|
write in the same database.
|
|
159
174
|
- Do not call an external API in a handler. Use `emit` and an effect handler.
|
|
160
175
|
- Write each handler so that it can run again. Delivery is at least once.
|
|
176
|
+
- A reminder changes state only when it runs, and it runs only while
|
|
177
|
+
`solid_objects start` runs. Do not compute expiry from the clock in a query;
|
|
178
|
+
read the state that the reminder committed.
|
|
179
|
+
|
|
180
|
+
Avoid these mistakes:
|
|
181
|
+
|
|
182
|
+
| Mistake | Correct form |
|
|
183
|
+
| --- | --- |
|
|
184
|
+
| `schedule(at: deadline)` with no operation after it | `schedule(at: deadline, key: buyer).expire(buyer:)`. `schedule` stages a reminder only when you call an operation on its result |
|
|
185
|
+
| `reject "room full"` | `reject(:room_full, "The room is full")` |
|
|
186
|
+
| `id` inside an actor | `actor_id`. An actor has no `id` method |
|
|
187
|
+
| `register_effect(:name) { \|context, arguments\| ... }` | `register_effect(:name) { \|arguments, context\| ... }`. The arguments come first |
|
|
161
188
|
|
|
162
189
|
[Reminders](reminders.md) and the [architecture guide](architecture.md) give
|
|
163
190
|
the full actor API.
|
|
@@ -0,0 +1,175 @@
|
|
|
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
|
+
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)
|
|
51
|
+
return { status: "confirmed" } if confirmed.key?(hold_id)
|
|
52
|
+
reject(:not_enough_seats, "Only #{seats_available} seats are left") if seats > seats_available
|
|
53
|
+
|
|
54
|
+
deadline = HOLD_DURATION.from_now
|
|
55
|
+
self.holds = holds.merge(
|
|
56
|
+
hold_id => { "buyer" => buyer, "seats" => seats, "expires_at" => deadline.to_i, "extensions" => 0 }
|
|
57
|
+
)
|
|
58
|
+
schedule(at: deadline, key: hold_id).expire(hold_id:, expires_at: deadline.to_i)
|
|
59
|
+
hold_result(hold_id)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def extend_hold(hold_id:)
|
|
63
|
+
reject(:no_hold, "The hold expired or does not exist") unless active_hold?(hold_id)
|
|
64
|
+
|
|
65
|
+
hold = holds.fetch(hold_id)
|
|
66
|
+
reject(:extension_limit, "The hold cannot be extended again") if hold.fetch("extensions") >= MAX_EXTENSIONS
|
|
67
|
+
|
|
68
|
+
deadline = Time.at(hold.fetch("expires_at")) + EXTENSION
|
|
69
|
+
self.holds = holds.merge(
|
|
70
|
+
hold_id => hold.merge("expires_at" => deadline.to_i, "extensions" => hold.fetch("extensions") + 1)
|
|
71
|
+
)
|
|
72
|
+
schedule(at: deadline, key: hold_id).expire(hold_id:, expires_at: deadline.to_i)
|
|
73
|
+
hold_result(hold_id)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def confirm(hold_id:)
|
|
77
|
+
return { status: "confirmed" } if confirmed.key?(hold_id)
|
|
78
|
+
reject(:no_hold, "The hold expired or does not exist") unless active_hold?(hold_id)
|
|
79
|
+
|
|
80
|
+
hold = holds.fetch(hold_id)
|
|
81
|
+
self.holds = holds.except(hold_id)
|
|
82
|
+
self.confirmed = confirmed.merge(hold_id => hold.fetch("seats"))
|
|
83
|
+
unschedule(:expire, key: hold_id)
|
|
84
|
+
{ status: "confirmed" }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def expire(hold_id:, expires_at:)
|
|
88
|
+
return seats_available unless holds.dig(hold_id, "expires_at") == expires_at
|
|
89
|
+
|
|
90
|
+
self.holds = holds.except(hold_id)
|
|
91
|
+
seats_available
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def active_hold?(hold_id)
|
|
97
|
+
holds.key?(hold_id) && holds.dig(hold_id, "expires_at") > Time.current.to_i
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def seats_available
|
|
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
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def hold_result(hold_id)
|
|
106
|
+
{ status: "held", expires_at: holds.dig(hold_id, "expires_at") }
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
- `open_show` sets the capacity once.
|
|
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.
|
|
117
|
+
|
|
118
|
+
Active holds and confirmed seats are bounded by the show capacity. An expired hold stays in the state until its reminder runs.
|
|
119
|
+
|
|
120
|
+
## Deadlines that survive a restart
|
|
121
|
+
|
|
122
|
+
Use durable reminders for persistent timers in Rails.
|
|
123
|
+
|
|
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.
|
|
125
|
+
|
|
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.
|
|
127
|
+
|
|
128
|
+
Delivery is at least once, so `expire` checks the deadline before it changes anything. See [reminders](../reminders.md).
|
|
129
|
+
|
|
130
|
+
## What the tests prove
|
|
131
|
+
|
|
132
|
+
- Eight concurrent holds request one seat each against a capacity of five. Five succeed, and three receive the rejection code `not_enough_seats`.
|
|
133
|
+
- A hold retry takes its seats once.
|
|
134
|
+
- An extension moves the reminder to the new deadline. At 16 minutes, nothing runs. At 21 minutes, the expiry runs and the seats return.
|
|
135
|
+
- An expiry for the old deadline changes nothing after an extension.
|
|
136
|
+
- The actor rejects a third extension with the code `extension_limit`.
|
|
137
|
+
- A confirmation retry returns the same result. The hold moves to `confirmed` once, and the confirmation cancels the reminder.
|
|
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.
|
|
141
|
+
- A hold that falls due while the runtime is stopped expires after a restart.
|
|
142
|
+
|
|
143
|
+
See [the tests for this guide](../../test/guides/expiring_reservations_test.rb).
|
|
144
|
+
|
|
145
|
+
## Payment and other side effects
|
|
146
|
+
|
|
147
|
+
Do not call a payment provider inside the actor. An effect can run more than once.
|
|
148
|
+
|
|
149
|
+
- Stage the payment with `emit`.
|
|
150
|
+
- Pass `context.id` to the provider as the idempotency key.
|
|
151
|
+
- Confirm the hold in the actor after the payment succeeds.
|
|
152
|
+
|
|
153
|
+
See [the transactional outbox guide](transactional-outbox.md).
|
|
154
|
+
|
|
155
|
+
## Run it in production
|
|
156
|
+
|
|
157
|
+
- Authorization: the generated policies deny every call. Write a policy. Pass `authorization_context:` on each call. See [authorization policies](../authorization.md).
|
|
158
|
+
- Run `bundle exec solid_objects start` beside the web process.
|
|
159
|
+
- 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).
|
|
160
|
+
|
|
161
|
+
## Limits
|
|
162
|
+
|
|
163
|
+
- Solid Objects requires Ruby 3.3 or newer and Rails 7.1 or newer.
|
|
164
|
+
- Delivery is at least once. Each operation must be safe to run again.
|
|
165
|
+
- There are no transactions across actors. A reservation that spans two shows needs its own design.
|
|
166
|
+
- One busy show runs its calls one at a time. Many shows run at the same time.
|
|
167
|
+
- The gem is pre-1.0 and makes no production-ready claim.
|
|
168
|
+
|
|
169
|
+
## More information
|
|
170
|
+
|
|
171
|
+
- [The example file](../../examples/guides/expiring_reservations/seat_inventory.rb)
|
|
172
|
+
- [The tests for this guide](../../test/guides/expiring_reservations_test.rb)
|
|
173
|
+
- [Prevent race conditions in Rails](race-conditions.md)
|
|
174
|
+
- [Correctness and delivery semantics](../correctness.md)
|
|
175
|
+
- [Virtual actors in Ruby on Rails](../virtual-actors.md)
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
# Run jobs in order for each customer in Rails
|
|
2
|
+
|
|
3
|
+
A Rails job queue does not keep one customer's job order when several workers run jobs or a job retries. You can serialize one queue, limit concurrency for each customer, or check a sequence number in the database. Solid Objects (the `solid_objects` gem) fits when each customer needs ordered commands, durable state, reminders, and recovery after a restart. Different customers still progress at the same time.
|
|
4
|
+
|
|
5
|
+
## Reproduce the problem
|
|
6
|
+
|
|
7
|
+
An account starts with a balance of 0 cents. The caller enqueues a deposit of 100 cents, then a withdrawal of 80 cents. If the withdrawal runs first, it fails because the balance is 0 cents.
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
class Account < ApplicationRecord
|
|
11
|
+
class InsufficientFunds < StandardError; end
|
|
12
|
+
class OutOfSequence < StandardError; end
|
|
13
|
+
|
|
14
|
+
def apply_entry!(kind:, amount_cents:)
|
|
15
|
+
change = (kind == "deposit") ? amount_cents : -amount_cents
|
|
16
|
+
raise InsufficientFunds, "balance #{balance_cents}, change #{change}" if balance_cents + change < 0
|
|
17
|
+
|
|
18
|
+
update!(balance_cents: balance_cents + change)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def apply_entry_in_sequence!(sequence:, kind:, amount_cents:)
|
|
22
|
+
with_lock do
|
|
23
|
+
raise OutOfSequence, "expected #{next_sequence}, received #{sequence}" if sequence > next_sequence
|
|
24
|
+
next if sequence < next_sequence
|
|
25
|
+
|
|
26
|
+
apply_entry!(kind:, amount_cents:)
|
|
27
|
+
update!(next_sequence: next_sequence + 1)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`apply_entry!` adds a deposit or subtracts a withdrawal, then updates the balance. It raises `Account::InsufficientFunds` if the result is below 0. The later section uses `apply_entry_in_sequence!` to fix the order.
|
|
34
|
+
|
|
35
|
+
The plain job calls `apply_entry!`:
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
class ApplyEntryUnorderedJob < ApplicationJob
|
|
39
|
+
def perform(account_id:, kind:, amount_cents:)
|
|
40
|
+
Account.find(account_id).apply_entry!(kind:, amount_cents:)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Order breaks for these reasons:
|
|
46
|
+
|
|
47
|
+
- Several workers take jobs from the queue at the same time.
|
|
48
|
+
- A job that fails and retries runs after newer jobs.
|
|
49
|
+
- A job that waits for a lock can finish after a later job.
|
|
50
|
+
|
|
51
|
+
The test performs the withdrawal job before the deposit job. The withdrawal raises `Account::InsufficientFunds`. See [the ordered jobs tests](../../test/guides/ordered_jobs_test.rb).
|
|
52
|
+
|
|
53
|
+
## Native options
|
|
54
|
+
|
|
55
|
+
| Option | What it gives | What it costs |
|
|
56
|
+
| --- | --- | --- |
|
|
57
|
+
| One serial queue (a Sidekiq capsule with concurrency 1) | One job at a time for the queues of that capsule, in each Sidekiq process | Every customer in that queue waits for every other customer. Sidekiq sets concurrency for each process. One order across the deployment needs one process for that queue. |
|
|
58
|
+
| Solid Queue `limits_concurrency` with the customer as the key | At most `to` jobs at a time for each key (`to` is 1 by default) | Solid Queue gives “no guarantee about the order of execution, only about jobs being performed at the same time”. |
|
|
59
|
+
| A sequence number and a row lock | Order for each customer | The code that enqueues must assign the sequence numbers. An early job raises an exception and retries. A job that fails on each attempt holds back the later jobs. |
|
|
60
|
+
|
|
61
|
+
Sidekiq supports capsules from Sidekiq 7.0. A capsule can provide serial execution for a queue. The wiki says, “Do not declare a capsule for each queue.” Concurrency applies to each process. By default, one Sidekiq process creates five threads ([checked October 9, 2026](https://github.com/sidekiq/sidekiq/wiki/Advanced-Options#capsules)).
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
Sidekiq.configure_server do |config|
|
|
65
|
+
config.capsule("unsafe") do |cap|
|
|
66
|
+
cap.concurrency = 1
|
|
67
|
+
cap.queues = %w[queue_a queue_b] # strict priority
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
This snippet comes from the Sidekiq wiki.
|
|
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)).
|
|
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.
|
|
77
|
+
|
|
78
|
+
## Keep order with a sequence number
|
|
79
|
+
|
|
80
|
+
`apply_entry_in_sequence!` locks the account row and checks the sequence number:
|
|
81
|
+
|
|
82
|
+
- If the entry arrives early, the method raises `Account::OutOfSequence`.
|
|
83
|
+
- If the account already applied that sequence, the method skips the entry.
|
|
84
|
+
- If the entry has the expected sequence, the method applies it and moves `next_sequence` forward.
|
|
85
|
+
|
|
86
|
+
The job calls this method:
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
class ApplyEntryJob < ApplicationJob
|
|
90
|
+
retry_on Account::OutOfSequence, wait: 5.seconds, attempts: 20
|
|
91
|
+
|
|
92
|
+
def perform(account_id:, sequence:, kind:, amount_cents:)
|
|
93
|
+
Account.find(account_id).apply_entry_in_sequence!(sequence:, kind:, amount_cents:)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
`retry_on` re-enqueues the job when its entry arrives early.
|
|
99
|
+
|
|
100
|
+
The tests show these results:
|
|
101
|
+
|
|
102
|
+
- Sequence 2 first raises `Account::OutOfSequence`.
|
|
103
|
+
- Sequences 1 and 2 then apply in order.
|
|
104
|
+
- A repeated sequence 1 causes no change.
|
|
105
|
+
- The job enqueues a retry when its entry arrives early.
|
|
106
|
+
|
|
107
|
+
This approach has two costs:
|
|
108
|
+
|
|
109
|
+
- The caller allocates the sequence numbers.
|
|
110
|
+
- A broken entry blocks the account until someone fixes it.
|
|
111
|
+
|
|
112
|
+
## One actor for each account
|
|
113
|
+
|
|
114
|
+
There is one `LedgerAccount` actor for each account ID.
|
|
115
|
+
|
|
116
|
+
```ruby
|
|
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.
|
|
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.
|
|
147
|
+
|
|
148
|
+
```ruby
|
|
149
|
+
class LedgerAccount < SolidObjects::Actor
|
|
150
|
+
attribute :balance_cents, default: 0
|
|
151
|
+
attribute :statement_balance_cents, default: nil
|
|
152
|
+
|
|
153
|
+
def apply(entry_id:, kind:, amount_cents:)
|
|
154
|
+
return balance_cents if LedgerEntry.exists?(account_id: actor_id, entry_id:)
|
|
155
|
+
|
|
156
|
+
change = (kind == "deposit") ? amount_cents : -amount_cents
|
|
157
|
+
reject(:insufficient_funds, "The balance is too low for this withdrawal") if balance_cents + change < 0
|
|
158
|
+
|
|
159
|
+
self.balance_cents += change
|
|
160
|
+
commit_action(:record_ledger_entry, account_id: actor_id, entry_id:, kind:, amount_cents:)
|
|
161
|
+
schedule(at: 1.day.from_now, key: "daily").close_statement
|
|
162
|
+
balance_cents
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def close_statement
|
|
166
|
+
self.statement_balance_cents = balance_cents
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
```
|
|
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
|
+
|
|
175
|
+
The caller sends each entry with `async` and an idempotency key:
|
|
176
|
+
|
|
177
|
+
```ruby
|
|
178
|
+
LedgerAccount.ref(account.id.to_s)
|
|
179
|
+
.async(idempotency_key: entry.id.to_s, authorization_context: Current.user)
|
|
180
|
+
.apply(entry_id: entry.id.to_s, kind: entry.kind, amount_cents: entry.amount_cents)
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
The runtime stores the message in SQL before `async` returns. Messages for one actor run one at a time, in the order the runtime receives them. Different accounts run at the same time on different workers.
|
|
184
|
+
|
|
185
|
+
The actor treats a business rejection and an exception differently:
|
|
186
|
+
|
|
187
|
+
- `reject` ends an entry with a business result, such as `insufficient_funds`. The runtime does not retry the rejection. The next entry runs.
|
|
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.
|
|
189
|
+
|
|
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.
|
|
191
|
+
|
|
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.
|
|
193
|
+
|
|
194
|
+
You do not assign sequence numbers. The actor mailbox gives the order.
|
|
195
|
+
|
|
196
|
+
## What the tests prove
|
|
197
|
+
|
|
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.
|
|
199
|
+
- The actor rejects a withdrawal that is too large. The deposit after it applies.
|
|
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.
|
|
202
|
+
- The statement reminder runs after the test resets the caller process.
|
|
203
|
+
|
|
204
|
+
## Choose
|
|
205
|
+
|
|
206
|
+
- Independent jobs: use a normal queue.
|
|
207
|
+
- One job at a time for each customer, order not important: use `limits_concurrency`.
|
|
208
|
+
- Low volume and one order for everything: use one serial queue.
|
|
209
|
+
- Order for each customer plus durable state, reminders, and recovery: use an actor for each customer.
|
|
210
|
+
|
|
211
|
+
## Run it in production
|
|
212
|
+
|
|
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.
|
|
214
|
+
- The generated policies deny every call. Write a policy. Pass `authorization_context:` to `async`. See [authorization policies](../authorization.md).
|
|
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).
|
|
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).
|
|
217
|
+
|
|
218
|
+
## Limits
|
|
219
|
+
|
|
220
|
+
- Solid Objects requires Ruby 3.3 or newer and Rails 7.1 or newer.
|
|
221
|
+
- Delivery is at least once. Each operation must be safe to run again.
|
|
222
|
+
- The gem provides no transactions across actors. A transfer between two accounts needs its own design, for example one SQL transaction on normal tables.
|
|
223
|
+
- One busy account runs its entries one at a time.
|
|
224
|
+
- The gem is pre-1.0 and makes no production-ready claim.
|
|
225
|
+
|
|
226
|
+
## More information
|
|
227
|
+
|
|
228
|
+
- [The example files](../../examples/guides/ordered_jobs/ledger_account.rb)
|
|
229
|
+
- [The tests for this guide](../../test/guides/ordered_jobs_test.rb)
|
|
230
|
+
- [Correctness and delivery semantics](../correctness.md)
|
|
231
|
+
- [Reminders](../reminders.md)
|
|
232
|
+
- [Prevent race conditions in Rails](race-conditions.md)
|