first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,126 @@
# `compensation`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## 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
```ts
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:
```ts
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.