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:
- Identifies every successfully executed action that has a compensation method.
- Runs those compensations in reverse order of execution.
- Moves the workflow to
COMPENSATION_COMPLETED. - 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.