Skip to content
Get started
Get started
Rollback: Saga Compensation in TypeScript
guide · workflow engine

Undo, in the right order.

A step that changed the outside world declares its inverse. When a later step fails, the engine runs those inverses in reverse, records every outcome, and tells you when one of them did not work.

A step that changed the outside world declares its inverse. When a later step fails, the engine runs those inverses in reverse.

const flow = new Workflow('money-transfer')
.step('debit-source', async () => {
await accounts.debit(from, amount);
return { debited: true };
}, {
compensate: async () => { await accounts.credit(from, amount); },
})
.step('credit-target', async () => {
await accounts.credit(to, amount);
return { credited: true };
}, {
compensate: async () => { await accounts.debit(to, amount); },
})
.step('send-receipt', async () => {
throw new Error('Email service down');
});

Observed order: debit(from)credit(to)faildebit(to)credit(from). The books balance.

Not reverse completion order. With parallel steps completion order depends on timing and is not reproducible; start order is fixed by your builder, so the unwind is deterministic across runs.

It matters as soon as you have a parallel() block:

started: database, bucket, index
completed: bucket, index, database ← timing
unwind: index, bucket, database ← reverse START order

Every eligible step gets exactly one outcome

Section titled “Every eligible step gets exactly one outcome”

Success is recorded as loudly as failure, so “did the refund actually run?” is answerable from the record alone:

exec.steps['charge'].compensation;
// { status: 'compensated' | 'compensation-failed' | 'compensation-skipped', at, error? }

The same outcomes are emitted as compensation:started, compensation:completed, compensation:failed and compensation:skipped events.

Not only the completed ones. A charge that reached the provider and then lost its response is recorded as failed while the money has already moved, and it is the step most likely to need undoing.

The consequence: a compensate handler must tolerate the absence of its own step’s output. Use the idempotency key to reconcile instead.

The unwind stops and the run parks in compensation-stuck. It does not plough on, because continuing would undo work whose dependencies are still standing. It does not end silently either:

const exec = engine.getExecution(run.id);
exec?.state; // 'compensation-stuck'
exec?.rollbackStatus; // 'stuck'
exec?.failureReason; // why the RUN failed, a separate axis
exec?.steps.charge?.compensation; // compensation-failed
exec?.steps.reserve?.compensation; // undefined, not reached

The steps behind the failure are deliberately left without an outcome, so a resume can still reach them. Two ways out:

// The dispute cleared, the endpoint is back: retry it and finish the unwind.
await engine.resumeCompensation(run.id);
// Or accept a partial rollback: the rest are recorded as skipped, the run ends.
await engine.abandonCompensation(run.id);

After abandonCompensation every eligible step carries an outcome, and that is where “exactly one, never zero” is finally paid.

A hung compensation counts as a failed one

Section titled “A hung compensation counts as a failed one”

A compensate handler is bounded by the step’s own timeout, the same one that bounds the forward handler, defaulting to 30000 ms. This matters more than it sounds: rollbacks run precisely when a provider is having a bad day, which is when a call hangs rather than refusing.

timeout: 0 means “no bound” for the forward handler, and that is your call to make: a step may legitimately run for hours. A reversal is a different case, so it falls back to 30000 ms rather than running unbounded. A reversal that never settles would hold the engine’s in-flight claim on that run, locking it out of recover(), resumeCompensation() and abandonCompensation() for the life of the process, and leaving it compensating instead of parked, with no operator exit at all.

.step('charge', chargeCard, {
timeout: 1000, // bounds chargeCard AND refund
compensate: refund,
})

If refund never settles, the unwind does not wait for it forever. The step is recorded like any other failed reversal and the run parks:

exec?.state; // 'compensation-stuck'
exec?.rollbackStatus; // 'stuck'
exec?.steps.charge?.compensation; // compensation-failed, timed out after 1000ms

Without that bound the run would sit in compensating instead, which is worse than a parked one: it is not compensation-stuck, so there is nothing to resumeCompensation or abandonCompensation. A concurrent local engine.recover() waits for the exact claim owner and reloads the durable row before deciding whether it must retry, but it cannot make a permanently hung handler finish. The timeout is what guarantees that ownership settles and the run reaches an operator-visible state.

A failure that was never resolved keeps the chain stopped

Section titled “A failure that was never resolved keeps the chain stopped”

An unwind interrupted by a crash leaves the run compensating, and recover() drives it again at the next startup. That second pass reads the outcomes the first one wrote, and a reversal recorded compensation-failed still blocks everything behind it: the pass stops there and the run parks again, exactly as it did the first time.

A forced Engine close is a smaller version of the same race: JavaScript already inside a compensate handler may briefly outlive the Engine whose store was closed. A replacement Engine in the same process waits for that specific local owner, then reloads SQLite through its own store and resumes only if the unwind is still owed. It never assumes that losing the local claim means the durable work completed.

It has to work that way. Treating a recorded failure as “already dealt with” would let the pass walk past a refund that never went through, reach the end and report rollbackStatus: 'completed' over money nobody returned, which is the one reading an operator must be able to trust.

compensated and compensation-skipped are settled for good and are never re-run. Only resumeCompensation() retries a failed one, because that is what it asks for.

failureReason says why the run failed. rollbackStatus says what the engine did afterwards:

rollbackStatusMeaning
completedEvery eligible step was compensated
not-applicableCommitted at the pivot, or nothing was eligible
stuckA compensation failed; the rest were not attempted

“The payment failed” and “the refund never went through” need different alerts. Collapsing them into one field makes that impossible.

.pivot() marks the step after which the saga is committed. Past it nothing is rolled back, not the steps after it and not the ones before it either:

const flow = new Workflow('provision')
.step('reserve-subdomain', reserve, { compensate: release })
.step('charge-setup-fee', charge, { compensate: refund })
.pivot() // committed from here
.step('send-welcome-email', sendWelcome) // irreversible
.step('activate-tenant', activate); // retry forward, never unwind

Releasing the subdomain of a tenant who has already been sent a welcome email is exactly what the pivot exists to prevent. A run that fails after it reports rollbackStatus: 'not-applicable' and keeps everything it built.

If a workflow declares no pivot, everything stays compensatable to the end.

.subWorkflow(name, inputMapper) runs another registered workflow as a step. Its results land under ctx.steps['sub:<name>']:

const paymentFlow = new Workflow<{ amount: number }>('payment')
.step('authorize', async (ctx) => authorizePayment(ctx.input.amount), {
compensate: async (ctx) => voidPayment(ctx.forwardIdempotencyKey),
});
const orderFlow = new Workflow('order')
.step('create-order', async () => ({ orderId: 'ORD-1', total: 99 }), {
compensate: async () => cancelOrder(),
})
.subWorkflow('payment', (ctx) => ({
amount: (ctx.steps['create-order'] as { total: number }).total,
}))
.step('confirm', async () => { throw new Error('confirmation failed'); });
engine.register(paymentFlow); // register the child too
engine.register(orderFlow);

Rolling back a sub-workflow runs the child’s own unwind. A child that succeeded before its parent failed is not left standing: it compensates through its own handlers, in its own reverse start order, before the parent continues with its own. In the example above the payment is voided first, then the order is cancelled.

This applies whether the child finished or not. A child that FAILED is rolled back through its own unwind too, and the parent’s sub: record is settled failed rather than left in flight, so a dashboard never shows a child still running under a parent that has already stopped.

If the child parks in compensation-stuck, the parent inherits it and parks too, rather than reporting a clean rollback over a half-undone child. The parent’s rollbackStatus reads stuck and its failureReason names the child and the two ways out, resumeCompensation and abandonCompensation. Resuming the parent reaches the child: the retry is forwarded, so the child’s failed reversal is attempted again and the whole saga can finish from one call.

That forwarding applies only while the child is still parked. If you explicitly call abandonCompensation(childId), the child becomes terminal (failed with rollbackStatus: 'stuck'). Resuming an ancestor cannot override that operator decision or run the child’s compensators again: the ancestor remains compensation-stuck until you abandon it separately or otherwise reconcile the partial rollback.

Every iteration of a doUntil, doWhile or forEach is compensated separately, in reverse order, and each one’s handler sees its OWN result rather than the last iteration’s.

The iteration that FAILED is compensated as well, and it is the one most likely to need it: a charge that reached the provider and then lost the response is recorded failed while the money has already moved. It has no result to hand its handler, for the same reason, so write the handler to reverse by idempotency key rather than by a transaction id it may never have received:

.step('charge', async (ctx) => {
return await provider.charge({ key: ctx.idempotencyKey, amount: 10 });
}, {
compensate: async (ctx) => {
// The failed turn has no result to read. Reverse by the key the forward call was
// made with, which the engine hands every compensate handler.
await provider.refundByKey(ctx.forwardIdempotencyKey);
},
})

Each iteration gets its own idempotencyKey, so the keys do not collide between turns, and ctx.forwardIdempotencyKey in the handler is the key that iteration charged with.

A handler that dereferences a result the failed turn never produced throws, and a compensation that throws halts the unwind at that step, leaving everything behind it untouched.

Make them idempotentAn interrupted unwind resumes, and at-least-once applies here too
Reconcile, don’t assumeThe forward step may have committed without persisting its output
No user-visible side effects beyond the technical undoDo not email “your order was cancelled” for an order that never existed
Keep them leavesIf undoing something needs its own saga, the forward step was too big, so split it
Only where it’s worth itA step that computed a value in memory does not need one