This recipe puts Solid Objects into a new Rails application. It uses SQLite, one actor, and one reminder. A person or a coding agent can follow it on a clean machine.
The recipe shows three behaviors:
- Concurrent calls to one actor identity commit one at a time.
- The actor state stays in the application's SQL database.
- A reminder that is due while the runtime is stopped runs after the runtime starts again.
bundle exec rake quickstart runs the same recipe against a gem that it builds
from this repository. See What the check proves.
- Ruby 3.3 or newer
- Rails 7.1 or newer
- SQLite 3.35 or newer
Solid Objects needs Rails. It is not a Ruby library that you can use without Rails.
rails new ticket_demo
cd ticket_demoThe json gem 3.x works only with Active Support 8.1.4 or newer. Solid
Objects stores its state in JSON columns, so an older Active Support with
json 3.x fails on every actor call. A new Rails application resolves Rails
8.1.4 or newer, which works. For an older Rails application, read
Installing and upgrading
before you continue.
bundle add solid_objects
bin/rails generate solid_objects:install
bin/rails db:migrate
bin/rails solid_objects:doctorbundle add solid_objects installs the current release. Do not pin an older
version from memory; the API changed between releases.
The generator writes config/initializers/solid_objects.rb and copies the
migrations. The migrations add the Solid Objects tables to the application's
existing database.
The generated initializer denies every operation. The doctor reports this condition as a warning:
WARN authorization: all five policies denied a neutral context; review the generated initializer before use
On SQLite, the doctor also warns that the runtime polls for work. SQLite has no notification channel, so a commit in one process cannot wake another process. The runtime finds the work at the next poll.
The demo calls the actor and reads its state from a console. It needs the
message policy and the query policy. In
config/initializers/solid_objects.rb, change these two lines:
configuration.authorize_message = ->(**) { false }
configuration.authorize_query = ->(**) { false }to:
configuration.authorize_message = ->(**) { true }
configuration.authorize_query = ->(**) { true }Do not change authorize_destroy, authorize_subscription,
authorize_administration, or authorize_transmission. They stay denied.
These two grants are for a local demo only. They let any caller send any message to any actor and read any actor state. A production policy must bind the actor type, the actor ID, and the operation to the authenticated user or tenant. Pass that principal at each call site:
TicketSale.ref(event.id.to_s).hold(
buyer: Current.user.id.to_s,
authorization_context: Current.user
)Then check it in the policy. can_buy_tickets_for? is an example method of
your application:
configuration.authorize_message = lambda do |actor_type:, actor_id:, operation:, authorization_context:, **|
user = authorization_context
actor_type == "TicketSale" &&
operation == "hold" &&
user.present? &&
user.can_buy_tickets_for?(actor_id)
endThe reminder that calls expire comes from a committed runtime row. It does
not go through authorize_message again.
Authorization describes every policy and its arguments.
Put this class in app/actors/ticket_sale.rb:
class TicketSale < SolidObjects::Actor
attribute :available, default: 1
attribute :holds, default: -> { {} }
def hold(buyer:)
return { held: false, available: } if available.zero? || holds.key?(buyer)
self.available -= 1
self.holds = holds.merge(buyer => Time.current.to_i)
schedule(at: 10.minutes.from_now, key: buyer).expire(buyer:)
{ held: true, available: }
end
def expire(buyer:)
return available unless holds.key?(buyer)
self.holds = holds.except(buyer)
self.available += 1
end
endOne actor owns the state of one event. The event ID is the actor ID. A
successful hold stores the hold and a keyed ten-minute reminder in the same
commit as the state change. The expire method checks that the hold still
exists, so a second delivery of the same reminder does not add a ticket.
Direct calls do not need a runtime process. The caller runs the turn. Reminders,
async messages, effects, and broadcasts need the runtime:
bundle exec solid_objects startWhen the runtime is stopped, due work stays in the database. Nothing else runs it. Run the runtime as a separate process in every environment that uses reminders.
Open a second terminal:
bin/rails consolesale = TicketSale.ref("concert")
sale.hold(buyer: "ada")
sale.hold(buyer: "grace")
sale.availableThe first call returns {"held" => true, "available" => 0}. The second call
returns {"held" => false, "available" => 0}. A synchronous call returns the
committed result as JSON data, so the keys are strings.
- Stop the runtime with Ctrl-C before ten minutes pass.
- Wait until the ten minutes pass.
- In the console, read
sale.available. The value is still0, because no process ran the reminder. - Start the runtime again with
bundle exec solid_objects start. - Read
sale.availableagain. The runtime runs the due reminder, and the value is1.
- Calls to one actor ID commit in order, one at a time. Different actor IDs can run concurrently.
- Delivery is at least once. A handler can run again after a crash. The guard
in
expiremakes a second delivery harmless. - Each turn commits the state and the reminder together, or commits neither.
- The demo has no external effects. An external effect, such as an email or a payment, can repeat. It must use the stable effect ID or another durable idempotency key.
- One actor ID is a sequential bottleneck. Do not use one actor ID for the whole application.
Correctness gives the full contract.
bundle exec rake quickstart runs smoke.rb. The check does these
steps:
- It confirms that this recipe and every
TicketSalesample in the README and indocs/show the exact actor inapp/actors/ticket_sale.rb. - It builds the gem with
gem build. - It runs
rails new --minimalwith SQLite in a temporary directory and runsbundle installinto a temporary bundle path. - It copies the built gem into
vendor/cache, addsgem "solid_objects", "= <version>"to theGemfile, and runsbundle install --local. The application cannot get the gem from the repository or from rubygems.org. - It confirms which gem the application loads:
- the
Gemfilehas nopath:option forsolid_objects; - the
Gemfile.lockchecksum is the checksum of the built gem; - the loaded gem files are in the temporary bundle path, not in the repository;
- the installed gem file has the checksum of the built gem.
- the
- It runs the install generator, the migrations, and the doctor. Then it writes the actor and the two demo grants. It confirms that the other policies stay denied.
- It starts
bundle exec solid_objects start. Eight separatebin/rails runnerprocesses wait at a barrier and then hold the same event at the same time. The check confirms that exactly one buyer holds the only ticket and that the durable state agrees. - It stops the runtime. One more process places a hold for a second event. That
process shifts its own clock back with
travel_to, so the ten-minute reminder is due eight seconds later. The reminder scheduler compares due times with the database clock, so the runtime does not need a shifted clock. - It waits until the reminder is past due and confirms that the hold is still there while no runtime runs. Then it starts the runtime again and confirms that the reminder released the hold exactly once.
- It stops every child process and removes the temporary directory, also when a step fails.
The check needs network access for rails new and bundle install. It takes
one to three minutes. The quickstart CI job runs it on every push.