4.4 KiB
compensation
(no submodules)
Purpose
On step failure the saga orchestrator walks completed steps in reverse
order and invokes each step's compensation handler. Per design doc
§9.1 those handlers MUST be idempotent + retryable up to a configured
budget (maxCompensationAttempts, default 5). This module owns that
retry loop and the terminal-failure shape — the orchestrator threads
a per-step run(attempt) callback that bundles "actually invoke the
plugin's compensate handler, update persistence, emit the event"
into a single closure the runner can retry.
The runner intentionally knows nothing about persistence or events — it owns ONLY the retry loop and the reverse-walk. This keeps the module unit-testable without any infrastructure and lets the orchestrator reuse it for the recovery replay path (which also needs "walk completed steps in reverse, retry up to N").
Features
- Reverse-order walk of completed steps.
- Per-step retry loop up to
maxAttempts(default 5). compensation_failedreturned on the first step whose retry budget is exhausted; remaining steps are NOT attempted (the saga is already in a broken-rollback terminal state).raiseCompensationExhausted(...)builder for the terminal error, chaining the underlying cause.- Configurable per-saga
maxAttemptsso a saga with very expensive compensation handlers can opt into a smaller budget. - Stateless module — every call yields a fresh
CompensationRunResult.
Usage: Basic
import {
runCompensation,
} from "@suaveplan/api-saga/compensation/compensation";
const result = await runCompensation({
completedSteps: [
{ stepIndex: 0, stepName: "charge", run: async () => refund() },
{ stepIndex: 1, stepName: "ship", run: async () => cancelShipment() },
],
});
if (result.outcome === "compensated") {
// every step's compensate succeeded
}
Usage: Advanced
Plug in per-saga retry budget and per-step instrumentation. The
runner's run(attempt) callback receives the current attempt number
so it can update telemetry and the step row before the actual
compensate invocation:
import {
raiseCompensationExhausted,
runCompensation,
} from "@suaveplan/api-saga";
const result = await runCompensation({
maxAttempts: 3,
completedSteps: completedRows.map((row) => ({
stepIndex: row.stepIndex,
stepName: row.name,
run: async (attempt) => {
instruments.compensationsTotal.add(1, {
name: sagaName,
step: row.name,
});
persistence.transitionStep(sagaId, row.stepIndex, "compensating");
await participant.compensate(
{ input: row.payload.input, output: row.payload.output },
ctx
);
persistence.transitionStep(sagaId, row.stepIndex, "compensated");
},
})),
});
if (result.outcome === "compensation_failed") {
raiseCompensationExhausted({
sagaId,
sagaName,
stepName: result.failedStep ?? "?",
attempts: result.perStep[result.perStep.length - 1]?.attempts ?? 0,
cause: new Error("see prior errors"),
});
}
API Reference
DEFAULT_MAX_COMPENSATION_ATTEMPTS = 5.CompensableStep { stepIndex, stepName, run(attempt) }— input contract.CompensationOutcome— per-step union ofcompensated | failed.CompensationRunResult— overall outcome:{ outcome: "compensated" | "compensation_failed", perStep, failedStep? }.runCompensation({ completedSteps, maxAttempts? })— entry point.raiseCompensationExhausted({ sagaId, sagaName, stepName, attempts, cause }): never— terminal error builder.
Implementation Notes
The reverse-walk runs sequentially rather than in parallel. Design §9.1 explicitly calls out "reverse order" because step N's compensation may depend on side-effects that step N+1's compensation removes (e.g. a fulfilment-cancel that walks an order id that the order-cancel step is about to delete). Sequencing keeps that guarantee.
The first compensation-failed step short-circuits the remaining walk by design — once one step's compensation is unrecoverable, the saga is already in a broken state and continuing would risk double-undoing earlier steps if a later compensation succeeded in isolation but referenced state the failed compensation had already mutated.