Sagas & Compensation
In Ironflow, every recorded execution (durable workflow) is a potential Saga. By using step.compensate(), you can implement the Saga Pattern (specifically orchestration-based sagas) to ensure eventual consistency across distributed systems.
What is a Saga?
A Saga is a design pattern used in distributed systems to manage complex, long-running transactions. Unlike a traditional database transaction that follows the ACID properties (Atomicity, Consistency, Isolation, Durability), a Saga ensures eventual consistency by breaking a single transaction into a sequence of smaller, independent steps.
If one step fails, the Saga executes compensating transactions (undo actions) for all previously completed steps to return the system to its initial state. This is essential when working with distributed services (like Stripe, Twilio, or external databases) that cannot be wrapped in a single database transaction.
Workflows as Sagas
Unlike other platforms that require a separate “Saga” primitive or complex state machines, Ironflow treats Sagas as a first-class capability of its standard workflow engine. A workflow acts like a saga simply by registering undo actions for its side effects.
- Orchestration-based: The workflow function acts as an in-process orchestrator coordinating steps and their undos.
- Unified Identity: There is no difference between a “background job,” a “recorded execution,” and a “Saga” in Ironflow — they are all built on the same engine, and every step is a recorded fact.
- Continuous History Integration: Because Ironflow records every step as a permanent fact in the continuous history, the “undo” path is just as durable and observable as the “happy” path. Compensation steps are recorded facts too.
How It Works
- After a step succeeds, register a compensation handler for it.
- If a later step throws a non-retryable error, the SDK runs all registered compensations in reverse order.
- Each compensation is a durable step — it is memoized, so if the run is retried (e.g., after a crash), it won’t re-run compensations that already completed.
step 1: charge-payment ✓ → register refund-paymentstep 2: reserve-inventory ✓ → register release-inventorystep 3: ship-order ✗ ← non-retryable failure
Compensations run in reverse: release-inventory (compensates step 2) refund-payment (compensates step 1)Compensations only run on terminal (non-retryable) failures. Transient errors that trigger automatic retries do not trigger compensation.
By default, cancelling a run mid-saga does not execute compensations. To roll back on cancellation, set compensateOnCancel: true (TypeScript) / CompensateOnCancel: true (Go) in the function config. The flag is honoured only for pull-mode functions — compensation closures exist inside the live SDK process, so push-mode has no point of re-entry after the cancel signal arrives. The Go SDK logs a warning if the flag is set on a push-mode function; the value is accepted but ignored.
step.compensate(stepName, fn) — Register a compensation
import { ironflow } from "@ironflow/node";import { NonRetryableError } from "@ironflow/node";
export const processOrder = ironflow.createFunction( { id: "process-order", triggers: [{ event: "order.placed" }], }, async ({ event, step }) => { // Step 1: charge the customer const payment = await step.run("charge-payment", async () => { return await stripe.charges.create({ amount: event.data.total, customerId: event.data.customerId, }); });
// Register undo for step 1 step.compensate("charge-payment", async () => { await stripe.refunds.create({ charge: payment.id }); });
// Step 2: reserve inventory const reservation = await step.run("reserve-inventory", async () => { return await inventory.reserve(event.data.items); });
// Register undo for step 2 step.compensate("reserve-inventory", async () => { await inventory.release(reservation.id); });
// Step 3: ship the order — if this fails terminally, // release-inventory and refund-payment run automatically await step.run("ship-order", async () => { const result = await shipping.createLabel(event.data.address); if (!result.ok) { throw new NonRetryableError("Shipping unavailable for this address"); } return result; }); },);import "github.com/sahina/ironflow/sdk/go/ironflow"
var ProcessOrder = ironflow.CreateFunction(ironflow.FunctionConfig{ ID: "process-order", Triggers: []ironflow.Trigger{{Event: "order.placed"}},}, func(ctx ironflow.Context) (any, error) { var data struct { Total int `json:"total"` CustomerID string `json:"customerId"` Address string `json:"address"` Items []any `json:"items"` } if err := ctx.Event.Data(&data); err != nil { return nil, err }
// Step 1: charge the customer payment, err := ironflow.Run(ctx, "charge-payment", func() (Payment, error) { return stripe.CreateCharge(data.Total, data.CustomerID) }) if err != nil { return nil, err }
// Register undo for step 1 ironflow.Compensate(ctx, "charge-payment", func() error { return stripe.CreateRefund(payment.ID) })
// Step 2: reserve inventory reservation, err := ironflow.Run(ctx, "reserve-inventory", func() (Reservation, error) { return inventory.Reserve(data.Items) }) if err != nil { return nil, err }
// Register undo for step 2 ironflow.Compensate(ctx, "reserve-inventory", func() error { return inventory.Release(reservation.ID) })
// Step 3: ship the order — if this fails terminally, // release-inventory and refund-payment run automatically _, err = ironflow.Run(ctx, "ship-order", func() (ShipResult, error) { result, err := shipping.CreateLabel(data.Address) if err != nil { return ShipResult{}, ironflow.WrapNonRetryable(err) } return result, nil }) return nil, err})Compensation Ordering
Compensations always run in reverse registration order, regardless of which step failed:
step.compensate("step-a", undoA); // registered 1st → runs laststep.compensate("step-b", undoB); // registered 2nd → runs firstThis ensures later side effects are undone before earlier ones, consistent with the saga pattern.
Compensation Durability
Each compensation is executed as a named durable step with the prefix compensate::
compensate:charge-paymentcompensate:reserve-inventory
If a run is retried after a crash mid-compensation, already-completed compensations are skipped via memoization. This gives exactly-once compensation semantics.
Compensation Failures
If a compensation handler throws, Ironflow:
- Records the failed compensation step in the run’s step history (status:
failed) - Logs the compensation failure with step ID and error details
- Continues running the remaining compensations
A failed compensation does not stop other compensations from running. Monitor failed compensation steps by querying the run’s step history for steps with compensate: prefixed IDs that have a failed status.
Using Compensations in Parallel Branches
In TypeScript, step.compensate works the same inside step.parallel branches since each branch receives a scoped step client. In Go, use ironflow.CompensateInBranch:
await step.parallel("provision-resources", [ async (s) => { const db = await s.run("create-db", async () => createDatabase()); s.compensate("create-db", async () => deleteDatabase(db.id)); return db; }, async (s) => { const cache = await s.run("create-cache", async () => createCache()); s.compensate("create-cache", async () => deleteCache(cache.id)); return cache; },]);ironflow.Parallel(ctx, "provision-resources", []func(*ironflow.BranchContext) (any, error){ func(b *ironflow.BranchContext) (any, error) { db, err := ironflow.RunWithBranch(b, "create-db", func() (DB, error) { return createDatabase() }) if err != nil { return nil, err } ironflow.CompensateInBranch(b, "create-db", func() error { return deleteDatabase(db.ID) }) return db, nil }, },)When to Use Sagas
| Scenario | Use Sagas? |
|---|---|
| Multi-service transaction (payment + inventory + shipping) | Yes |
| Single database write | No — use a transaction |
| Idempotent operations with no side effects | No |
| Operations that can’t be reversed (e.g., sending an email) | Optional — record the fact, alert ops |
What’s Next?
- Error Handling — NonRetryableError and retries
- Step Primitives — All available step types
- Debugging — Inspect compensation steps in the run detail view
Domain-Driven Design
Ironflow’s saga pattern maps directly to the DDD Saga and Process Manager patterns for maintaining consistency across aggregate boundaries. See Sagas & Process Managers for the full DDD perspective.