Agora
Reliability

Sagas & compensation

Undo the side effects of a partially-completed run with per-step compensate callbacks that run in reverse on failure, compensationRetries and compensationTimeoutMs for transient/lost undos, checkpointed (crash-safe) unwinds, compensate:<step> events, and compensating cancellation via engine.cancel(runId, { compensate: true }).

A durable run often performs several irreversible side effects in sequence — reserve inventory, charge a card, allocate a shipment. If a later step fails, the earlier effects are still out there in the world, and "the run failed" is not an acceptable end state when money has moved. The saga pattern handles this: alongside each step that does something, you register how to undo it, and when the run fails the engine runs those undos in reverse order. @adonis-agora/durable builds this in via a compensate option — a closure on ctx.localStep for in-process work, or a step ref on the dispatched ctx.step.

Local compensation — a closure on ctx.localStep

Attach a compensate callback to an in-process ctx.localStep. The callback is registered when the step completes; if the run later fails, the engine runs every registered compensation in reverse order (last completed step undone first), restoring the world before failing the run.

engine.register('checkout', '1', async (ctx, order: Order) => {
  const reservation = await ctx.localStep(
    'reserve-inventory',
    () => inventory.reserve(order.items),
    { compensate: () => inventory.release(order.items) },
  )

  const charge = await ctx.localStep(
    'charge-card',
    () => payments.charge(order.customerId, order.totalCents),
    { compensate: () => payments.refund(order.customerId, order.totalCents) },
  )

  // If this step throws, the engine runs the two compensations above in reverse:
  // refund the card, then release the inventory — then fails the run.
  const label = await ctx.localStep('allocate-shipment', () => shipping.allocate(order, reservation.id))

  return { chargeId: charge.id, tracking: label.tracking }
})

If allocate-shipment throws (and exhausts its retries, or throws a FatalError), the run fails — but before it does, the engine refunds the card and releases the inventory. The saga is reconstructed from the run's history on replay, so it works correctly even after a crash: the steps that completed are the ones whose compensations get registered.

Dispatched compensation — a step ref on ctx.step

A dispatched ctx.step undoes itself with a compensate step ref — a @Step/defineStep handler (or its name) that runs as its own dispatched step at unwind time. The undo handler receives a StepUndo envelope — { input, output } — of the step it compensates, so it can reverse exactly what ran:

app/steps/booking_steps.ts
import { Step } from '@adonis-agora/durable'
import type { UndoOf } from '@adonis-agora/durable'

export default class BookingSteps {
  @Step('flights:book')
  async book(input: { flightId: string; pax: number }) {
    return { bookingRef: await airline.book(input) }
  }

  // The undo — typed to the step it compensates via UndoOf<...>.
  @Step('flights:cancel')
  async cancel(undo: UndoOf<BookingSteps['book']>) {
    await airline.cancel(undo.output.bookingRef)
  }
}

// in the workflow (step class injected as `this.booking`):
const booking = await ctx.step(this.booking.book, { flightId, pax }, {
  compensate: this.booking.cancel, // dispatched in reverse if the run later fails
})

Both kinds of compensation live in the same reverse-order stack: whether a step registered a local closure or a dispatched undo ref, the engine unwinds them last-completed-first when the run fails.

A dispatched step is already deduplicated by its deterministic stepId (runId:seq), which workers can use as an idempotency key — so there's no separate idempotency-key option.

1@Workflow({ name: 'book-trip', version: '1' })2export default class BookTripWorkflow {3  constructor(private trips: TripSteps) {}4 5  async run(ctx: WorkflowCtx, trip: TripRequest) {6    const flight = await ctx.step(this.trips.bookFlight, trip, {7      compensate: this.trips.cancelFlight,8    })9    const hotel = await ctx.step(this.trips.bookHotel, trip, {10      compensate: this.trips.cancelHotel,11    })12    // the deposit registers no compensate — nothing of its own to undo13    await ctx.step(this.trips.chargeDeposit, { trip, flight, hotel })14    return { flight, hotel }15  }16}
unwind done → run settles failed (original error)flighthoteldeposit ✗undo hotelundo flightfailed
failedBoth legs undone, the run settles failed with the ORIGINAL deposit error — never masked by the unwind. The compensate:* checkpoints keep the whole undo trail visible in the dashboard.
6 / 6

Retrying a transient undo

A compensation can itself fail transiently — the refund API might be momentarily unreachable. The engine retries each compensation up to compensationRetries times. This is an engine-level option (it applies to every compensation), set in config/durable.ts, and it defaults to 1, i.e. a single attempt with no retry:

config/durable.ts
export default defineConfig({
  // … plus your transport/store (see Getting Started)
  compensationRetries: 5, // retry each saga undo up to 5 times before giving up on it
})

Because a compensation may run more than once, compensations should be idempotent — releasing an already-released reservation or refunding an already-refunded charge must be a no-op. A compensation that keeps failing past compensationRetries is skipped rather than allowed to throw: a permanently-failing undo must not mask the original failure or strand the remaining compensations.

A dispatched undo whose step definition carries no liveness timeoutMs is additionally bounded by compensationTimeoutMs (engine config, default 300 000 — 5 minutes): if the worker produces no result within the window, that attempt fails like any other undo failure — retried up to compensationRetries, then skipped loudly — instead of the unwind awaiting a lost job forever.

config/durable.ts
export default defineConfig({
  compensationRetries: 5,
  compensationTimeoutMs: 120_000, // give up on one dispatched undo attempt after 2 minutes
})

The unwind itself is durable

Compensations are not just dispatched — they are checkpointed, at reserved negative sequence numbers (-2 - idx, in registration order) so they can never collide with the body's own checkpoints. Each undo persists a pending checkpoint before it dispatches and settles it on the outcome, which buys the unwind the same crash-safety as the run body:

  • A crash mid-unwind doesn't restart it from scratch. A re-driven unwind reads the checkpoints, skips undos already completed (no double refunds), and resumes each remaining undo's attempt count where it left off.
  • Multi-pod deployments work. On a shared results queue, a dispatched undo's worker result can land on another pod; because the undo is a persisted checkpoint, whichever pod consumes the result completes the unwind instead of dropping it.

You don't configure any of this — it's how the saga executes. It's why the "compensations should be idempotent" rule above is about your undo handlers, not about the engine re-running undos it already knows finished.

Compensations are visible

Every compensation surfaces as a compensate:<step> event, emitted as a step.completed (the undo ran) or step.failed (it exhausted its retries) lifecycle event. The dashboard and the Telescope view render these, so a stranded undo is visible rather than silently swallowed. For the checkout above you'd see compensate:charge-card and compensate:reserve-inventory appear in the timeline as the saga unwinds.

Compensating cancellation

The saga also runs when you deliberately cancel a run with compensation. A plain engine.cancel(runId) is immediate: it marks the run cancelled right away and broadcasts the cancellation so a worker actually running it can abort cooperatively — but it does not undo completed steps. Passing { compensate: true } instead runs the saga first:

// Immediate cancel — mark cancelled, abort in-flight work, but leave completed side effects in place:
await engine.cancel(runId)

// Compensating cancel — undo the completed steps in reverse, THEN mark the run cancelled:
await engine.cancel(runId, { compensate: true })

A compensating cancel returns immediately (non-blocking) and runs the undo in the background: the run is resumed so the replay re-registers the saga from history, the engine runs the compensations in reverse, and marks the run cancelled. For the checkout example, cancelling a run that had already charged the card and reserved inventory with { compensate: true } issues the refund and releases the reservation before the run becomes cancelled — leaving the world clean, exactly as a failure would.

The request itself is durable: cancel({ compensate: true }) persists a cancel marker alongside broadcasting the cancellation, so the undo survives a crash or a pod handoff — whichever pod next drives the run honors the marker and runs the saga, even if the pod that took the cancel request couldn't run the workflow (or died before it did).

To cancel many runs at once, engine.cancelWhere(filter, opts?) cancels every run matching a run query (with the same { compensate } option).

On this page