PATTERN Cited by 1 source
Saga rollback as step metadata¶
Intent¶
Attach compensation (rollback) logic directly to the durable step definition as configuration metadata, rather than in a separate catch block, a global error handler, or a separate builder/wrapper API. The engine owns the orchestration of when and in what order rollbacks run; the developer only declares what undo means for each step, co-located with the forward action.
Context¶
Workflow engines that support the saga pattern need a place to declare compensation logic. Traditional approaches include:
- Try/catch blocks — developer manually tracks what succeeded and unwinds in reverse. Error-prone, doesn't compose with parallel steps, compensation logic sprawls as the workflow grows.
- Separate compensation registries — a global error handler or saga coordinator that maps step names to undo functions. Decouples forward and reverse logic, making it harder to reason about what undoes what.
- Builder/fluent APIs —
step.saga("name").do(...).rollback(...).run()orstep.do(...).rollback(...). Adds ceremony or creates semantic ambiguity with the step's return value.
Solution¶
Make rollback a metadata option on the step primitive itself:
await step.do("debit-bank-a", () => bankA.debit(from, amount), {
rollback: async ({ output }) => bankA.credit(from, amount, output.id),
rollbackConfig: {
retries: { limit: 10, delay: '30 seconds', backoff: 'exponential' },
timeout: '2 minutes',
},
});
Properties of this shape:
- Co-location. Forward action and its undo live in the same call site. Add a step, add its rollback right there.
- Engine-managed orchestration. The engine fires rollbacks in reverse step-start order on terminal failure. Developer doesn't manage ordering.
- No new API surface. Extends the existing step primitive rather than introducing a new type (builder, saga coordinator).
- Step timing unchanged. The step starts when
step.do()is called; the returned promise still represents step output. No deferred start. - Opt-in per step. Steps without rollback handlers simply aren't compensated.
Relationship to prior art¶
- Skipper's
@Compensateannotation is the same concept at the annotation-class level — compensation co-located with the forward action. The metadata-options shape is the TypeScript/functional equivalent for engines without annotation-based class models. - Temporal's compensation via
try/finally+ activity cancellation is more general but requires explicit developer orchestration of the reverse walk.
Trade-offs¶
- Simpler adoption — one options object to learn; existing
step.do()calls keep working. - Sequential rollback only — the engine doesn't (yet) support parallel compensation, limiting throughput of the reverse walk.
- Rollback handler failure stops the walk — if a handler exhausts retries, the Workflow errors out and remaining handlers don't run. No partial-completion model.
- Handler must tolerate
output === undefined— the failing step may have partially executed before persisting output.
Seen in¶
- sources/2026-06-25-cloudflare-saga-rollbacks-for-workflows — canonical wiki instance. Cloudflare Workflows ships
{ rollback, rollbackConfig }as the options-based API after rejecting fluent and builder alternatives. Explicit framing: "each step ships with its own undo. add a step, add its rollback right here. no growing catch block, no manual ordering, no replay logic."
Related¶
- patterns/saga-over-long-transaction — the distributed-systems pattern this operationalises
- patterns/compensation-stub-recovery-via-replay — the recovery mechanism that makes this pattern survive engine restarts
- patterns/workflow-primitives-as-annotated-classes — Skipper's annotation-class equivalent
- concepts/workflow-compensation-action — the abstract concept
- concepts/durable-execution — the property that makes persisted rollback metadata meaningful
- systems/cloudflare-workflows — canonical implementation
- systems/airbnb-skipper — annotation-class sibling implementation