Saga compensation
Run rollback handlers in reverse dependency order when a workflow fails.
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
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
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
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
- 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
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.
Version 0.28.0, MIT-licensed.npm