Skip to content

Sagas, State Machines, and Long-Running Workflows

This guide covers three related patterns for managing complex, multi-step processes:

  • Sagas — distributed transactions with automatic compensation (rollback)
  • State Machines — state-based entity lifecycle management
  • Long-Running Workflows — sagas that pause for timers or external events

Sagas

A saga orchestrates a multi-step distributed transaction. Each step has an action (forward operation) and an optional compensate (rollback). If any step fails, compensations run in reverse order for all previously completed steps.

saga "create_order" {
  from {
    connector = "api"
    operation = "POST /orders"
  }

  step "order" {
    action {
      connector = "orders_db"
      operation = "INSERT"
      target    = "orders"
      data      = { status = "pending", user_id = "input.user_id" }
    }
    compensate {
      connector = "orders_db"
      operation = "DELETE"
      target    = "orders"
      where     = { id = "step.order.id" }
    }
  }

  step "payment" {
    action {
      connector = "stripe"
      operation = "POST /charges"
      body      = { amount = "input.amount", currency = "input.currency" }
      headers   = { "Idempotency-Key" = "input.order_id" }   # per-request headers, like a step's
    }
    compensate {
      connector = "stripe"
      operation = "POST /refunds"
      body      = { charge = "step.payment.charge_id" }
    }
  }

  step "inventory" {
    action {
      connector = "inventory_db"
      operation = "UPDATE"
      target    = "inventory"
      set       = { reserved = "reserved + input.quantity" }
      where     = { product_id = "input.product_id" }
    }
    compensate {
      connector = "inventory_db"
      operation = "UPDATE"
      target    = "inventory"
      set       = { reserved = "reserved - input.quantity" }
      where     = { product_id = "input.product_id" }
    }
  }

  on_complete {
    connector = "orders_db"
    operation = "UPDATE"
    target    = "orders"
    set       = { status = "confirmed" }
    where     = { id = "step.order.id" }
  }

  on_failure {
    connector = "notifications"
    operation = "POST /send"
    template  = "order_failed"
    data      = { user_id = "input.user_id", reason = "error.message" }
  }
}

How Sagas Work

  1. Steps execute in order
  2. Each completed step's result is available as step.NAME.* in subsequent steps
  3. If a step fails, all previously completed compensations run in reverse order
  4. on_complete runs after all steps succeed
  5. on_failure runs after all compensations complete (regardless of compensation result)

Step-Level Error Handling

Mark non-critical steps with on_error = "skip" to continue without triggering compensation:

step "send_notification" {
  on_error = "skip"
  action {
    connector = "email"
    operation = "send"
  }
  # No compensate block — skip means: failure is acceptable
}

Simple Sagas (No Delay/Await)

Simple sagas execute synchronously and return a response when all steps complete. No extra configuration is needed — just define the saga block.

State Machines

A state machine defines valid states and transitions for an entity. State is persisted in the entity's status column. Transitions can have guards (CEL conditions) and side-effect actions.

state_machine "order_status" {
  initial = "pending"

  state "pending" {
    on "pay"    { transition_to = "paid" }
    on "cancel" { transition_to = "cancelled" }
  }

  state "paid" {
    on "ship" {
      transition_to = "shipped"
      guard         = "input.tracking_number != ''"
      action {
        connector = "notifications"
        operation = "POST /send"
        template  = "order_shipped"
        data      = { tracking = "input.tracking_number" }
      }
    }
    on "refund" { transition_to = "refunded" }
  }

  state "shipped" {
    on "deliver" { transition_to = "delivered" }
  }

  state "delivered" { final = true }
  state "cancelled" { final = true }
  state "refunded"  { final = true }
}

Triggering Transitions

Use the state_transition block in a flow:

flow "update_order_status" {
  from {
    connector = "api"
    operation = "POST /orders/:id/events"
  }

  state_transition {
    machine = "order_status"  # Name of the state_machine block
    entity  = "orders"        # Database table
    id      = "input.id"
    event   = "input.event"   # e.g., "pay", "ship", "cancel"
    data    = "input.data"    # Additional data for guards and actions

    # Optional: where the entity lives. Defaults to the flow's destination
    # below; without either, every connector is tried in turn.
    connector = "db"
  }

  to {
    connector = "db"
    target    = "orders"
  }
}

State Machine Features

  • Guards — CEL expressions that prevent invalid transitions (e.g., require tracking number before shipping)
  • Actions — side effects executed during a transition (e.g., send notification)
  • Final states — states that cannot transition further (final = true)
  • Initial state — applied when the entity has no status value yet

Error Handling

If a guard fails (returns false), the transition is rejected with an error. If no valid transition exists for the event in the current state, an error is returned.

Long-Running Workflows

When a saga includes steps that pause execution, Mycel persists the workflow state to a database so it survives restarts.

Configuration

Enable workflow persistence in the service block:

service {
  name    = "order-service"
  version = "1.0.0"

  workflow {
    storage     = "db"              # Database connector name
    table       = "mycel_workflows" # Table name (default)
    auto_create = true              # Create table on startup

    # The HTTP interface to running workflows. Optional, and off unless it is
    # written: these endpoints wake and cancel workflows, so they are not on
    # the admin server and they are never served without authentication.
    api {
      port = 9091                   # default 9091; the admin port is refused

      auth {                        # required — the same block a connector takes
        type = "api_key"
        keys = [env("WORKFLOW_API_KEY")]
      }
    }
  }
}

Delay Steps

Pause execution for a duration:

saga "onboarding" {
  from {
    connector = "api"
    operation = "POST /onboard"
  }

  step "create_account" {
    action {
      connector = "db"
      operation = "INSERT"
      target    = "accounts"
    }
  }

  step "wait_24h" {
    delay = "24h"
  }

  step "send_welcome_email" {
    action {
      connector = "email"
      operation = "send"
      data      = { template = "welcome", user_id = "step.create_account.id" }
    }
  }
}

When the saga reaches the delay step, it saves state to the database and pauses. A background ticker checks for workflows whose delay has expired and resumes them automatically.

Await/Signal Steps

Pause until an external system sends a signal:

saga "loan_approval" {
  timeout = "7d"

  from {
    connector = "api"
    operation = "POST /loans"
  }

  step "submit" {
    action {
      connector = "db"
      operation = "INSERT"
      target    = "loans"
    }
  }

  step "wait_approval" {
    await   = "loan_approved"    # Pause until this event is signaled
    timeout = "48h"              # Step-level timeout
  }

  step "disburse" {
    action {
      connector = "banking"
      operation = "POST /transfers"
    }
  }

  on_failure {
    connector = "notifications"
    operation = "POST /send"
    template  = "loan_rejected"
  }
}

Signal the workflow via the auto-registered REST API:

POST /workflows/{workflow_id}/signal/loan_approved
X-API-Key: $WORKFLOW_API_KEY
Content-Type: application/json

{ "approved_by": "manager@company.com", "note": "Approved" }

The signal data is available in subsequent steps as input.signal.

Workflow REST API

The engine auto-registers three endpoints:

Endpoint Description
GET /workflows/{id} Get workflow status, current step, timestamps
POST /workflows/{id}/signal/{event} Resume a paused workflow awaiting an event
POST /workflows/{id}/cancel Cancel an active workflow (runs compensation)

They are served on the port the api block names — 9091 by default — and every request carries the credentials that block configures:

curl -H "X-API-Key: $WORKFLOW_API_KEY" http://localhost:9091/workflows/wf_abc123

Without an api block there are no workflow endpoints at all. They are not on the admin server: that port carries health and metrics, is read-only and unauthenticated by design, and signalling a workflow resumes it with whatever data the caller sends.

When a saga with delay/await steps is triggered, the response is HTTP 202 with the workflow ID:

{
  "workflow_id": "wf-abc123",
  "status": "running",
  "started_at": "2024-01-15T10:30:00Z"
}

Supported Databases

Workflow state can be persisted to: PostgreSQL, MySQL, SQLite.

See Also