Files
suaveplan/packages/api-stack/api/api-saga/src/orchestrator/compensate-runner.md
2026-05-27 23:29:53 +01:00

3.6 KiB

orchestrator/compensate-runner

Purpose

runFullCompensation(...) is the orchestrator's per-instance compensation driver. It bridges the generic compensation module (which only knows about CompensableStep + retry loop) with the saga-specific concerns: looking up each completed step's registered compensate handler, transitioning the step through compensating → compensated, recording the compensation counter + span, and emitting saga.step.compensated. The result of the walk is returned as a structured outcome so the orchestrator can settle the instance state.

This module is split out from orchestrator.ts to keep the main file under the ~250-line soft limit and to isolate the compensation concerns from the forward step-dispatch loop.

Features

  • Walks completed step rows in reverse via the generic runCompensation helper.
  • Per-step: opens a saga.compensate span, transitions to compensating, invokes the registered compensate handler under a fresh RequestContext, increments saga.compensations_total, transitions to compensated, emits saga.step.compensated.
  • Honours per-saga maxCompensationAttempts (defaults to 5 via the compensation module).
  • On terminal failure: transitions the failed step row back to failed so the persistence layer reflects the broken state.

Usage: Basic

Internal helper — typically only consumed by createSagaOrchestrator:

import { runFullCompensation } from "@suaveplan/api-saga/orchestrator/compensate-runner";

const result = await runFullCompensation({
    instance,
    maxCompensationAttempts: 5,
    principal,
    persistence,
    registry,
    emitter,
    instruments,
    requestContextFactory,
    nowMs: () => Date.now(),
});

if (result.outcome === "compensated") {
    persistence.transitionInstance(instance.id, "compensated");
}

Usage: Advanced

A consumer that wants to drive compensation manually (e.g. an operator-triggered "rollback this saga" admin command) can call this directly without going through start() or resume(). The caller is responsible for transitioning the instance into the matching terminal state afterwards:

import { runFullCompensation } from "@suaveplan/api-saga";

const result = await runFullCompensation({
    instance: persistence.requireInstance(id),
    maxCompensationAttempts: 3,
    principal: null,
    persistence,
    registry,
    emitter,
    instruments,
    requestContextFactory,
    nowMs: () => Date.now(),
});

persistence.transitionInstance(
    id,
    result.outcome === "compensated"
        ? "compensated"
        : "compensation_failed"
);

API Reference

  • runFullCompensation(args): Promise<CompensationFlowOutcome>.
  • args:
    • instance: SagaInstanceRow,
    • maxCompensationAttempts: number,
    • principal: Principal | null,
    • persistence, registry, emitter, instruments, requestContextFactory, nowMs — injected dependencies.
  • CompensationFlowOutcome:
    • { outcome: "compensated" },
    • { outcome: "compensation_failed", failedStep: string }.

Implementation Notes

The compensation walk only considers step rows whose persisted state is completed. Steps in pending, in_flight, or failed have either not run or already failed; running compensation against them would either invoke a compensate handler with no matching output or repeat work the runner already declared lost.

updateStepPayload is invoked AFTER the compensate handler so the persistence layer records the final attempt count rather than the initial one. This matters for the operator-facing dashboards that read the row to display "compensation took N attempts".