Saga compensation
const reserve = defineStep({ type: 'reserve', workflowInputSchema, outputSchema: z.object({ ticketId: z.string() }), handler: async () => ({ ticketId: await reserveSeat() }), compensate: async (ctx) => { await releaseSeat(ctx.output.ticketId); }, // undo on later failure});On workflow failure the engine runs each completed step’s compensate in reverse dependency
order (compensating → compensated).
→ examples/10-saga-compensation.ts
When it runs
Section titled “When it runs”Only on the failure path — the moment the workflow is finalized as failed, after every
step has reached a terminal state. Specifically:
| Ending | Compensation |
|---|---|
| a step fails terminally (retries exhausted) | yes |
| a map item fails, failing its parent | yes |
| a sub-workflow child fails, failing the parent step | yes, in both parent and child |
engine.cancelWorkflow(id) |
no — cancel is not failure |
| workflow completes | no |
Cancellation deliberately does not compensate. If you need rollback on a cancel, do it yourself
after cancelWorkflow returns.
Which steps, and in what order
Section titled “Which steps, and in what order”Only steps that are completed and whose type actually registered a compensate handler.
A step that failed, was skipped, or never ran has nothing to undo.
Order is a reverse topological sort over that subset — each step is compensated before the dependencies it was built on, so effects come apart in the opposite order they were made. Steps are compensated one at a time, sequentially, not in parallel; a diamond’s two branches unwind in a deterministic but unspecified relative order.
The handler
Section titled “The handler”compensate receives the same typed context as the step’s handler, plus output — the thing
to undo:
compensate: async (ctx) => { ctx.output.ticketId; // this step's own output ctx.deps.findSeat.row; // dependency outputs, still available ctx.workflowInput; // and the workflow input}The context is re-derived best-effort: if a schema no longer matches what was persisted, the
raw stored value is passed through rather than failing the rollback. buildStepContext runs
again too, so ctx.context is live — a compensation handler can use your DI scope and domain
services exactly like a step handler.
Best-effort, by design
Section titled “Best-effort, by design”- One attempt per step.
retrydoes not apply to compensation. - A throw is caught, logged, and surfaced on the step as
compensatedwith aCompensation failed: …error — and the remaining steps still compensate. One failed rollback never blocks the others. - There is no “compensation failed” workflow state. The workflow is already
failed; inspect the individual steps (or thestep.compensatedevents, which carryerror) to find rollbacks that didn’t take. - It runs outside the engine’s transaction. Rollback handlers do network I/O, and a transaction must never be held open across that — so compensation is not atomic with the failure that triggered it.
Make handlers idempotent. A crash mid-compensation is not resumed: the sweep is driven by
the failure finalization, and a step already marked compensating is not retried by anything.
How it surfaces
Section titled “How it surfaces”compensating folds to the display state running and compensated to skipped in the
public view — a rolled-back step reads as “didn’t happen”, with the
failure reported by the step that actually failed. Watch the step.compensating /
step.compensated events if you need the real states.