A durable workflow orchestration engine for Rails applications. Built on top of ActiveJob and Solid Queue.
In mature Rails applications, background jobs often become complex chains of inter-dependent tasks. Managing this complexity with raw ActiveJob calls is error-prone. Rigid Workflow solves this by providing:
- Explicit State: Workflows are defined as code, with state persisted automatically in your database.
- Observability: A built-in Admin UI to visualize, monitor, and retry workflows.
- Complexity Management: Native support for parallel execution, race conditions, signals/waits, and compensation (Saga pattern) without external dependencies (like Redis/Temporal) beyond what Rails already provides (ActiveJob).
Order Fulfillment — Reserve inventory, charge payment, ship. Compensate on failure
→ Saga pattern via rescue/raise to release inventory if payment fails
Content Moderation — AI-powered content scanning with human-in-the-loop review
→ race between auto-approve, auto-reject, and manual review signals
Video Processing — Parallel transcoding across formats with thumbnail generation
→ parallel for fan-out execution, loop for thumbnail extraction at timestamps
Subscription Billing — Payment with retries and fallback to downgrade
→ step with max_attempts and retry_delay for payment gateways
Employee Offboarding — Sequential multi-step business process across systems
→ step chaining with persistent state across job enqueues
User Onboarding — Trial provisioning with time-gated auto-teardown
→ wait with timeout, conditional branching per plan tier
AI Pipelines — Scrape websites, chunk content, generate embeddings, summarize with LLMs. Each step retries independently when APIs flake.
→ step with per-step max_attempts and retry_delay
RAG Pipelines — Chunk documents, generate embeddings, index content. Multi-step LLM chains with persistent state.
→ step chaining, loop over document collections, memo for caching
- DSL-based workflow definitions - Define workflows with a clear, expressive syntax
- Sequential and parallel execution - Run steps in sequence or concurrently
- Controlled race conditions - First to complete wins (signal vs. timer)
- Wait states and signals - Pause workflows for external events, with optional timeouts
- Retry logic with exponential backoff - Automatic retries with configurable delay and jitter
- Saga/Compensation pattern - Auto-compensate completed steps in reverse order when an activity exhausts retries
- Workflow versioning - Track versions of workflow definitions; runs resume safely across version changes
- Memoization - Persist non-deterministic values across workflow resumptions
- Looping over collections - Iterate with persistent state tracking per item
- Persistent state - All workflow state is stored in the database
- Admin UI - Built-in dashboard with overview stats, filtering, pagination, Gantt chart visualization, and bulk actions
- Event instrumentation -
ActiveSupport::Notificationsintegration for all key lifecycle events - Generators - Rails generators for install, workflow, and activity scaffolding
- Ruby >= 3.1
- Rails >= 7.0
Set admin_controller to a controller that already has authentication (e.g., from Devise):
# config/initializers/rigid_workflow.rb
RigidWorkflow.configure do |config|
config.admin_controller = "Admin::BaseController"
endThe engine's ApplicationController will inherit from this class, automatically applying your authentication.
Authenticate at the route level in your config/routes.rb:
# For Devise:
authenticate :user do
mount RigidWorkflow::Engine => "/admin/rigid_workflow"
end
# For custom auth:
authenticate :admin_user do
mount RigidWorkflow::Engine => "/admin/rigid_workflow"
endConsequence of skipping authentication: All workflow data (runs, steps, signals, job details) will be publicly accessible to anyone who can reach the mounted route.
Add to your Gemfile:
gem "rigid_workflow"Run the installer:
rails generate rigid_workflow:install
rails db:migrateThe install generator creates:
config/initializers/rigid_workflow.rb- Configuration file- Mounts the engine in
config/routes.rb - A migration to create the 4 required tables (
rigid_workflow_runs,rigid_workflow_steps,rigid_workflow_step_attempts,rigid_workflow_signals)
# Generate a workflow class
rails generate rigid_workflow:workflow OrderProcessing
# Generate an activity class
rails generate rigid_workflow:activity ChargeCustomer# app/workflows/order_processing_workflow.rb
class OrderProcessingWorkflow < RigidWorkflow::Workflow
class ValidateOrder < RigidWorkflow::Activity
def perform(order_id:, **)
order = Order.find(order_id)
raise "Invalid order" unless order.valid?
{ order_id: order_id, validated: true }
end
end
class ProcessPayment < RigidWorkflow::Activity
def perform(order_id:, **)
# Process payment...
{ payment_id: "pay_123", order_id: order_id }
end
# Called if a later step fails (Saga pattern)
def compensate
refund_payment(output[:payment_id])
end
end
class SendConfirmation < RigidWorkflow::Activity
def perform(order_id:, **)
OrderMailer.confirmation(order_id).deliver_later
{ confirmed: true }
end
end
version 2 # Optional: track workflow definition versions
def run
step :validate, ValidateOrder, input: { order_id: params[:order_id] }
step :payment, ProcessPayment, input: { order_id: params[:order_id] }
step :confirm, SendConfirmation, input: { order_id: params[:order_id] }
end
endrun = OrderProcessingWorkflow.start!(order_id: 123)run.emit_signal(:payment_received, method: "applepay")step :name, ActivityClass, input: { key: "value" }, async: trueOptions:
| Option | Description | Default |
|---|---|---|
input: |
Hash of input data passed to the activity | Workflow params |
async: |
Run asynchronously (enqueued as a job) | false |
wait: |
Delay before executing (e.g., wait: 1.hour) |
nil |
wait_until: |
Execute at a specific time | nil |
max_attempts: |
Number of retry attempts | 3 |
retry_delay: |
Base delay for exponential backoff | 15.seconds |
Async restriction: async: true steps will suspend the workflow. Use them inside parallel or race blocks to avoid suspension, or mark the activity class with force_async:
class SlowActivity < RigidWorkflow::Activity
force_async true
def perform(**)
# This step always runs asynchronously, even outside parallel/race
end
endloop :items, collection do |item, index|
step :process_item, ProcessItemActivity, input: { item: item }
endThe loop index is persisted in workflow memory. If the workflow is interrupted and resumes, it picks up where it left off.
parallel :notifications do
step :email, SendEmailActivity
step :sms, SendSmsActivity
step :push, SendPushActivity
endAll steps within a parallel block run concurrently via ActiveJob. The workflow suspends until all complete.
race :approval do
wait :manual_approval
wait :auto_approval, timeout: 24.hours
endThe first signal to arrive wins. The other branches are canceled.
# Wait indefinitely for a signal
wait :payment_received
# Wait with timeout
wait :payment_received, timeout: 1.hour
# Emit a signal elsewhere to resume that workflow
run.emit_signal(:payment_received, method: "applepay")When an activity exhausts all retries, the workflow automatically compensates all previously completed steps in reverse order:
class ReserveInventory < RigidWorkflow::Activity
def perform(product_id:, quantity:, **)
{ reserved: true }
end
def compensate
inventory.release(output[:product_id], output[:quantity])
end
endCompensation is called on each completed step's activity. If compensation itself fails, the run stays in compensating status for manual intervention.
def run
user = memo(:current_user) { User.find(params[:user_id]) }
# User is cached in workflow memory across resumptions
endclass OrderWorkflow < RigidWorkflow::Workflow
version 2
def run
if @run_version < 2
# Legacy path for runs started before v2
else
# Current logic
end
end
endSet version in the workflow class. Each run captures the version at start time. Access it via @run_version for conditional logic.
Mount the engine in your routes:
# config/routes.rb
mount RigidWorkflow::Engine => "/admin/rigid_workflow"- Overview (
/admin/rigid_workflow) - Stats table per workflow class: completed/active/pending/failed counts, success percentage, P50 duration - All Runs (
/admin/rigid_workflow/runs) - Paginated list with filtering by status (pending/active/completed/failed), clickable rows, select-all checkboxes, bulk action bar (retry/cancel selected runs) - Run Detail (
/admin/rigid_workflow/runs/:id) - Run metadata, step attempt history, and an interactive vis-timeline Gantt chart
- Tailwind CSS v4 (CDN)
- Hotwire Turbo + Stimulus (importmap-managed)
- vis-timeline for interactive Gantt charts
- LocalTime for client-side time formatting
- Kaminari for pagination
All key lifecycle events emit via ActiveSupport::Notifications:
# Subscribe to events
RigidWorkflow.on("workflow.complete") do |payload|
payload # => { run_id: "..." }
end
RigidWorkflow.on("step.fail") do |payload|
payload # => { run_id: "...", step_id: "..." }
endAvailable events: workflow.start, workflow.complete, workflow.fail, step.complete, step.fail, step.retry, step.canceled
# config/initializers/rigid_workflow.rb
RigidWorkflow.configure do |config|
# REQUIRED for admin UI security. Controller class the admin UI inherits from.
config.admin_controller = "MyAdminController"
# Maximum retry attempts for failed activities (default: 3)
config.max_attempts = 3
# Base delay for exponential backoff retries in seconds (default: 15)
config.retry_delay = 15.seconds
# Enable logging output in all environments including test (default: false)
config.logging = true
end| Option | Default | Description |
|---|---|---|
admin_controller |
nil |
Controller class for admin UI inheritance (required for auth) |
max_attempts |
3 |
Maximum retry attempts for failed activities |
retry_delay |
15.seconds |
Base delay for exponential backoff (±20% jitter) |
logging |
nil |
Enable workflow/activity log output even in test |
If a workflow fails or crashes, the last known state remains in the database. When the next worker picks up the job, it continues exactly where it left off. If one of the activities fails, they will be retried automatically by the underlying job queue. Upon success or max attempts reached, the workflow will continue or fail respectively.
Since all state transitions are transactional within your Rails database, if the database is down, the workflow jobs will fail and be retried by the job queue. Once the database is back up, the workers will retry processing the enqueued jobs.
Workflows are code. If you change a workflow definition while a run is in progress:
- New steps: Will be picked up as the workflow advances.
- Removed steps: If already completed, their results remain in history. If not yet reached, they are skipped.
- Changed logic: Will apply to all future steps of the currently running workflow.
For breaking changes, we recommend creating a new workflow class (e.g.,
OrderWorkflowV2).
Yes. Because state is persisted in the database and execution is driven by job scheduling, signals, and timers, a workflow can sit in a waiting state indefinitely without consuming CPU or memory.
You can test individual Activity classes in isolation or use integration tests to verify the entire Workflow flow. The project itself uses RSpec with an in-memory SQLite database, DatabaseCleaner, and automatic job performance for testing. See the spec/ directory for examples.
Definitely, every step results in at least one database write to persist the state. This is the trade-off for durability. For high-throughput, sub-millisecond tasks, raw ActiveJob with Redis might be faster, but for business-critical processes where you cannot afford to lose state, the overhead is acceptable.
Temporal: Temporal is a separate system (Go/Java server) that requires a heavy infrastructure setup. Rigid Workflow is built on top of Rails and ActiveJob. It lives in your existing DB and uses your existing workers infrastructure.
Sidekiq: Sidekiq is a job queue. While you can chain jobs in Sidekiq, managing state, retries, and compensation across chains is manual and complex. Rigid Workflow provides a durable workflow orchestration engine. It is ready to go.
State Machines (AASM): State machines track the status of a model (e.g., an Order). Rigid Workflow tracks the process of a business flow. They are often used together: a Workflow might update an Order's state machine.
DBOS: is a multi-language (TypeScript, Python, Java, Kotlin) durable execution library that checkpoints workflow state to Postgres via decorators/annotations. Rigid Workflow is Rails-only and uses ActiveJob + your Rails database.
When to choose Rigid Workflow: You're building a Rails app and want a full-featured workflow engine with Saga, signals, race conditions, and an admin dashboard out of the box — all within your existing database and worker infrastructure.
Rigid Workflow is licensed under the GNU Affero General Public License (AGPL) v3.0 or later. This means that you are allowed to modify the code and/or provide it as a Software-as-a-Service, but you are required to make your modifications available to the users of that service.
