Skip to content

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

  1. After a step succeeds, register a compensation handler for it.
  2. If a later step throws a non-retryable error, the SDK runs all registered compensations in reverse order.
  3. 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-payment
step 2: reserve-inventory ✓ → register release-inventory
step 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;
});
},
);

Compensation Ordering

Compensations always run in reverse registration order, regardless of which step failed:

step.compensate("step-a", undoA); // registered 1st → runs last
step.compensate("step-b", undoB); // registered 2nd → runs first

This 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-payment
  • compensate: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:

  1. Records the failed compensation step in the run’s step history (status: failed)
  2. Logs the compensation failure with step ID and error details
  3. 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;
},
]);

When to Use Sagas

ScenarioUse Sagas?
Multi-service transaction (payment + inventory + shipping)Yes
Single database writeNo — use a transaction
Idempotent operations with no side effectsNo
Operations that can’t be reversed (e.g., sending an email)Optional — record the fact, alert ops

What’s Next?

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.