Workflow (Sagas)

The Workflow capability runs multi-step, long-lived business processes with retries, backoff, and compensating actions. It is the classic saga pattern: a plugin declares the shape of a workflow (which steps exist, what each one does, how each should retry), the host owns the driving loop, and failed steps can roll back earlier succeeded steps in reverse order.

When To Use It

Reach for Workflow when a request touches more than one external system and you need all-or-nothing semantics without a distributed transaction:

  • Checkout: reserve stock, charge card, confirm order, send receipt.
  • Onboarding: create user, provision mailbox, attach billing account.
  • Migration jobs: export, transform, upload, verify.

If your operation fits in a single database transaction, you do not need a workflow. Use Workflow when the pieces span services that cannot share a transaction boundary.

The Trait

pub trait WorkflowPlugin: Send + Sync {
    fn name(&self) -> &str;
    fn definitions(&self) -> Vec<WorkflowDefinition>;
    fn run_step(
        &self,
        run: &WorkflowRun,
        step_name: &str,
    ) -> Result<StepOutcome, WorkflowError>;
    fn compensate_step(
        &self,
        _run: &WorkflowRun,
        _step_name: &str,
    ) -> Result<(), WorkflowError> { Ok(()) }
    fn on_workflow_complete(
        &self,
        _run: &WorkflowRun,
    ) -> Result<(), WorkflowError> { Ok(()) }
}

The plugin owns the shape and behavior. The host owns the loop: when to call run_step, when to retry, when to start compensation. Plugins are stateless between calls — state flows through [WorkflowRun] and context_updates returned from each step.

Key Types

Type Purpose
WorkflowDefinition Declared shape: id, version, ordered steps.
StepDefinition A single step: name, retry policy, timeout, idempotent flag.
RetryPolicy max_attempts, backoff_ms, backoff_multiplier, max_backoff_ms.
WorkflowRun Live execution state: run_id, workflow_id, status, current_step, attempt, context.
StepOutcome Continue { context_updates }, Retry { after_ms }, Fail { reason, compensate }.
WorkflowStatus Pending, Running, Succeeded, Failed, Compensated.
WorkflowError StepNotFound (404), Timeout (504), Backend (500).

Retry Policy Semantics

RetryPolicy::delay_ms_for_attempt(n) returns the delay the host waits before attempt n:

  • attempt 1: delay 0 (the initial try has no preceding wait)
  • attempt 2: delay backoff_ms
  • attempt 3: delay backoff_ms * backoff_multiplier
  • attempt N: delay backoff_ms * backoff_multiplier^(N-2)

All delays are clamped to max_backoff_ms. The default exponential policy is max_attempts = 3, backoff_ms = 1000, multiplier = 2.0, max_backoff = 30_000.

RetryPolicy {
    max_attempts: 5,
    backoff_ms: 200,
    backoff_multiplier: 2.0,
    max_backoff_ms: 5_000,
}
// attempts: 0ms, 200ms, 400ms, 800ms, 1600ms

Compensation

When a step returns StepOutcome::Fail { compensate: true, .. } the host walks the completed prefix in reverse step order and calls compensate_step on each. The run ends in WorkflowStatus::Compensated once rollback finishes; if compensation itself fails the run goes to Failed and the host surfaces the error.

Tip

Only steps that successfully completed are compensated. A step that failed before it produced a side effect does not need rollback — set compensate: false in StepOutcome::Fail for those steps to avoid calling compensate_step unnecessarily.

The default compensate_step is a no-op. Idempotent step graphs that don't need rollback just leave it unimplemented.

Example: A Three-Step Checkout Saga

# bext.config.toml
[[plugins]]
name = "workflow-pg"
source = "@bext/workflow-pg"

[[plugins.workflows]]
id = "checkout"
version = 1
steps = [
    { name = "reserve_stock", retries = { max_attempts = 3, backoff_ms = 500, backoff_multiplier = 2.0, max_backoff_ms = 5000 }, idempotent = true },
    { name = "charge_card",   retries = { max_attempts = 1, backoff_ms = 0,   backoff_multiplier = 1.0, max_backoff_ms = 0 },    idempotent = false },
    { name = "send_receipt",  retries = { max_attempts = 5, backoff_ms = 200, backoff_multiplier = 2.0, max_backoff_ms = 10000 }, idempotent = true },
]
fn run_step(
    &self,
    run: &WorkflowRun,
    step_name: &str,
) -> Result<StepOutcome, WorkflowError> {
    match step_name {
        "reserve_stock" => match reserve(&run.context) {
            Ok(reservation_id) => {
                let mut updates = HashMap::new();
                updates.insert("reservation_id".into(), json!(reservation_id));
                Ok(StepOutcome::Continue { context_updates: updates })
            }
            Err(e) if e.transient() => Ok(StepOutcome::Retry { after_ms: 500 }),
            Err(e) => Ok(StepOutcome::Fail {
                reason: e.to_string(),
                compensate: false, // nothing to undo yet
            }),
        },
        "charge_card" => match charge(&run.context) {
            Ok(_) => Ok(StepOutcome::Continue { context_updates: HashMap::new() }),
            Err(_) => Ok(StepOutcome::Fail {
                reason: "card declined".into(),
                compensate: true, // roll back the reservation
            }),
        },
        "send_receipt" => { /* ... */ Ok(StepOutcome::Continue { context_updates: HashMap::new() }) }
        other => Err(WorkflowError::step_not_found(other)),
    }
}

Reference Implementation

The bext monorepo ships @bext/workflow-pg, an in-memory implementation today. When @bext/infra-pg lands, the same plugin gains PostgreSQL-backed persistence so runs survive host restarts. The trait surface does not change — only the storage does.

Feature Flag

None. The Workflow trait and types live in bext-plugin-api and are always available; no cargo feature gates them.

See Also

  • @bext/workflow-pg — the reference implementation.
  • Scheduled jobs — uses the same plugin-declares/host-orchestrates split for cron-style jobs.
  • Durable flows guide — practical patterns for long-running pipelines on bext.
  • Capabilities overview — the full list of pluggable capabilities.