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
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
# Prevent race conditions in Rails
|
|
2
|
+
|
|
3
|
+
Most Rails races need a database constraint, an atomic update, a row lock, or optimistic locking. A virtual actor helps when one resource has a lifecycle. Requests, jobs, and timers change it, and these changes must occur in order and survive a restart. Solid Objects (the `solid_objects` gem) provides that actor on the SQL database that the application already uses.
|
|
4
|
+
|
|
5
|
+
## Choose the right tool
|
|
6
|
+
|
|
7
|
+
| Problem | Start with | When Solid Objects becomes relevant |
|
|
8
|
+
| --- | --- | --- |
|
|
9
|
+
| Duplicate records | A unique index in the database | A larger entity lifecycle also needs ordered durable work |
|
|
10
|
+
| Concurrent increments or an inventory decrement | An atomic SQL update or a short transaction | The operation is part of holds, expiry, retries, and later commands |
|
|
11
|
+
| Two people edit from an old form | Optimistic locking or a revision check | The entity also needs coordination across jobs and requests |
|
|
12
|
+
| Several database changes in one request | A transaction and the correct row locks | Work must continue after that transaction and survive failures |
|
|
13
|
+
| Commands that arrive through requests, jobs, and reminders | An explicit coordination design | This is the main use for an actor. The rest of this guide shows it |
|
|
14
|
+
|
|
15
|
+
## Use the Rails tools first
|
|
16
|
+
|
|
17
|
+
### Unique index
|
|
18
|
+
|
|
19
|
+
A Rails uniqueness validation does not create a uniqueness constraint in the database. Two database connections can create two records with the same value. The validation alone does not stop duplicates.
|
|
20
|
+
|
|
21
|
+
Create a unique index on the column in the database. See the [Rails Guides, section 2.10: uniqueness](https://guides.rubyonrails.org/active_record_validations.html) (checked October 9, 2026).
|
|
22
|
+
|
|
23
|
+
### Pessimistic locking
|
|
24
|
+
|
|
25
|
+
Rails supports row-level locks through `SELECT … FOR UPDATE`. The `with_lock` method wraps the block in a transaction. It reloads the object with a lock before it runs the block.
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
event.with_lock do
|
|
29
|
+
event.update!(seats_available: event.seats_available - 1) if event.seats_available.positive?
|
|
30
|
+
end
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
See the [Rails pessimistic locking reference](https://api.rubyonrails.org/classes/ActiveRecord/Locking/Pessimistic.html) (checked October 9, 2026).
|
|
34
|
+
|
|
35
|
+
### Optimistic locking
|
|
36
|
+
|
|
37
|
+
Active Record uses an integer `lock_version` column for optimistic locking. Each update increments `lock_version`. A stale save raises `ActiveRecord::StaleObjectError`.
|
|
38
|
+
|
|
39
|
+
The Rails documentation recommends a hidden `lock_version` field in the form. That field lets the check work across web requests. See the [Rails optimistic locking reference](https://api.rubyonrails.org/classes/ActiveRecord/Locking/Optimistic.html) (checked October 9, 2026).
|
|
40
|
+
|
|
41
|
+
## A worked example: ticket holds
|
|
42
|
+
|
|
43
|
+
An event has a fixed number of seats. Buyers hold seats before they pay.
|
|
44
|
+
|
|
45
|
+
### Step 1: Reproduce the race
|
|
46
|
+
|
|
47
|
+
Two requests load the same event. Each request sees one free seat. Each request writes the value it computed. Both holds succeed, although the event has only one seat.
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
class Event < ApplicationRecord
|
|
51
|
+
def hold_seat_unsafely
|
|
52
|
+
return false if seats_available.zero?
|
|
53
|
+
|
|
54
|
+
update!(seats_available: seats_available - 1)
|
|
55
|
+
true
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def hold_seat
|
|
59
|
+
self.class.where(id:).where("seats_available > 0")
|
|
60
|
+
.update_all("seats_available = seats_available - 1") == 1
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The `hold_seat_unsafely` method shows the race. The `hold_seat` method provides the fix in step 2.
|
|
66
|
+
|
|
67
|
+
The test reproduces the race without timed delays. It loads two copies of the event, then holds a seat with each copy. Both holds succeed. See [the race condition tests](../../test/guides/race_conditions_test.rb).
|
|
68
|
+
|
|
69
|
+
### Step 2: Fix it with one SQL statement
|
|
70
|
+
|
|
71
|
+
The `hold_seat` method lets the database decide. The `UPDATE` changes the row only when a seat is free. The method returns `true` only when the statement changes one row.
|
|
72
|
+
|
|
73
|
+
In the test, ten requests load the event before they wait at a barrier. Then they try to hold seats at the same time. The event has three seats. Exactly three holds succeed.
|
|
74
|
+
|
|
75
|
+
Stop here if a hold never expires and no later operation changes it. An atomic SQL update is the correct fix for a simple counter.
|
|
76
|
+
|
|
77
|
+
### Step 3: The lifecycle needs more than a lock
|
|
78
|
+
|
|
79
|
+
A real ticket hold has more requirements:
|
|
80
|
+
|
|
81
|
+
- A hold expires after 10 minutes if the buyer does not pay.
|
|
82
|
+
- The buyer confirms the hold after payment.
|
|
83
|
+
- A client retries a hold or a confirmation after a timeout.
|
|
84
|
+
- The process restarts before the holds expire.
|
|
85
|
+
- An expiry that arrives late must not release a newer hold.
|
|
86
|
+
|
|
87
|
+
A pure SQL design needs a holds table, an expiry job, retry keys, and recovery after a restart. Every path must take the same lock in the same order.
|
|
88
|
+
|
|
89
|
+
### Step 4: One actor owns the event
|
|
90
|
+
|
|
91
|
+
There is one `EventTickets` actor for each event ID. Calls for one actor run one at a time, in order. Different events can run at the same time.
|
|
92
|
+
|
|
93
|
+
```ruby
|
|
94
|
+
class EventTickets < SolidObjects::Actor
|
|
95
|
+
HOLD_DURATION = 10.minutes
|
|
96
|
+
|
|
97
|
+
attribute :opened, default: false
|
|
98
|
+
attribute :seats_available, default: 0
|
|
99
|
+
attribute :holds, default: -> { {} }
|
|
100
|
+
attribute :sold, default: -> { [] }
|
|
101
|
+
attribute :title, default: ""
|
|
102
|
+
attribute :revision, default: 0
|
|
103
|
+
|
|
104
|
+
def open_sales(seats:)
|
|
105
|
+
return seats_available if opened
|
|
106
|
+
|
|
107
|
+
self.opened = true
|
|
108
|
+
self.seats_available = seats
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def hold(buyer:, hold_id:)
|
|
112
|
+
release_expired_holds
|
|
113
|
+
return { held: true, hold_id: } if holds.dig(buyer, "hold_id") == hold_id
|
|
114
|
+
return { held: false, reason: "already_held" } if holds.key?(buyer)
|
|
115
|
+
return { held: false, reason: "sold_out" } if seats_available.zero?
|
|
116
|
+
|
|
117
|
+
deadline = HOLD_DURATION.from_now
|
|
118
|
+
self.seats_available -= 1
|
|
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:, expires_at: deadline.to_i)
|
|
121
|
+
{ held: true, hold_id: }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def confirm(buyer:, hold_id:)
|
|
125
|
+
return { confirmed: true } if sold.include?(hold_id)
|
|
126
|
+
|
|
127
|
+
release_expired_holds
|
|
128
|
+
reject(:no_hold, "The hold expired or does not exist") unless holds.dig(buyer, "hold_id") == hold_id
|
|
129
|
+
|
|
130
|
+
self.holds = holds.except(buyer)
|
|
131
|
+
self.sold = sold + [ hold_id ]
|
|
132
|
+
unschedule(:expire, key: buyer)
|
|
133
|
+
{ confirmed: true }
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def expire(buyer:, hold_id:, expires_at:)
|
|
137
|
+
return seats_available unless holds[buyer] == { "hold_id" => hold_id, "expires_at" => expires_at }
|
|
138
|
+
|
|
139
|
+
self.holds = holds.except(buyer)
|
|
140
|
+
self.seats_available += 1
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def update_details(title:, base_revision:)
|
|
144
|
+
reject(:stale_revision, "Reload the event and try again") unless base_revision == revision
|
|
145
|
+
|
|
146
|
+
self.title = title
|
|
147
|
+
self.revision += 1
|
|
148
|
+
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
|
|
157
|
+
end
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
The actor owns the lifecycle:
|
|
161
|
+
|
|
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.
|
|
165
|
+
- The `confirm` method moves the hold to `sold` and cancels the reminder with `unschedule`.
|
|
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.
|
|
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.
|
|
168
|
+
- The `reject` method ends the call with a business result. The caller receives `SolidObjects::Rejected`. The runtime does not retry a rejection.
|
|
169
|
+
|
|
170
|
+
Call the actor from a controller:
|
|
171
|
+
|
|
172
|
+
```ruby
|
|
173
|
+
result = EventTickets.ref(event.id.to_s).hold(
|
|
174
|
+
buyer: Current.user.id.to_s,
|
|
175
|
+
hold_id: params.require(:hold_id),
|
|
176
|
+
authorization_context: Current.user
|
|
177
|
+
)
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
### Step 5: What the tests prove
|
|
181
|
+
|
|
182
|
+
- **Concurrent holds:** Ten concurrent hold calls against three seats produce exactly three successful holds.
|
|
183
|
+
- **Restart before expiry:** The test resets the caller process. It runs due reminders at 9 minutes and at 11 minutes. Nothing runs at 9 minutes. The expiry runs at 11 minutes, and the seat returns.
|
|
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.
|
|
185
|
+
- **Retries:** Two identical holds and two identical confirmations sell one seat.
|
|
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.
|
|
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.
|
|
189
|
+
- **Stale form:** The actor rejects the update with the code `stale_revision`. The next section explains this check.
|
|
190
|
+
|
|
191
|
+
## Serial execution does not stop a stale form
|
|
192
|
+
|
|
193
|
+
An actor runs one call at a time. A stale form can still replace newer data. If two people load revision 0 and both submit, the second submit still runs after the first.
|
|
194
|
+
|
|
195
|
+
Use a domain operation or a revision check. The example's `update_details` method rejects a call when `base_revision` differs from the current revision.
|
|
196
|
+
|
|
197
|
+
## Run it in production
|
|
198
|
+
|
|
199
|
+
### Authorization
|
|
200
|
+
|
|
201
|
+
The install generator creates policies that deny every call. Write a policy that checks the caller and the actor type. For example, the policy can check a signed-in user.
|
|
202
|
+
|
|
203
|
+
Pass `authorization_context:` on each call. An actor ID is not a permission. See [authorization policies](../authorization.md).
|
|
204
|
+
|
|
205
|
+
### The runtime process
|
|
206
|
+
|
|
207
|
+
Reminders run only while `bundle exec solid_objects start` runs. SQL keeps the unfinished work while the runtime process is down. The runtime process runs an overdue reminder after it starts again. See [reminders](../reminders.md).
|
|
208
|
+
|
|
209
|
+
### Retention cost
|
|
210
|
+
|
|
211
|
+
Every actor call creates a durable message row. The default `message_retention` keeps terminal message history for 30 days. Actor state stays until you destroy the actor or configure `instance_retention_by_actor_type`.
|
|
212
|
+
|
|
213
|
+
In this example, the event capacity bounds the state. See [retention and backups](../operations.md#retention-and-backups).
|
|
214
|
+
|
|
215
|
+
### External effects
|
|
216
|
+
|
|
217
|
+
Do not call a payment provider inside the actor. Use `emit` and an effect handler. Pass `context.id` to the provider as the idempotency key.
|
|
218
|
+
|
|
219
|
+
Delivery is at least once, so an effect can run more than once. See [effect idempotency](../agents.md#8-make-external-effects-idempotent).
|
|
220
|
+
|
|
221
|
+
## Limits
|
|
222
|
+
|
|
223
|
+
- Solid Objects requires Ruby 3.3 or newer and Rails 7.1 or newer.
|
|
224
|
+
- Delivery is at least once, not exactly once. Write each operation so that it can run again.
|
|
225
|
+
- There are no transactions across actors. A change that must touch two events atomically needs one SQL transaction on normal tables.
|
|
226
|
+
- One busy event runs its calls one at a time. Many events can run at the same time.
|
|
227
|
+
- The gem is pre-1.0 and makes no production-ready claim.
|
|
228
|
+
|
|
229
|
+
## More information
|
|
230
|
+
|
|
231
|
+
- [The example files](../../examples/guides/race_conditions/event_tickets.rb)
|
|
232
|
+
- [The tests for this guide](../../test/guides/race_conditions_test.rb)
|
|
233
|
+
- [Correctness and delivery semantics](../correctness.md)
|
|
234
|
+
- [Virtual actors in Ruby on Rails](../virtual-actors.md)
|
|
235
|
+
- [Expiring reservations](expiring-reservations.md)
|
|
236
|
+
- [Ordered jobs for each customer](ordered-jobs.md)
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# Save state and queue work together in Rails
|
|
2
|
+
|
|
3
|
+
A database commit and a job enqueue are two separate steps. If the process stops between them, the database keeps the data, but the queue receives no work. The transactional outbox pattern writes the work into the same database transaction as the data. A separate process delivers the work later. A Solid Objects actor, from the `solid_objects` gem, commits its state, its database writes, and its staged effects in one transaction.
|
|
4
|
+
|
|
5
|
+
## Reproduce the lost job
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
class Order < ApplicationRecord
|
|
9
|
+
def self.place_and_enqueue!(reference:, total_cents:)
|
|
10
|
+
order = create!(reference:, total_cents:)
|
|
11
|
+
ShipmentJob.perform_later(order_id: order.id)
|
|
12
|
+
order
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.place_with_outbox!(reference:, total_cents:)
|
|
16
|
+
transaction do
|
|
17
|
+
order = create!(reference:, total_cents:)
|
|
18
|
+
OutboxMessage.create!(name: "request_shipment", arguments: { "order_id" => order.id })
|
|
19
|
+
order
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
class ShipmentJob < ApplicationJob
|
|
27
|
+
def perform(order_id:)
|
|
28
|
+
order = Order.find(order_id)
|
|
29
|
+
ShippingProvider.create_shipment(idempotency_key: "order-#{order.id}", order_reference: order.reference)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
In `place_and_enqueue!`, `create!` commits the order. Then `perform_later` sends the job to the queue. The test uses a queue adapter that stops the process before the job reaches the queue. The order exists, but no job exists.
|
|
35
|
+
|
|
36
|
+
`after_commit` and `enqueue_after_transaction_commit` have the same gap: they enqueue the job after the database commit.
|
|
37
|
+
|
|
38
|
+
The Rails Guides state that `enqueue_after_transaction_commit` defers the enqueue until the Active Record transaction commits successfully. If the transaction rolls back, Rails does not enqueue the job. Rails 8 configures Solid Queue on a separate database by default. With this default, the job row and the order row use two databases. See [the Rails Guides, section 6.6.1](https://guides.rubyonrails.org/active_job_basics.html) (checked October 9, 2026).
|
|
39
|
+
|
|
40
|
+
[The transactional outbox tests](../../test/guides/transactional_outbox_test.rb) demonstrate the crash gap.
|
|
41
|
+
|
|
42
|
+
## The plain outbox pattern
|
|
43
|
+
|
|
44
|
+
In `place_with_outbox!`, the model writes the order and an outbox row in one transaction.
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
class OutboxMessage < ApplicationRecord
|
|
48
|
+
JOBS = { "request_shipment" => ShipmentJob }.freeze
|
|
49
|
+
|
|
50
|
+
scope :pending, -> { where(delivered_at: nil).order(:id) }
|
|
51
|
+
|
|
52
|
+
def self.relay(limit: 100)
|
|
53
|
+
pending.limit(limit).each do |message|
|
|
54
|
+
JOBS.fetch(message.name).perform_later(**message.arguments.symbolize_keys)
|
|
55
|
+
message.update!(delivered_at: Time.current)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The `relay` method uses these steps:
|
|
62
|
+
|
|
63
|
+
1. It sends each undelivered row to the queue.
|
|
64
|
+
2. It marks the row as delivered.
|
|
65
|
+
|
|
66
|
+
If the process stops after the enqueue and before the mark, the next relay sends the row again. The job must be idempotent. `ShipmentJob` passes `order-<id>` to the provider as the idempotency key.
|
|
67
|
+
|
|
68
|
+
Rails Event Store uses this pattern. Its scheduler writes the job into the same database table within the same transaction. A separate `res_outbox` process sends those rows to the background jobs tool. See [Rails Event Store](https://railseventstore.org/docs/advanced-topics/outbox) (checked October 9, 2026).
|
|
69
|
+
|
|
70
|
+
The plain outbox pattern is a good choice for an application that does not use actors.
|
|
71
|
+
|
|
72
|
+
## The atomic boundary of an actor turn
|
|
73
|
+
|
|
74
|
+
```ruby
|
|
75
|
+
class Checkout < SolidObjects::Actor
|
|
76
|
+
attribute :status, default: "open"
|
|
77
|
+
attribute :total_cents, default: 0
|
|
78
|
+
attribute :shipment_id, default: nil
|
|
79
|
+
|
|
80
|
+
def place(total_cents:)
|
|
81
|
+
return status unless status == "open"
|
|
82
|
+
|
|
83
|
+
self.status = "placed"
|
|
84
|
+
self.total_cents = total_cents
|
|
85
|
+
commit_action(:record_order, reference: actor_id, total_cents:)
|
|
86
|
+
emit(:request_shipment, order_reference: actor_id, on_success: :shipment_requested)
|
|
87
|
+
status
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def shipment_requested(effect_id:, arguments:, result:)
|
|
91
|
+
self.status = "shipping"
|
|
92
|
+
self.shipment_id = result.fetch("shipment_id")
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
SolidObjects.register_commit_action(:record_order) do |arguments, _context|
|
|
99
|
+
Order.create!(reference: arguments.fetch("reference"), total_cents: arguments.fetch("total_cents"))
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
SolidObjects.register_effect(:request_shipment) do |arguments, context|
|
|
103
|
+
ShippingProvider.create_shipment(
|
|
104
|
+
idempotency_key: context.id,
|
|
105
|
+
order_reference: arguments.fetch("order_reference")
|
|
106
|
+
)
|
|
107
|
+
end
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
One successful turn commits the actor state, the staged effects, the same-database commit actions, the reminders, and the outbound messages together. Actor Ruby code and external I/O run outside the actor-state transaction.
|
|
111
|
+
|
|
112
|
+
- `commit_action(:record_order, ...)` writes the `orders` row inside that transaction. A commit action requires Solid Objects and `ActiveRecord::Base` to share one connection pool. It can run again after a database rollback. Keep each commit action deterministic, bounded, and database-only.
|
|
113
|
+
- `emit(:request_shipment, ...)` stages an effect row in the same transaction. After the commit, an effect worker calls the handler outside any database transaction.
|
|
114
|
+
- The handler passes `context.id` to the provider as the idempotency key. `context.id` is the effect ID. It stays the same on every attempt.
|
|
115
|
+
- `on_success: :shipment_requested` sends the provider result back to the actor as a normal message.
|
|
116
|
+
- If the turn raises, it commits no state, no order row, and no effect row.
|
|
117
|
+
- `place` returns early when the checkout is not open. A repeated call stages no additional order or effect.
|
|
118
|
+
|
|
119
|
+
## What Solid Objects does not do
|
|
120
|
+
|
|
121
|
+
- Solid Objects does not wrap ordinary Active Record writes. Code outside an actor keeps its own transactions.
|
|
122
|
+
- A direct Active Record write inside an actor operation raises `SolidObjects::ApplicationWriteForbidden`. A registered commit action provides the only path for application row writes inside the actor commit.
|
|
123
|
+
- A commit action is unavailable when Solid Objects uses a separate database. Use `emit` with an idempotent effect consumer. Two databases cannot share one transaction.
|
|
124
|
+
- Solid Objects does not provide exactly-once delivery. An effect can run more than once.
|
|
125
|
+
|
|
126
|
+
## What the tests prove
|
|
127
|
+
|
|
128
|
+
- A stop before the enqueue saves the order and loses the job.
|
|
129
|
+
- A failure between the order insert and the outbox insert saves neither row.
|
|
130
|
+
- The relay sends a message twice after a lost mark, and the provider creates one shipment.
|
|
131
|
+
- A failed turn keeps no state, no order row, and no effect row.
|
|
132
|
+
- A successful turn stores the state, the order row, and the effect row together.
|
|
133
|
+
- Repeated `place` calls stage one order and one effect in total.
|
|
134
|
+
- The provider response fails to reach the effect worker once. The effect runs again with the same `context.id`. The provider creates one shipment, and the actor records the shipment ID.
|
|
135
|
+
|
|
136
|
+
## Run it in production
|
|
137
|
+
|
|
138
|
+
- Run `bundle exec solid_objects start`. Effects and their callbacks run only while this process runs. The SQL database keeps undelivered effects while the process does not run.
|
|
139
|
+
- Register effects and commit actions in an initializer at boot.
|
|
140
|
+
- The generated policies deny every call. Write a policy. Pass `authorization_context:` on each call. See [authorization policies](../authorization.md).
|
|
141
|
+
- See [effect recovery](../effect-recovery.md) for effects whose worker stopped during a call.
|
|
142
|
+
|
|
143
|
+
## Limits
|
|
144
|
+
|
|
145
|
+
- Solid Objects requires Ruby 3.3 or newer and Rails 7.1 or newer.
|
|
146
|
+
- Delivery is at least once. Each effect handler must deduplicate with `context.id`.
|
|
147
|
+
- Solid Objects provides no transactions across actors.
|
|
148
|
+
- The gem is pre-1.0 and makes no production-ready claim.
|
|
149
|
+
|
|
150
|
+
## More information
|
|
151
|
+
|
|
152
|
+
- [The example files](../../examples/guides/transactional_outbox/checkout.rb)
|
|
153
|
+
- [The tests for this guide](../../test/guides/transactional_outbox_test.rb)
|
|
154
|
+
- [Correctness and delivery semantics](../correctness.md)
|
|
155
|
+
- [Expiring reservations](expiring-reservations.md)
|
|
156
|
+
- [Prevent race conditions in Rails](race-conditions.md)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
class SeatInventory < SolidObjects::Actor
|
|
4
|
+
HOLD_DURATION = 15.minutes
|
|
5
|
+
EXTENSION = 5.minutes
|
|
6
|
+
MAX_EXTENSIONS = 2
|
|
7
|
+
|
|
8
|
+
attribute :capacity, default: 0
|
|
9
|
+
attribute :holds, default: -> { {} }
|
|
10
|
+
attribute :confirmed, default: -> { {} }
|
|
11
|
+
|
|
12
|
+
query :seats_left do
|
|
13
|
+
seats_available
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def open_show(capacity:)
|
|
17
|
+
self.capacity = capacity if self.capacity.zero?
|
|
18
|
+
seats_available
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def hold(hold_id:, buyer:, seats:)
|
|
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)
|
|
24
|
+
return { status: "confirmed" } if confirmed.key?(hold_id)
|
|
25
|
+
reject(:not_enough_seats, "Only #{seats_available} seats are left") if seats > seats_available
|
|
26
|
+
|
|
27
|
+
deadline = HOLD_DURATION.from_now
|
|
28
|
+
self.holds = holds.merge(
|
|
29
|
+
hold_id => { "buyer" => buyer, "seats" => seats, "expires_at" => deadline.to_i, "extensions" => 0 }
|
|
30
|
+
)
|
|
31
|
+
schedule(at: deadline, key: hold_id).expire(hold_id:, expires_at: deadline.to_i)
|
|
32
|
+
hold_result(hold_id)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def extend_hold(hold_id:)
|
|
36
|
+
reject(:no_hold, "The hold expired or does not exist") unless active_hold?(hold_id)
|
|
37
|
+
|
|
38
|
+
hold = holds.fetch(hold_id)
|
|
39
|
+
reject(:extension_limit, "The hold cannot be extended again") if hold.fetch("extensions") >= MAX_EXTENSIONS
|
|
40
|
+
|
|
41
|
+
deadline = Time.at(hold.fetch("expires_at")) + EXTENSION
|
|
42
|
+
self.holds = holds.merge(
|
|
43
|
+
hold_id => hold.merge("expires_at" => deadline.to_i, "extensions" => hold.fetch("extensions") + 1)
|
|
44
|
+
)
|
|
45
|
+
schedule(at: deadline, key: hold_id).expire(hold_id:, expires_at: deadline.to_i)
|
|
46
|
+
hold_result(hold_id)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def confirm(hold_id:)
|
|
50
|
+
return { status: "confirmed" } if confirmed.key?(hold_id)
|
|
51
|
+
reject(:no_hold, "The hold expired or does not exist") unless active_hold?(hold_id)
|
|
52
|
+
|
|
53
|
+
hold = holds.fetch(hold_id)
|
|
54
|
+
self.holds = holds.except(hold_id)
|
|
55
|
+
self.confirmed = confirmed.merge(hold_id => hold.fetch("seats"))
|
|
56
|
+
unschedule(:expire, key: hold_id)
|
|
57
|
+
{ status: "confirmed" }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def expire(hold_id:, expires_at:)
|
|
61
|
+
return seats_available unless holds.dig(hold_id, "expires_at") == expires_at
|
|
62
|
+
|
|
63
|
+
self.holds = holds.except(hold_id)
|
|
64
|
+
seats_available
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def active_hold?(hold_id)
|
|
70
|
+
holds.key?(hold_id) && holds.dig(hold_id, "expires_at") > Time.current.to_i
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def seats_available
|
|
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
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def hold_result(hold_id)
|
|
79
|
+
{ status: "held", expires_at: holds.dig(hold_id, "expires_at") }
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
class Account < ApplicationRecord
|
|
4
|
+
class InsufficientFunds < StandardError; end
|
|
5
|
+
class OutOfSequence < StandardError; end
|
|
6
|
+
|
|
7
|
+
def apply_entry!(kind:, amount_cents:)
|
|
8
|
+
change = (kind == "deposit") ? amount_cents : -amount_cents
|
|
9
|
+
raise InsufficientFunds, "balance #{balance_cents}, change #{change}" if balance_cents + change < 0
|
|
10
|
+
|
|
11
|
+
update!(balance_cents: balance_cents + change)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def apply_entry_in_sequence!(sequence:, kind:, amount_cents:)
|
|
15
|
+
with_lock do
|
|
16
|
+
raise OutOfSequence, "expected #{next_sequence}, received #{sequence}" if sequence > next_sequence
|
|
17
|
+
next if sequence < next_sequence
|
|
18
|
+
|
|
19
|
+
apply_entry!(kind:, amount_cents:)
|
|
20
|
+
update!(next_sequence: next_sequence + 1)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
class ApplyEntryJob < ApplicationJob
|
|
4
|
+
retry_on Account::OutOfSequence, wait: 5.seconds, attempts: 20
|
|
5
|
+
|
|
6
|
+
def perform(account_id:, sequence:, kind:, amount_cents:)
|
|
7
|
+
Account.find(account_id).apply_entry_in_sequence!(sequence:, kind:, amount_cents:)
|
|
8
|
+
end
|
|
9
|
+
end
|
|
@@ -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,0 +1,22 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
class LedgerAccount < SolidObjects::Actor
|
|
4
|
+
attribute :balance_cents, default: 0
|
|
5
|
+
attribute :statement_balance_cents, default: nil
|
|
6
|
+
|
|
7
|
+
def apply(entry_id:, kind:, amount_cents:)
|
|
8
|
+
return balance_cents if LedgerEntry.exists?(account_id: actor_id, entry_id:)
|
|
9
|
+
|
|
10
|
+
change = (kind == "deposit") ? amount_cents : -amount_cents
|
|
11
|
+
reject(:insufficient_funds, "The balance is too low for this withdrawal") if balance_cents + change < 0
|
|
12
|
+
|
|
13
|
+
self.balance_cents += change
|
|
14
|
+
commit_action(:record_ledger_entry, account_id: actor_id, entry_id:, kind:, amount_cents:)
|
|
15
|
+
schedule(at: 1.day.from_now, key: "daily").close_statement
|
|
16
|
+
balance_cents
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def close_statement
|
|
20
|
+
self.statement_balance_cents = balance_cents
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
SolidObjects.register_commit_action(:record_ledger_entry) do |arguments, _context|
|
|
4
|
+
LedgerEntry.create!(
|
|
5
|
+
account_id: arguments.fetch("account_id"),
|
|
6
|
+
entry_id: arguments.fetch("entry_id"),
|
|
7
|
+
kind: arguments.fetch("kind"),
|
|
8
|
+
amount_cents: arguments.fetch("amount_cents")
|
|
9
|
+
)
|
|
10
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
class Event < ApplicationRecord
|
|
4
|
+
def hold_seat_unsafely
|
|
5
|
+
return false if seats_available.zero?
|
|
6
|
+
|
|
7
|
+
update!(seats_available: seats_available - 1)
|
|
8
|
+
true
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def hold_seat
|
|
12
|
+
self.class.where(id:).where("seats_available > 0")
|
|
13
|
+
.update_all("seats_available = seats_available - 1") == 1
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
class EventTickets < SolidObjects::Actor
|
|
4
|
+
HOLD_DURATION = 10.minutes
|
|
5
|
+
|
|
6
|
+
attribute :opened, default: false
|
|
7
|
+
attribute :seats_available, default: 0
|
|
8
|
+
attribute :holds, default: -> { {} }
|
|
9
|
+
attribute :sold, default: -> { [] }
|
|
10
|
+
attribute :title, default: ""
|
|
11
|
+
attribute :revision, default: 0
|
|
12
|
+
|
|
13
|
+
def open_sales(seats:)
|
|
14
|
+
return seats_available if opened
|
|
15
|
+
|
|
16
|
+
self.opened = true
|
|
17
|
+
self.seats_available = seats
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def hold(buyer:, hold_id:)
|
|
21
|
+
release_expired_holds
|
|
22
|
+
return { held: true, hold_id: } if holds.dig(buyer, "hold_id") == hold_id
|
|
23
|
+
return { held: false, reason: "already_held" } if holds.key?(buyer)
|
|
24
|
+
return { held: false, reason: "sold_out" } if seats_available.zero?
|
|
25
|
+
|
|
26
|
+
deadline = HOLD_DURATION.from_now
|
|
27
|
+
self.seats_available -= 1
|
|
28
|
+
self.holds = holds.merge(buyer => { "hold_id" => hold_id, "expires_at" => deadline.to_i })
|
|
29
|
+
schedule(at: deadline, key: buyer).expire(buyer:, hold_id:, expires_at: deadline.to_i)
|
|
30
|
+
{ held: true, hold_id: }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def confirm(buyer:, hold_id:)
|
|
34
|
+
return { confirmed: true } if sold.include?(hold_id)
|
|
35
|
+
|
|
36
|
+
release_expired_holds
|
|
37
|
+
reject(:no_hold, "The hold expired or does not exist") unless holds.dig(buyer, "hold_id") == hold_id
|
|
38
|
+
|
|
39
|
+
self.holds = holds.except(buyer)
|
|
40
|
+
self.sold = sold + [ hold_id ]
|
|
41
|
+
unschedule(:expire, key: buyer)
|
|
42
|
+
{ confirmed: true }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def expire(buyer:, hold_id:, expires_at:)
|
|
46
|
+
return seats_available unless holds[buyer] == { "hold_id" => hold_id, "expires_at" => expires_at }
|
|
47
|
+
|
|
48
|
+
self.holds = holds.except(buyer)
|
|
49
|
+
self.seats_available += 1
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def update_details(title:, base_revision:)
|
|
53
|
+
reject(:stale_revision, "Reload the event and try again") unless base_revision == revision
|
|
54
|
+
|
|
55
|
+
self.title = title
|
|
56
|
+
self.revision += 1
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def release_expired_holds
|
|
62
|
+
expired_buyers = holds.select { |_buyer, hold| hold.fetch("expires_at") <= Time.current.to_i }.keys
|
|
63
|
+
self.holds = holds.except(*expired_buyers)
|
|
64
|
+
self.seats_available += expired_buyers.length
|
|
65
|
+
end
|
|
66
|
+
end
|