Skip to content

Commit e5b62f0

Browse files
committed
docs: add four tested problem guides
Developers search for a problem, not for a virtual actor library. Four guides now start from one: race conditions in Rails, running jobs in order for each customer, expiring reservations, and saving state and queuing work together. Each one reproduces the failure, shows the plain Rails fix first (a unique index, a conditional update, row and optimistic locks, a sequence number, Sidekiq capsules, Solid Queue concurrency limits, a transactional outbox), and only then shows an actor where ordering, durable state, reminders, and recovery are needed together. Codex with GPT-6 Astra wrote the prose from briefs that held only verified facts and cited sources. Every code block in a guide is a file under examples/guides/, and the tests in test/guides/ prove each claim. Each test was watched to fail first, and each guard in the examples was removed once to confirm that a test fails without it. A document test fails when a guide does not embed the current example files, contains an em dash or an en dash, or links to a missing file. The concurrency helper limits the threads that act at once to the free connections in the pool. On PostgreSQL with the default pool of five, ten waiting synchronous callers otherwise held every connection and the actor turn could not get one ("could not obtain a connection from the pool within 5.000 seconds"). Active Job joins the development and test bundle for the job examples, pinned to the Rails line in compatibility runs.
1 parent d535fdb commit e5b62f0

26 files changed

Lines changed: 1682 additions & 0 deletions

‎Gemfile‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ if rails_version
1414
end
1515

1616
group :development, :test do
17+
gem "activejob", rails_version ? "~> #{rails_version}.0" : ">= 7.1", require: false
1718
gem "mysql2", ">= 0.5", require: false
1819
gem "pg", ">= 1.5", require: false
1920
gem "redis", ">= 5.0", require: false

‎Gemfile.lock‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ GEM
3636
erubi (~> 1.11)
3737
rails-dom-testing (~> 2.2)
3838
rails-html-sanitizer (~> 1.6)
39+
activejob (8.1.3.1)
40+
activesupport (= 8.1.3.1)
41+
globalid (>= 0.3.6)
3942
activemodel (8.1.3.1)
4043
activesupport (= 8.1.3.1)
4144
activerecord (8.1.3.1)
@@ -78,6 +81,8 @@ GEM
7881
ffi (1.17.4-x86_64-linux-gnu)
7982
ffi (1.17.4-x86_64-linux-musl)
8083
fileutils (1.8.0)
84+
globalid (1.4.0)
85+
activesupport (>= 6.1)
8186
i18n (1.15.2)
8287
concurrent-ruby (~> 1.0)
8388
io-console (0.8.2)
@@ -283,6 +288,7 @@ PLATFORMS
283288
x86_64-linux-musl
284289

285290
DEPENDENCIES
291+
activejob (>= 7.1)
286292
benchmark
287293
brakeman
288294
minitest
@@ -301,6 +307,7 @@ CHECKSUMS
301307
actioncable (8.1.3.1) sha256=e318528295c878a3efdfe25f0f2267c80cb7a76eba41bb5f64d44aa380a3d91b
302308
actionpack (8.1.3.1) sha256=974cb7154548e81f470b1b0f247b99cb38e87825899dca58610596e2817723d0
303309
actionview (8.1.3.1) sha256=2da68b8414c47b43bfbed1ce69c5afe1c04f78c267aacb5660a4cab5ca12cfb6
310+
activejob (8.1.3.1) sha256=1c8dd275df930df40deecffec63d913a550a33fd94bd298f69721dd96939954a
304311
activemodel (8.1.3.1) sha256=99cc02ce2faec371d14440949d85787ebd23a907c9baef0a9d4bcd4d21888f88
305312
activerecord (8.1.3.1) sha256=0a2fb6c28f4938f6b013a3a549bec0a7e37d535f3dc8990e804bcc3258c0403b
306313
activesupport (8.1.3.1) sha256=85458765f25ea48b9019c46b6bb3fa5683197bf4280d9f06710a6e8d7a831376
@@ -326,6 +333,7 @@ CHECKSUMS
326333
ffi (1.17.4-x86_64-linux-gnu) sha256=9d3db14c2eae074b382fa9c083fe95aec6e0a1451da249eab096c34002bc752d
327334
ffi (1.17.4-x86_64-linux-musl) sha256=3fdf9888483de005f8ef8d1cf2d3b20d86626af206cbf780f6a6a12439a9c49e
328335
fileutils (1.8.0) sha256=8c6b1df54e2540bdb2f39258f08af78853aa70bad52b4d394bbc6424593c6e02
336+
globalid (1.4.0) sha256=037f12fbf1d9d7a014d501c2d5c77356fd4ddd96d7a7991d6700bba96706f427
329337
i18n (1.15.2) sha256=00f9eb62412fe593b2a65a97daa75300d37abb8f7202ec748e94b6d46a9dd1b5
330338
io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc
331339
irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3

‎README.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ And so much more.
3939
- [Good uses](#good-uses)
4040
- [When a transaction is better](#when-a-transaction-is-better)
4141
- [Guarantees and boundaries](#guarantees-and-boundaries)
42+
- [Guides](#guides)
4243
- [Read more](#read-more)
4344
- [Status and license](#status-and-license)
4445

@@ -164,6 +165,16 @@ SQL and should be allowed to enjoy that.
164165
Exactly once is not hiding in a more advanced configuration. Read the
165166
[correctness contract](docs/correctness.md) before using important data.
166167

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+
167178
## Read more
168179

169180
- [Five-minute Rails guide](https://solidobjects.dev/5min/rails)

‎docs/agents.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,13 @@ Select a simpler tool in these cases:
3030
The full checklist is in [Choosing Solid Objects](fit.md). The category guide
3131
is [Virtual actors in Ruby on Rails](virtual-actors.md).
3232

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+
3340
## 2. Package identity
3441

3542
| Item | Value |
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
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+
return hold_result(hold_id) if holds.key?(hold_id)
50+
return { status: "confirmed" } if confirmed.key?(hold_id)
51+
reject(:not_enough_seats, "Only #{seats_available} seats are left") if seats > seats_available
52+
53+
deadline = HOLD_DURATION.from_now
54+
self.holds = holds.merge(
55+
hold_id => { "buyer" => buyer, "seats" => seats, "expires_at" => deadline.to_i, "extensions" => 0 }
56+
)
57+
schedule(at: deadline, key: hold_id).expire(hold_id:, expires_at: deadline.to_i)
58+
hold_result(hold_id)
59+
end
60+
61+
def extend_hold(hold_id:)
62+
hold = holds[hold_id]
63+
reject(:no_hold, "The hold expired or does not exist") unless hold
64+
reject(:extension_limit, "The hold cannot be extended again") if hold.fetch("extensions") >= MAX_EXTENSIONS
65+
66+
deadline = Time.at(hold.fetch("expires_at")) + EXTENSION
67+
self.holds = holds.merge(
68+
hold_id => hold.merge("expires_at" => deadline.to_i, "extensions" => hold.fetch("extensions") + 1)
69+
)
70+
schedule(at: deadline, key: hold_id).expire(hold_id:, expires_at: deadline.to_i)
71+
hold_result(hold_id)
72+
end
73+
74+
def confirm(hold_id:)
75+
return { status: "confirmed" } if confirmed.key?(hold_id)
76+
77+
hold = holds[hold_id]
78+
reject(:no_hold, "The hold expired or does not exist") unless hold
79+
80+
self.holds = holds.except(hold_id)
81+
self.confirmed = confirmed.merge(hold_id => hold.fetch("seats"))
82+
unschedule(:expire, key: hold_id)
83+
{ status: "confirmed" }
84+
end
85+
86+
def expire(hold_id:, expires_at:)
87+
return seats_available unless holds.dig(hold_id, "expires_at") == expires_at
88+
89+
self.holds = holds.except(hold_id)
90+
seats_available
91+
end
92+
93+
private
94+
95+
def seats_available
96+
capacity - holds.values.sum { |hold| hold.fetch("seats") } - confirmed.values.sum
97+
end
98+
99+
def hold_result(hold_id)
100+
{ status: "held", expires_at: holds.dig(hold_id, "expires_at") }
101+
end
102+
end
103+
```
104+
105+
- `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.
111+
112+
All state stays bounded by the show capacity.
113+
114+
## Deadlines that survive a restart
115+
116+
Use durable reminders for persistent timers in Rails.
117+
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.
119+
120+
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+
122+
Delivery is at least once, so `expire` checks the deadline before it changes anything. See [reminders](../reminders.md).
123+
124+
## What the tests prove
125+
126+
- Eight concurrent holds request one seat each against a capacity of five. Five succeed, and three receive the rejection code `not_enough_seats`.
127+
- A hold retry takes its seats once.
128+
- An extension moves the reminder to the new deadline. At 16 minutes, nothing runs. At 21 minutes, the expiry runs and the seats return.
129+
- An expiry for the old deadline changes nothing after an extension.
130+
- The actor rejects a third extension with the code `extension_limit`.
131+
- A confirmation retry returns the same result. The hold moves to `confirmed` once, and the confirmation cancels the reminder.
132+
- A confirmation after expiry receives the rejection code `no_hold`.
133+
- A hold that falls due while the runtime is stopped expires after a restart.
134+
135+
See [the tests for this guide](../../test/guides/expiring_reservations_test.rb).
136+
137+
## Payment and other side effects
138+
139+
Do not call a payment provider inside the actor. An effect can run more than once.
140+
141+
- Stage the payment with `emit`.
142+
- Pass `context.id` to the provider as the idempotency key.
143+
- Confirm the hold in the actor after the payment succeeds.
144+
145+
See [the transactional outbox guide](transactional-outbox.md).
146+
147+
## Run it in production
148+
149+
- Authorization: the generated policies deny every call. Write a policy. Pass `authorization_context:` on each call. See [authorization policies](../authorization.md).
150+
- Run `bundle exec solid_objects start` beside the web process.
151+
- 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).
152+
153+
## Limits
154+
155+
- Solid Objects requires Ruby 3.3 or newer and Rails 7.1 or newer.
156+
- Delivery is at least once. Each operation must be safe to run again.
157+
- There are no transactions across actors. A reservation that spans two shows needs its own design.
158+
- One busy show runs its calls one at a time. Many shows run at the same time.
159+
- The gem is pre-1.0 and makes no production-ready claim.
160+
161+
## More information
162+
163+
- [The example file](../../examples/guides/expiring_reservations/seat_inventory.rb)
164+
- [The tests for this guide](../../test/guides/expiring_reservations_test.rb)
165+
- [Prevent race conditions in Rails](race-conditions.md)
166+
- [Correctness and delivery semantics](../correctness.md)
167+
- [Virtual actors in Ruby on Rails](../virtual-actors.md)

0 commit comments

Comments
 (0)