Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ jobs:
timeout-minutes: 15
services:
redis:
image: redis:7
image: public.ecr.aws/docker/library/redis:7
ports:
- 6379:6379
options: >-
Expand All @@ -87,7 +87,7 @@ jobs:
timeout-minutes: 15
services:
postgres:
image: postgres:18
image: public.ecr.aws/docker/library/postgres:18
env:
POSTGRES_USER: solid_objects
POSTGRES_PASSWORD: solid_objects
Expand Down Expand Up @@ -121,7 +121,7 @@ jobs:
client: [ mysql2, trilogy ]
services:
mysql:
image: mysql:8.4
image: public.ecr.aws/docker/library/mysql:8.4
env:
MYSQL_USER: solid_objects
MYSQL_PASSWORD: solid_objects
Expand Down
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,22 @@
# Changelog

## 0.17.3 - 2026-10-09

- Add four problem guides in `docs/guides/`: preventing race conditions in
Rails, running jobs in order for each customer, expiring reservations, and
saving state and queuing work together. Each guide reproduces the failure,
shows the plain Rails fix first, and then shows a Solid Objects actor where
it adds value. The README and the agent guide list them.
- Each guide embeds tested example files from `examples/guides/`. The tests in
`test/guides/` prove each claim: the race and its SQL fix, ordered entries
under two workers, a reminder that runs after a restart, a stale expiry that
changes nothing, an atomic actor turn, and an effect that runs twice with one
idempotency key. 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.
- Add `activejob` to the development and test bundle for the guide examples.
Compatibility runs pin it to the Rails line under test.

## 0.17.2 - 2026-10-08

- The README names the agent guide at the start of Installation, and the agent
Expand Down
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ if rails_version
end

group :development, :test do
gem "activejob", rails_version ? "~> #{rails_version}.0" : ">= 7.1", require: false
gem "mysql2", ">= 0.5", require: false
gem "pg", ">= 1.5", require: false
gem "redis", ">= 5.0", require: false
Expand Down
12 changes: 10 additions & 2 deletions Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
solid_objects (0.17.2)
solid_objects (0.17.3)
actioncable (>= 7.1)
actionpack (>= 7.1)
actionview (>= 7.1)
Expand Down Expand Up @@ -36,6 +36,9 @@ GEM
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
activejob (8.1.3.1)
activesupport (= 8.1.3.1)
globalid (>= 0.3.6)
activemodel (8.1.3.1)
activesupport (= 8.1.3.1)
activerecord (8.1.3.1)
Expand Down Expand Up @@ -78,6 +81,8 @@ GEM
ffi (1.17.4-x86_64-linux-gnu)
ffi (1.17.4-x86_64-linux-musl)
fileutils (1.8.0)
globalid (1.4.0)
activesupport (>= 6.1)
i18n (1.15.2)
concurrent-ruby (~> 1.0)
io-console (0.8.2)
Expand Down Expand Up @@ -283,6 +288,7 @@ PLATFORMS
x86_64-linux-musl

DEPENDENCIES
activejob (>= 7.1)
benchmark
brakeman
minitest
Expand All @@ -301,6 +307,7 @@ CHECKSUMS
actioncable (8.1.3.1) sha256=e318528295c878a3efdfe25f0f2267c80cb7a76eba41bb5f64d44aa380a3d91b
actionpack (8.1.3.1) sha256=974cb7154548e81f470b1b0f247b99cb38e87825899dca58610596e2817723d0
actionview (8.1.3.1) sha256=2da68b8414c47b43bfbed1ce69c5afe1c04f78c267aacb5660a4cab5ca12cfb6
activejob (8.1.3.1) sha256=1c8dd275df930df40deecffec63d913a550a33fd94bd298f69721dd96939954a
activemodel (8.1.3.1) sha256=99cc02ce2faec371d14440949d85787ebd23a907c9baef0a9d4bcd4d21888f88
activerecord (8.1.3.1) sha256=0a2fb6c28f4938f6b013a3a549bec0a7e37d535f3dc8990e804bcc3258c0403b
activesupport (8.1.3.1) sha256=85458765f25ea48b9019c46b6bb3fa5683197bf4280d9f06710a6e8d7a831376
Expand All @@ -326,6 +333,7 @@ CHECKSUMS
ffi (1.17.4-x86_64-linux-gnu) sha256=9d3db14c2eae074b382fa9c083fe95aec6e0a1451da249eab096c34002bc752d
ffi (1.17.4-x86_64-linux-musl) sha256=3fdf9888483de005f8ef8d1cf2d3b20d86626af206cbf780f6a6a12439a9c49e
fileutils (1.8.0) sha256=8c6b1df54e2540bdb2f39258f08af78853aa70bad52b4d394bbc6424593c6e02
globalid (1.4.0) sha256=037f12fbf1d9d7a014d501c2d5c77356fd4ddd96d7a7991d6700bba96706f427
i18n (1.15.2) sha256=00f9eb62412fe593b2a65a97daa75300d37abb8f7202ec748e94b6d46a9dd1b5
io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc
irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3
Expand Down Expand Up @@ -384,7 +392,7 @@ CHECKSUMS
rubocop-rails-omakase (1.1.0) sha256=2af73ac8ee5852de2919abbd2618af9c15c19b512c4cfc1f9a5d3b6ef009109d
ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33
securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1
solid_objects (0.17.2)
solid_objects (0.17.3)
sqlite3 (2.9.5-aarch64-linux-gnu) sha256=78075b6337d3d182c6d2b4691049ed45cd220826160c9ea18946bf6a1de200dc
sqlite3 (2.9.5-aarch64-linux-musl) sha256=18c801185deb4adc01ddb281e8f672a39e3d1729979ca91e39439cd3eac0402d
sqlite3 (2.9.5-arm-linux-gnu) sha256=1bdfca0c7d63998c60b0f4a8e3c8df2d33800ccc4abd2d612eddbbbc92a4c48b
Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ And so much more.
- [Good uses](#good-uses)
- [When a transaction is better](#when-a-transaction-is-better)
- [Guarantees and boundaries](#guarantees-and-boundaries)
- [Guides](#guides)
- [Read more](#read-more)
- [Status and license](#status-and-license)

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

## Guides

Each guide starts from a problem, shows the plain Rails fix first, and tests
every claim in [`test/guides/`](test/guides/):

- [Prevent race conditions in Rails](docs/guides/race-conditions.md)
- [Run jobs in order for each customer](docs/guides/ordered-jobs.md)
- [Expiring reservations](docs/guides/expiring-reservations.md)
- [Save state and queue work together](docs/guides/transactional-outbox.md)

## Read more

- [Five-minute Rails guide](https://solidobjects.dev/5min/rails)
Expand Down
7 changes: 7 additions & 0 deletions docs/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ Select a simpler tool in these cases:
The full checklist is in [Choosing Solid Objects](fit.md). The category guide
is [Virtual actors in Ruby on Rails](virtual-actors.md).

Problem guides compare the plain Rails fix with an actor and test each claim:

- [Prevent race conditions in Rails](guides/race-conditions.md)
- [Run jobs in order for each customer](guides/ordered-jobs.md)
- [Expiring reservations](guides/expiring-reservations.md)
- [Save state and queue work together](guides/transactional-outbox.md)

## 2. Package identity

| Item | Value |
Expand Down
175 changes: 175 additions & 0 deletions docs/guides/expiring-reservations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
# Expiring reservations in Rails

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.

## Put the stock under the right identity

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.

Put the stock and all of its holds in the actor that owns the stock. This guide uses one actor for each show.

## The plain SQL design

- Store each hold in a table with its seats and an `expires_at` time.
- Count free seats as the capacity minus confirmed seats minus holds whose `expires_at` is still in the future.
- Insert a hold inside a transaction that locks the show row. Two holds cannot then both see the last seat.
- This design needs no timer. An old hold no longer counts when its time passes.

This design is not enough when an action must occur at the deadline. You need a timer for these actions:

- Release a payment authorization.
- Tell the buyer that the hold expired.
- Update state that other code reads without the clock.
- Start the next step of a workflow.

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.

## The actor

```ruby
class SeatInventory < SolidObjects::Actor
HOLD_DURATION = 15.minutes
EXTENSION = 5.minutes
MAX_EXTENSIONS = 2

attribute :capacity, default: 0
attribute :holds, default: -> { {} }
attribute :confirmed, default: -> { {} }

query :seats_left do
seats_available
end

def open_show(capacity:)
self.capacity = capacity if self.capacity.zero?
seats_available
end

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

deadline = HOLD_DURATION.from_now
self.holds = holds.merge(
hold_id => { "buyer" => buyer, "seats" => seats, "expires_at" => deadline.to_i, "extensions" => 0 }
)
schedule(at: deadline, key: hold_id).expire(hold_id:, expires_at: deadline.to_i)
hold_result(hold_id)
end

def extend_hold(hold_id:)
reject(:no_hold, "The hold expired or does not exist") unless active_hold?(hold_id)

hold = holds.fetch(hold_id)
reject(:extension_limit, "The hold cannot be extended again") if hold.fetch("extensions") >= MAX_EXTENSIONS

deadline = Time.at(hold.fetch("expires_at")) + EXTENSION
self.holds = holds.merge(
hold_id => hold.merge("expires_at" => deadline.to_i, "extensions" => hold.fetch("extensions") + 1)
)
schedule(at: deadline, key: hold_id).expire(hold_id:, expires_at: deadline.to_i)
hold_result(hold_id)
end

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

hold = holds.fetch(hold_id)
self.holds = holds.except(hold_id)
self.confirmed = confirmed.merge(hold_id => hold.fetch("seats"))
unschedule(:expire, key: hold_id)
{ status: "confirmed" }
end

def expire(hold_id:, expires_at:)
return seats_available unless holds.dig(hold_id, "expires_at") == expires_at

self.holds = holds.except(hold_id)
seats_available
end

private

def active_hold?(hold_id)
holds.key?(hold_id) && holds.dig(hold_id, "expires_at") > Time.current.to_i
end

def seats_available
held_seats = holds.each_key.select { |hold_id| active_hold?(hold_id) }.sum { |hold_id| holds.dig(hold_id, "seats") }
capacity - held_seats - confirmed.values.sum
end

def hold_result(hold_id)
{ status: "held", expires_at: holds.dig(hold_id, "expires_at") }
end
end
```

- `open_show` sets the capacity once.
- `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.
- `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.
- `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.
- 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.
- `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.

Active holds and confirmed seats are bounded by the show capacity. An expired hold stays in the state until its reminder runs.

## Deadlines that survive a restart

Use durable reminders for persistent timers in Rails.

`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.

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.

Delivery is at least once, so `expire` checks the deadline before it changes anything. See [reminders](../reminders.md).

## What the tests prove

- Eight concurrent holds request one seat each against a capacity of five. Five succeed, and three receive the rejection code `not_enough_seats`.
- A hold retry takes its seats once.
- An extension moves the reminder to the new deadline. At 16 minutes, nothing runs. At 21 minutes, the expiry runs and the seats return.
- An expiry for the old deadline changes nothing after an extension.
- The actor rejects a third extension with the code `extension_limit`.
- A confirmation retry returns the same result. The hold moves to `confirmed` once, and the confirmation cancels the reminder.
- A confirmation after expiry receives the rejection code `no_hold`.
- 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.
- A hold for zero seats or for -5 seats is rejected with `invalid_seats`, and the free seats do not change.
- A hold that falls due while the runtime is stopped expires after a restart.

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

## Payment and other side effects

Do not call a payment provider inside the actor. An effect can run more than once.

- Stage the payment with `emit`.
- Pass `context.id` to the provider as the idempotency key.
- Confirm the hold in the actor after the payment succeeds.

See [the transactional outbox guide](transactional-outbox.md).

## Run it in production

- Authorization: the generated policies deny every call. Write a policy. Pass `authorization_context:` on each call. See [authorization policies](../authorization.md).
- Run `bundle exec solid_objects start` beside the web process.
- 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).

## Limits

- Solid Objects requires Ruby 3.3 or newer and Rails 7.1 or newer.
- Delivery is at least once. Each operation must be safe to run again.
- There are no transactions across actors. A reservation that spans two shows needs its own design.
- One busy show runs its calls one at a time. Many shows run at the same time.
- The gem is pre-1.0 and makes no production-ready claim.

## More information

- [The example file](../../examples/guides/expiring_reservations/seat_inventory.rb)
- [The tests for this guide](../../test/guides/expiring_reservations_test.rb)
- [Prevent race conditions in Rails](race-conditions.md)
- [Correctness and delivery semantics](../correctness.md)
- [Virtual actors in Ruby on Rails](../virtual-actors.md)
Loading
Loading