Skip to content

Compensation

A saga has no transaction to abort. When step four fails, steps one through three have already happened in the outside world: money moved, inventory was held, an email went out. Compensation is you telling minisagas how to undo each one, so it can walk backwards on your behalf.

Writing one

compensate receives everything execute received, plus result, exactly what that task returned:

ts
.task({
  name: "charge",
  execute: async ({ input, context }) => context.payments.charge(input.amount),
  compensate: async ({ result, context }) => context.payments.refund(result.chargeId),
})

result is typed from execute's return, so the charge id you need to refund is right there. This is why compensation is written next to the task that needs it, not in a catch block three steps later that has to guess what state it's in.

A task with no compensate is simply not rolled back.

Order

Reverse of execution. Task three's compensation runs, then two's, then one's.

Within a parallel group, compensations run concurrently (they undo work that happened concurrently, so nothing is gained by serializing them), but the group as a whole still holds its position in the reverse walk.

A group that ran under a concurrency cap rolls back under the same cap. The limit is there to protect whatever the branches talk to, and the rollback talks to the same thing: a group capped at ten that undid five hundred branches at once would breach the limit at the worst possible moment.

Mapped tasks compensate per iteration, so each iteration undoes with the exact value and result it produced.

Only what actually ran

The rollback log is append-only and written after a task succeeds. So:

  • The task that threw is never compensated: it did not succeed.
  • Tasks skipped by when are not compensated: they never ran.
  • Optional tasks that failed are not compensated; ones that succeeded are.
  • Branches cancelled by failFast are compensated if they finished anyway; the group waits for them. See Parallel work.

When a compensation itself fails

It is caught, reported through onCompensateError, and skipped. The rollback continues down the rest of the list; one failed refund does not strand the inventory holds behind it.

That task's name is left out of result.compensated, which is how you find out. The gap between executedTasks and compensated is your manual-intervention list:

ts
if (!result.success) {
  const stranded = result.executedTasks.filter((n) => !result.compensated.includes(n));
  // stranded tasks: ran, and were not undone. Alert someone.
}

Note that tasks without a compensate also show up in that gap; they had nothing to undo. Combine with onCompensateError if you need to tell the two apart.

Writing compensations that hold

Make them idempotent. A refund that runs twice should not refund twice.

Make them tolerant of partial work. execute may have thrown halfway through, so compensate should undo whatever it finds rather than assuming.

Don't let them throw. Compensation runs when things are already going badly. Catch what you can handle, and let onCompensateError carry what you can't.

Keep them cheap. A compensation that hangs hangs the whole execute call - see below.

Retries and timeouts do not apply

retry and timeout govern a task's execute. They are not applied to its compensate, which runs exactly once and is not bounded in time.

ts
.task(
  { name: "charge", execute: /* ... */, compensate: /* runs once, untimed */ },
  { retry: { attempts: 5, delay: 100 }, timeout: 2_000 }, // both describe `execute`
)

That is deliberate rather than pending. Rollback is sequential and already on the failure path: retrying one undo for a minute holds every undo queued behind it for that minute, and a timeout would abandon a compensation mid-flight without telling you whether the refund went through, which is strictly worse than the failure it was meant to bound.

If a particular undo is worth a second try, put the loop in the compensation itself, where you know what is safe to repeat:

ts
compensate: async ({ result, context }) => {
  for (let attempt = 1; ; attempt++) {
    try {
      return await context.payments.refund(result.chargeId);
    } catch (error) {
      if (attempt === 3) throw error; // let it land in compensationErrors
      await new Promise((r) => setTimeout(r, 100 * attempt));
    }
  }
},

Throwing on the last attempt is the point: it reports the stranded effect through onCompensateError and compensationErrors instead of hiding it.

Reading what happened

ts
if (!result.success) {
  result.error;         // what failed
  result.executedTasks; // everything that succeeded, in order
  result.compensated;   // everything successfully undone, in rollback order
  result.compensationErrors; // rollbacks that threw: [{ name, error }]
  result.failedTask;    // the task that threw
}

failedTask is the name of the task whose failure stopped the saga; for a mapped or parallel task, the individual iteration or branch. A callback that runs outside execute is credited too: an iterator or a when that throws is named after the task it belongs to (a parallel group answers to its members' names, joined), and a key that throws is named after the iteration it was about to name. An optional step is the exception: its callbacks are stepped over rather than blamed, so it never becomes failedTask.

Only one name fits, so when a parallel group loses several branches at once, failedTask holds the first and the AggregateError message lists them all.

When a rollback itself fails

A compensate that throws does not stop the others: the remaining rollbacks still run, and the failure is reported instead of raised. It lands in compensationErrors, and fires onCompensateError.

ts
if (!result.success && result.compensationErrors.length > 0) {
  // effects are still out there; this is the case that needs a human
  alert(result.compensationErrors);
}

A name appears in compensated or in compensationErrors, never both. Tasks with no compensate appear in neither, so an empty compensated on its own does not mean the rollback worked, so check compensationErrors too.

MIT Licensed