Files
skills/docs/api-stack/api/api-saga/src/compensation/compensation.md
2026-05-29 08:20:56 +01:00

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_failed returned 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 maxAttempts so 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 of compensated | 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.