Stepped Actions
Stepped is a Rails engine for orchestrating complex workflows as a tree of actions. Each action is persisted, runs through Active Job, and can fan out into more actions while keeping the parent action moving step-by-step as dependencies complete.
Stepped was extracted out of Envirobly where it powers tasks like application deployment, that involve complex, out-of-the-band tasks like DNS provisioning, retries, waiting for instances to boot, running health checks and all the fun of a highly distributed networked system.
Concepts
- Action trees: define a root action with multiple steps; each step can enqueue more actions and the step completes only once all the actions within it complete.
- Models are the Actors: in Rails, your business logic usually centers around database-persisted models. Stepped takes advantage of this and allows you to define and run actions on all your models, out of the box.
-
Concurrency lanes: actions with the same
concurrency_keyshare aStepped::Performance, so only one runs at a time while others queue up (with automatic superseding of older queued work). -
Reuse: optional
checksumlets Stepped skip work that is already achieved, or share a currently-performing action with multiple parents. Imagine you need to launch multiple workflows with different outcomes, that all depend on the outcome of the same action, somewhere in the action tree. Stepped makes this easy and efficient. - Outbound completion: actions can be marked outbound (or implemented as a job) and completed later by an external event.
Installation
Add Stepped to your application (Rails >= 8.1.1):
gem "stepped"Then install and run the migrations:
bundle install
bin/rails stepped:install
bin/rails db:migrateQuick start
Stepped hooks into Active Record automatically, so any model can declare actions.
If you define an action without steps, Stepped generates a single step that calls the instance method with the same name:
class Car < ApplicationRecord
stepped_action :drive
def drive(miles)
update!(mileage: mileage + miles)
end
end
car = Car.find(1)
car.drive_later(5) # enqueues Stepped::ActionJob
car.drive_now(5) # runs synchronously (still uses the Stepped state machine)Calling *_now/*_later creates a Stepped::Action and a Stepped::Step record behind the scenes. If the action finishes immediately, the associated Stepped::Performance (the concurrency “lane”) is created and destroyed within the same run. If the action is short-circuited (for example, cancelled/completed in before, or skipped due to a matching achievement), Stepped returns an action instance but does not create any database rows.
Concepts
An action is represented by Stepped::Action (statuses include pending, performing, succeeded, failed, cancelled, superseded, timed_out, and deadlocked). Each action executes one step at a time; steps are stored in Stepped::Step and complete when all of their dependencies finish.
Actions that share a concurrency_key are grouped under a Stepped::Performance. A performance behaves like a single-file queue: the current action performs, later actions wait as pending, and when the current action completes the performance advances to the next incomplete action.
If you opt into reuse, successful actions write a Stepped::Achievement keyed by checksum. When an action is invoked again with the same checksum, Stepped can skip the work entirely.
Defining actions
Define an action on an Active Record model with stepped_action. The block is a small DSL that lets you specify steps, hooks, and keys:
class Car < ApplicationRecord
stepped_action :visit do
step do |step, location|
step.do :change_location, location
end
succeeded do
update!(last_visited_at: Time.current)
end
end
def change_location(location)
update!(location:)
end
endSteps and action trees
Each step block runs in the actor’s context (self is the model instance) and receives (step, *arguments). Inside a step you typically enqueue more actions:
stepped_action :park do
step do
honk
end
step do |step, miles|
step.do :honk
step.on [self, nil], :drive, miles
end
endstep.do is shorthand for “run another action on the same actor”. step.on accepts a single actor or an array of actors; nil values are ignored. If a step enqueues work, the parent action will remain performing until those child actions finish and report back.
To deliberately fail a step without raising, set step.status = :failed inside the step body.
The code within the step block runs within the model instance context. Therefore you have flexibility to write any model code within this block, not just invoking actions.
Waiting
Steps can also enqueue a timed wait:
stepped_action :stopover do
step { |step| step.wait 5.seconds }
step { honk }
endBefore hooks and argument mutation
before runs once, before any steps are performed. It can mutate action.arguments, or cancel/complete the action early:
stepped_action :multiplied_drive do
before do |action, distance|
action.arguments = [distance * 2]
end
step do |step, distance|
step.do :drive, distance
end
endThe checksum (if you define one) is computed after before, so it sees the updated arguments.
After callbacks
After callbacks run when the action is completed. You can attach them inline (succeeded, failed, cancelled, timed_out) or later from elsewhere with after_stepped_action:
Car.stepped_action :drive, outbound: true do
after :cancelled, :failed, :timed_out do
honk
end
end
Car.after_stepped_action :drive, :succeeded do |action, miles|
Rails.logger.info("Drove #{miles} miles")
endIf an after callback raises and you’ve configured Stepped to handle that exception class, the action status is preserved but the callback is counted as failed and the action will not grant an achievement.
Concurrency, queueing, and superseding
Every action runs under a concurrency_key. Actions with the same key share a performance and therefore run one-at-a-time, in order.
By default, the key is scoped to the actor and action name (for example Car/123/visit). You can override it with concurrency_key to coordinate across records or across different actions:
stepped_action :recycle, outbound: true do
concurrency_key { "Car/maintenance" }
end
stepped_action :paint, outbound: true do
concurrency_key { "Car/maintenance" }
endWhile one action is performing, later actions with the same key are queued as pending. If multiple pending actions build up, Stepped supersedes older pending actions in favor of the newest one, and transfers any parent-step dependencies to the newest action so waiting steps don’t get stuck.
Stepped also protects you from deadlocks: if a descendant action tries to join the same concurrency_key as one of its ancestors, it is marked deadlocked and its parent step will fail.
Checksums and reuse (Achievements)
Reuse is opt-in per action via checksum. When a checksum is present, Stepped stores the last successful checksum in Stepped::Achievement under checksum_key (which defaults to the action’s tenancy key).
stepped_action :visit do
checksum { |location| location }
step do |step, location|
step.do :change_location, location
end
endWith a checksum in place:
- If you invoke an action while an identical checksum is already performing under the same concurrency lane, Stepped returns the existing performing action and attaches the new parent step to it.
- If an identical checksum has already succeeded (an achievement exists), Stepped returns a
succeededaction immediately without creating new records. - If the checksum changes, Stepped performs the action and updates the stored achievement to the new checksum.
Use checksum_key to control the scope of reuse. Returning an array joins parts with /:
checksum_key { ["Car", "visit"] } # shared across all carsOutbound actions and external completion
An outbound action runs its steps but does not complete automatically when the final step finishes. It stays performing until you explicitly complete it (for example, from a webhook handler or another system):
stepped_action :charge_card, outbound: true do
step do |step, amount_cents|
# enqueue calls to external systems here
end
end
user.charge_card_later(1500)
user.complete_stepped_action_later(:charge_card, :succeeded)Under the hood, completion forwards to the current outbound action for that actor+name and advances its performance queue.
Job-backed actions
This is especially useful if you'd like to have (delayed) retries on certain errors, that ActiveJob supports out of the box.
You can declare it with job:. Job-backed actions are treated as outbound and are expected to call action.complete! when finished. The action instance is passed as the first and only argument. To work with the action arguments, use the familiar action.arguments:
class TowJob < ActiveJob::Base
def perform(action)
car = action.actor
location = action.arguments.first
car.update!(location:)
action.complete!
end
end
class Car < ApplicationRecord
stepped_action :tow, job: TowJob
endYou can extend existing actions (including job-backed ones) by prepending steps:
Car.prepend_stepped_action_step :tow do
honk
endTimeouts
Set timeout: to enqueue a Stepped::TimeoutJob when the action starts. If the action is still performing after the timeout elapses, it completes as timed_out:
stepped_action :change_location, outbound: true, timeout: 5.secondsTimeouts propagate through the tree: a timed-out nested action fails its parent step, which fails the parent action.
Exception handling
Stepped can either raise exceptions (letting your job backend retry) or treat specific exception classes as handled and turn them into action failure.
Configure the handled exception classes in your application:
# config/initializers/stepped.rb (or an environment file)
Stepped::Engine.config.stepped_actions.handle_exceptions = [StandardError]When an exception is handled, Stepped reports it via Rails.error.report and marks the action/step as failed instead of raising.
Testing
Stepped ships with Stepped::TestHelper (require "stepped/test_helper") which builds on Active Job’s test helpers to make it easy to drain the full action tree.
# test/test_helper.rb
require "stepped/test_helper"
class ActiveSupport::TestCase
include ActiveJob::TestHelper
include Stepped::TestHelper
# If your workflows include outbound actions, complete them here so
# `perform_stepped_actions` can fully drain the tree.
def complete_stepped_outbound_performances
Stepped::Performance.outbounds.includes(:action).find_each do |performance|
action = performance.action
Stepped::Performance.outbound_complete(action.actor, action.name, :succeeded)
end
end
endIn a test, you can perform Stepped jobs recursively:
car.visit_later("London")
perform_stepped_actionsTo test failure behavior without bubbling exceptions, you can temporarily mark exception classes as handled:
handle_stepped_action_exceptions(only: [StandardError]) do
car.visit_now("London")
endDevelopment
Run the test suite:
bin/rails db:test:prepare
bin/rails testLicense
The gem is available as open source under the terms of the MIT License.