Compensation (Saga)

Automatically undo completed work when a workflow fails, the saga way.

When a workflow fails partway through, the actions that already succeeded may have left side effects — a charge, a reservation, a created record. Compensation is Skipper’s mechanism for automatically undoing that work, giving you the saga pattern with almost no boilerplate.

How it works

When a workflow hits a non-retryable error (directly, or after retries are exhausted), Skipper automatically:

  1. Identifies every successfully executed action that has a compensation method.
  2. Runs those compensations in reverse order of execution.
  3. Moves the workflow to COMPENSATION_COMPLETED.
  4. Notifies any registered callback handler.
Executed:    [charge] → [reserve] → [ship ✗ fails]
Compensated: [un-reserve] → [refund]

Writing compensation methods

Annotate a method with @Compensate(forExecute = "..."), naming the action it undoes.

class PaymentActions : Actions() {
  @Execute
  suspend fun charge(request: ChargeRequest): String =
    paymentService.charge(request)

  @Compensate(forExecute = "charge")
  suspend fun refund(request: ChargeRequest, paymentId: String) {
    // First param: the original action input.
    // Second param (optional): the action's result.
    paymentService.refund(paymentId)
  }
}
public class PaymentActions extends Actions {
  @Execute
  public String charge(ChargeRequest request) {
    return paymentService.charge(request);
  }

  @Compensate(forExecute = "charge")
  public void refund(ChargeRequest request, String paymentId) {
    // First param: the original action input.
    // Second param (optional): the action's result.
    paymentService.refund(paymentId);
  }
}

Rules:

  • A compensable action takes at most one argument, and it must be serializable.
  • The compensation’s first parameter matches the action’s input type.
  • An optional second parameter matches the action’s return type (the unwrapped value — not a CompletableFuture). These rules are validated when the workflow is created.

Compensation is automatic

You don’t call compensation yourself — Skipper runs it when the workflow fails. The workflow body stays clean:

@WorkflowMethod
suspend fun processOrder(req: OrderRequest) {
  payments.charge(req.charge)       // compensated if a later step fails
  inventory.reserve(req.items)      // compensated if a later step fails
  shipping.schedule(req)            // if this fails, the two above are undone
}
@WorkflowMethod
public CompletableFuture<Void> processOrder(OrderRequest req) {
  payments.charge(req.getCharge());   // compensated if a later step fails
  inventory.reserve(req.getItems());  // compensated if a later step fails
  shipping.schedule(req);             // if this fails, the two above are undone
  return CompletableFuture.completedFuture(null);
}

Compensating a cancelled workflow

By default a cancel is terminal: the instance stops as CANCELLED and nothing is undone. A workflow method can opt in to the same rollback on cancel:

@WorkflowMethod(compensateOnCancel = true)
suspend fun processOrder(req: OrderRequest) { /* ... */ }
@WorkflowMethod(compensateOnCancel = true)
public CompletableFuture<Void> processOrder(OrderRequest req) { /* ... */ }

Cancelling such an instance runs the compensations of its completed actions in reverse order and ends it as COMPENSATION_COMPLETED (or COMPENSATION_ERROR if a compensation fails), so the instance is not final the moment it is cancelled. onCancelled and onCompensationCompleted both fire, in no guaranteed order.

An action that an in-flight cancel interrupted has no checkpoint and is not compensated; the engine names it in a CANCEL_COMPENSATION event. Actions using EVENTUAL_CHECKPOINT can also be missed. Their checkpoints are saved only when the run ends, and are lost if the worker dies first or the cancel arrives just as the run saves them. A cancelled instance does not re-run its actions, so those results are lost. Use IMMEDIATE_CHECKPOINT on compensable actions where that matters.

Cancelling an already cancelled instance only re-requests its compensation; the new reason is dropped and onCancelled does not fire again. Compensation on cancel needs the INFLIGHT_CANCELLATION_CHECKPOINTS feature gate on from the cancel until the instance reaches its final status. If the gate is turned off in between, the instance stays CANCELLED without compensation.

Reacting to compensation

Implement callback methods to be notified when compensation finishes or itself fails (which may need manual intervention):

override fun onCompensationCompleted(workflow: WorkflowInstanceView) { /* ... */ }
override fun onCompensationError(workflow: WorkflowInstanceView, error: SkipperError) { /* ... */ }
@Override
public void onCompensationCompleted(WorkflowInstanceView workflow) { /* ... */ }

@Override
public void onCompensationError(WorkflowInstanceView workflow, SkipperError error) { /* ... */ }

This is how Skipper workflows achieve eventual data consistency across services.