GenevaDrive
Durable workflows for Rails applications.
GenevaDrive provides a clean DSL for defining multi-step workflows that execute asynchronously, with strong guarantees around idempotency, concurrency control, and state management.
Features
- Hero-oriented: Workflows are associated with a polymorphic "hero" (the subject of the workflow)
- Step-based execution: Define workflows as a series of steps with optional wait times
- Resumable steps: Iterate over large collections with a database-checkpointed cursor, surviving interruptions
-
Signals: Steps can park until an external event arrives (
wait_for:), woken byworkflow.signal!from a webhook or controller - Durable: Steps are persisted to the database, surviving process restarts
- Idempotent: Database constraints ensure each step runs exactly once
-
Flow control: Methods like
cancel!,pause!,skip!,reattempt!, andfinished! - Exception handling: Configure per-step exception behavior
- Multi-database support: PostgreSQL, MySQL, and SQLite
Installation
Add this line to your application's Gemfile:
gem "geneva_drive"Then install the gem and run the generator:
bundle install
bin/rails generate geneva_drive:install
bin/rails db:migrateYou should also add the housekeeping job to your background job cron table
(below example is for recurring.yml in solid_queue):
geneva_drive_housekeeping:
schedule: "*/30 * * * *" # Every 30 minutes
class: "GenevaDrive::HousekeepingJob"Usage
Defining a Workflow
class SignupWorkflow < GenevaDrive::Workflow
step :send_welcome_email do
WelcomeMailer.welcome(hero).deliver_later
end
step :send_reminder, wait: 2.days do
ReminderMailer.remind(hero).deliver_later
end
step :complete_onboarding, wait: 7.days do
hero.update!(onboarding_completed: true)
end
endStarting a Workflow
SignupWorkflow.create!(hero: current_user)Flow Control
Use flow control methods inside steps to control workflow execution:
class PaymentWorkflow < GenevaDrive::Workflow
step :process_payment do
result = PaymentGateway.charge(hero)
case result.status
when :success
# Continues to next step
when :declined
cancel! # Workflow canceled
when :rate_limited
reattempt!(wait: 5.minutes) # Retry later
when :pending
pause! # Wait for manual intervention
end
end
step :send_receipt do
ReceiptMailer.send_receipt(hero).deliver_later
end
endConditional Steps
Skip steps based on conditions:
class TrialWorkflow < GenevaDrive::Workflow
step :send_trial_email do
TrialMailer.start(hero).deliver_later
end
step :charge_card, skip_if: -> { hero.free_tier? } do
PaymentGateway.charge(hero)
end
step :activate_account do
hero.activate!
end
endBlanket Conditions
Cancel workflow if conditions become true:
class OnboardingWorkflow < GenevaDrive::Workflow
cancel_if { hero.deactivated? }
cancel_if { hero.deleted_at.present? }
step :step_one do
# Won't run if hero is deactivated
end
endException Handling
Configure how exceptions are handled per step:
class RobustWorkflow < GenevaDrive::Workflow
# Retry on any error (default: pause!)
step :flaky_api, on_exception: :reattempt! do
ExternalApi.call(hero)
end
# Skip step if it fails (non-critical)
step :send_notification, on_exception: :skip! do
NotificationService.notify(hero)
end
# Cancel workflow if critical step fails
step :critical_operation, on_exception: :cancel! do
CriticalService.process(hero)
end
endWorkflow Inheritance
class BaseWorkflow < GenevaDrive::Workflow
cancel_if { hero.deactivated? }
set_step_job_options queue: :workflows
end
class PremiumWorkflow < BaseWorkflow
cancel_if { hero.subscription_expired? }
set_step_job_options queue: :premium, priority: 10
step :premium_feature, job_options: {priority: -1} do
# Inherits cancel_if from parent
end
endStep job options override workflow defaults when the step is initially enqueued, reattempted, or resumed. Options are passed directly to Active Job's set method.
Hero Deletion Handling
By default, workflows cancel if their hero is deleted:
# Default: auto-cancel if hero is deleted
class PaymentWorkflow < GenevaDrive::Workflow
step :charge do
hero.charge! # Won't run if hero was deleted
end
end
# Allow workflow to continue without hero
class CleanupWorkflow < GenevaDrive::Workflow
may_proceed_without_hero!
step :cleanup do
DataArchive.cleanup(hero&.id)
end
endResuming Paused Workflows
# Find and resume paused workflows
PaymentWorkflow.paused.find_each(&:resume!)Querying Workflows
# By state
SignupWorkflow.ready
SignupWorkflow.performing
SignupWorkflow.finished
SignupWorkflow.canceled
SignupWorkflow.paused
SignupWorkflow.ongoing # Not finished or canceled
# By hero
SignupWorkflow.for_hero(user)Execution History
workflow = SignupWorkflow.find(123)
workflow.execution_history.each do |execution|
puts "#{execution.step_name}: #{execution.state} (#{execution.outcome})"
puts " Error: #{execution.error_message}" if execution.error_message
endTesting
Include GenevaDrive::TestHelpers in your tests:
class SignupWorkflowTest < ActiveSupport::TestCase
include GenevaDrive::TestHelpers
test "completes all steps" do
workflow = SignupWorkflow.create!(hero: users(:one))
speedrun_workflow(workflow)
assert_workflow_state(workflow, :finished)
assert_step_executed(workflow, :send_welcome_email)
assert_step_executed(workflow, :send_reminder)
end
test "step-by-step execution" do
workflow = SignupWorkflow.create!(hero: users(:one))
perform_next_step(workflow)
assert_equal "send_reminder", workflow.current_step_name
perform_next_step(workflow)
assert_equal "complete_onboarding", workflow.current_step_name
end
endDatabase Support
| Feature | PostgreSQL | MySQL | SQLite |
|---|---|---|---|
| Workflow Uniqueness | Partial Index | Generated Column | Partial Index |
| Step Execution Uniqueness | Partial Index | Generated Column | Partial Index |
Requirements
- Ruby 3.0+
- Rails 7.2.2+
Licenses
The gem is available both as open source under the terms of the LGPLv3 and as commercial software, see the license files for more detail