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.
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.