Initial Commit
This commit is contained in:
126
packages/api-stack/api/api-saga/src/compensation/compensation.md
Normal file
126
packages/api-stack/api/api-saga/src/compensation/compensation.md
Normal 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.
|
||||
Reference in New Issue
Block a user