Workflows & Graphs

ask-graph lets you define durable, multi-step workflows with conditional routing, parallel execution, human approval steps, crash recovery, and sub-workflow composition. Each step is a plain Ruby class.

Use ask-graph when you need a reliable multi-step process — order fulfillment, document processing pipelines, approval workflows, data ETL. Steps checkpoint after every completion, so a crash mid-way means you resume where you left off, not from the start.

gem "ask-graph"

Quick Start

require "ask-graph"

# Define a workflow
module ProcessOrder
  class Workflow < Ask::Graph
    step ValidateOrder
    step ChargeCustomer,  if: :valid?
    step SendConfirmation, if: :valid?
    step NotifyAdmin,     unless: :valid?

    private

    def valid?
      context.order.valid?
    end
  end
end

# Run it
result = ProcessOrder::Workflow.call(order: order)
result.order      # => the validated order
result.charged    # => true if payment succeeded

The call class method creates an instance, runs through all steps, and returns a Context object with every step’s output accessible by name.

Defining Steps

Steps are plain Ruby classes with a call(context) method. They read from and write to the context — no return values, no special interfaces.

class ValidateOrder
  def call(context)
    order = context.input[:order]
    context.order = OrderValidator.validate(order)
    context.valid = context.order.errors.empty?
  end
end

When a Hash is passed as input to a workflow, its keys are directly accessible on the context:

result = MyWorkflow.call({ order: order })
result.input       # => { order: order }
result.order       # => the order — same as input[:order]

This works for all hash inputs, including data passed to sub-workflows.

The step contract

A step must implement call(context). That’s it. No base class to inherit, no module to include. The context is an Ask::Graph::Context object — use method access or bracket access, whichever reads better:

class SetValue
  def call(context)
    context.greeting = "hello"      # method setter
    context[:color] = "blue"        # bracket setter
  end
end

class ReadValue
  def call(context)
    puts context.greeting           # => "hello"
    puts context[:color]            # => "blue"
  end
end

Conditional Steps

Use if: or unless: to control whether a step runs. The condition is a method name on the workflow class:

module HandleCall
  class Workflow < Ask::Graph
    step BookAppointment,  if: :booking?
    step EmergencyAlert,   if: :emergency?
    step HandleInquiry,    unless: :known_intent?

    private

    def booking?   = context.intent == "booking"
    def emergency? = context.intent == "emergency"
    def known_intent? = %w[booking emergency inquiry].include?(context.intent)
  end
end

A step is skipped when its condition returns false (if:) or true (unless:). Skipped steps don’t affect the context or trigger hooks. Steps after a skipped step run normally.

Parallel Execution

Use steps (plural) to run multiple steps simultaneously. All steps run in parallel threads, and the workflow waits for all to finish before continuing:

module SyncData
  class Workflow < Ask::Graph
    step FetchRecords

    # All three run in parallel
    steps CrmUpdate, CalendarSync, SendNotification

    step ConfirmResponse
  end
end

Parallel steps share the same context — each step reads from and writes to it concurrently. Use thread-safe operations (the context is mutex-protected). If any step fails, the entire group fails.

Sub-Workflows

A step can delegate to another workflow. This is how you compose larger processes from smaller, reusable workflows.

module NotifyCustomer
  class Workflow < Ask::Graph
    step SendEmail
    step LogNotification
  end
end

Wrap it in a PORO step that calls Workflow.call(context):

module OrderFulfillment
  class NotifyCustomer
    def call(context)
      NotifyCustomer::Workflow.call(context)
    end
  end

  class Workflow < Ask::Graph
    step ValidatePayment
    step NotifyCustomer
    step ShipOrder
  end
end

NotifyCustomer::Workflow.call(context) exports the current context, creates the sub-workflow, runs it, and merges every result back. The sub-workflow sees all the outer context’s data — no special wiring required.

You can also use context.run for the same thing:

class NotifyCustomer
  def call(context)
    context.run(NotifyCustomer::Workflow)
  end
end

Sub-workflows support nesting — a sub-workflow can call another sub-workflow. Timeouts, retries, and conditions on the outer step apply to the entire sub-workflow as a unit.

Suggested directory layout

app/workflows/
  notify_customer/
    workflow.rb          # module NotifyCustomer; class Workflow < Ask::Graph
    steps/
      send_email.rb
      log_notification.rb
  order_fulfillment/
    workflow.rb          # module OrderFulfillment; class Workflow < Ask::Graph
    steps/
      validate_payment.rb
      notify_customer.rb
      ship_order.rb

The directory name acts as the module namespace. workflow.rb holds the graph class. steps/ holds the step POROs.

Human-in-the-Loop (Approve)

Use approve to pause a workflow and wait for external input. The step runs, then the workflow saves its checkpoint and raises a pause signal. Call resume on the same instance to continue:

module ProcessBooking
  class Workflow < Ask::Graph
    step BookAppointment
    approve ReviewBooking, if: :expensive?
    step ConfirmBooking
  end
end
graph = ProcessBooking::Workflow.new(input)
result = graph.call                  # runs, pauses after ReviewBooking

# Later, when the operator responds:
result = graph.resume(input: "approved")  # resumes, runs ConfirmBooking

The approve step’s context changes are preserved in the checkpoint. When you resume, the workflow skips the approve step and continues with the remaining steps.

Timeouts

Set a timeout on any step. If the step takes longer than the given seconds, it raises Ask::Graph::StepFailed.

step FetchApi, timeout: 10, retry: 2

Set a default timeout for all steps in a workflow:

module ApiWorkflow
  class Workflow < Ask::Graph
    timeout 30
    step FastOp           # uses 30s
    step SlowOp, timeout: 120  # overrides
  end
end

Set a global default for every workflow:

Ask::Graph.timeout 30

Resolution order: step timeout: → workflow timeoutAsk::Graph.timeout. Set timeout nil on a child workflow to explicitly clear the parent’s default.

Retry

Retry a step when it fails. The step is retried up to the specified number of times with exponential backoff:

step FlakyOp, retry: 3

If all retries are exhausted, Ask::Graph::StepFailed is raised. The on_failure hook fires before each retry and on the final failure.

step FetchApi, timeout: 10, retry: 2

Timeouts also trigger retries. Each attempt runs within the timeout window, and a timeout counts as a failure for retry purposes.

Lifecycle Hooks

Observe or intervene at each step boundary:

Hook When it fires
before_step Before every step
after_step After every successful step
on_failure When a step fails
module MonitoredWorkflow
  class Workflow < Ask::Graph
    before_step :log_start
    after_step  :log_completion
    on_failure  :alert_team

    step ProcessPayment, timeout: 15, retry: 2
  end
end

Hooks receive declaration: and context: keyword arguments. on_failure also receives error: with the failure message:

def log_start(declaration:, context:)
  Rails.logger.info "Starting #{declaration[:name]}"
end

def alert_team(declaration:, context:, error:)
  SlackNotifier.alert("Step failed: #{error.message}")
end

Hooks are inherited by subclasses and can be stacked — multiple before_step declarations all fire in order.

Crash Recovery (Checkpointing)

By default, workflows use an in-memory store. If the process crashes, the workflow restarts from the beginning. Pass a storage backend to make workflows durable across restarts:

store = Ask::State::Memory.new   # default, dies on restart
store = RedisStore.new            # survives restarts
store = PostgresStore.new         # or any backend with #set and #get

result = OrderFulfillment::Workflow.call(input, storage: store)

# If a crash occurs, resume from the last completed step:
result = OrderFulfillment::Workflow.call(input, storage: store)

Set a default storage for all workflows:

Ask::Graph.storage RedisPool.new

# Every workflow uses it automatically
result = MyWorkflow.call(input)

Override per-graph or per-call:

class MyWorkflow < Ask::Graph
  storage PostgresStore.new
end

MyWorkflow.call(input)                          # uses PostgresStore
MyWorkflow.call(input, storage: InMemory.new)   # overrides per-call

Per-Item Iteration

When a step needs to process a list of items — sending notifications, processing records — use context.each inside the step. It automatically checkpoints after each item, so a crash mid-list resumes from the last completed item:

class SendVoiceReminders
  def call(context)
    context.each(context.appointments) do |appt|
      PhoneService.call(appt.number, appt.message)
    end
  end
end

module DailyReminders
  class Workflow < Ask::Graph
    step FetchAppointments
    step SendVoiceReminders
    step MarkComplete
  end
end

context.item gives you the current item being processed, accessible anywhere inside the block.

Error Handling

When a step fails, Ask::Graph::StepFailed is raised with the step name in the message:

begin
  MyWorkflow::Workflow.call(input)
rescue Ask::Graph::StepFailed => e
  puts e.message  # => "RiskyOperation failed: connection timeout"
end

The on_failure hook fires before the exception is raised, giving you a chance to log, alert, or clean up.

Step Metadata

Attach a human-readable description to any step:

step ValidateOrder, description: "Verify order details with inventory system"
step ChargeCustomer, description: "Process payment via Stripe"

The description is available in the declaration for debugging, monitoring, and hook callbacks.

Inheritance

Child workflow classes inherit steps and hooks from their parent. Adding new steps to the child doesn’t affect the parent:

class Parent < Ask::Graph
  step ValidateOrder
end

class Child < Parent
  step ChargeCustomer
end

Parent.declarations.size  # => 1
Child.declarations.size   # => 2

Configuration Reference

Option Where Purpose
timeout step option Max seconds per step
timeout class method Default timeout for all steps in this workflow
Ask::Graph.timeout global Default timeout for every workflow
retry step option Number of retries on failure
storage class method Checkpoint backend for this workflow
Ask::Graph.storage global Default checkpoint backend for all workflows
storage: .call param Override storage per-call

Next Steps


This site uses Just the Docs, a documentation theme for Jekyll.