# Compensation

Rolling back a workflow and undoing its actions


A compensation is a per-step callback that undoes the effect of a task that completed successfully. When the workflow cannot finish, the work it already did is rolled back.

```go
workflow.Step("charge",
	workflow.WithRun(chargeCard),
	workflow.WithCompensate(refundCharge),
),
```

If we consider workflows as "distributed transactions", compensations allow defining the operations that need to be performed in the case of a "rollback". For example, in an ecommerce workflow, failure to ship the item can require refunding the charge on the credit card.

## The stack

Compensation is a stack. Every task that completes successfully is pushed onto it in completion order, as long as its step declares `WithCompensate` or is a child workflow. When running a compensation, the stack is popped in reverse order.

```
forward:      charge-card ──► reserve-stock ──► ship[0] ship[1] ship[2] ──► ✗ confirm
                                               (parallel children)

unwind:       refund-card ◄── release-stock ◄── unwind ship[0..2]
                                               (parallel, all at once)
```

- Steps unwind in reverse order. The customer is refunded before the stock is released, because the stock was held first.
- Within one step, compensations run concurrently.
- A step is fully compensated before the next one starts.

A compensation is a task like any other, with its own attempts and timeout, and its progress is visible in `GetStatus`.

## What triggers an unwind

- A step fails terminally under the default policy (not `WithOptional` or `WithSkipOnFailure`)
- A group or fan-out fails under `FailFast` or `CollectFailures`
- `Service.Cancel` is called on a running or suspended instance
- The instance timeout elapses
- A `WaitForEvent` step's own timeout elapses without the event
- A parent instance unwinds a [child step](/workflows/child-workflows.md)

Francis records the cause in every case, and each compensation receives it through `Cause()`. Use it when the undo differs by reason, such as writing a different audit record for a payment failure and a customer cancellation.

## Writing a compensation

A compensation receives the same input and item the forward task had, plus the output that task produced:

```go
type chargeResult struct {
	ChargeID string `json:"chargeId"`
	Amount   int64  `json:"amount"`
}

func chargeCard(ctx context.Context, t workflow.Task) (any, error) {
	var order orderInput
	err := t.DecodeInput(&order)
	if err != nil {
		return nil, errors.Join(actor.ErrJobPermanentFailure, err)
	}

	// The same key on every attempt means the provider returns the existing charge instead of creating another
	idem := t.InstanceID() + "|" + t.Step()
	ch, err := payments.Charge(ctx, order.PaymentMethod, order.Total, idem)
	if errors.Is(err, payments.ErrDeclined) {
		return nil, errors.Join(actor.ErrJobPermanentFailure, err)
	} else if err != nil {
		return nil, err
	}

	// Return the charge ID, because that is what the refund needs
	return chargeResult{ChargeID: ch.ID, Amount: ch.Amount}, nil
}

func refundCharge(ctx context.Context, c workflow.Compensation) error {
	var res chargeResult
	err := c.DecodeResult(&res)
	if err != nil {
		return errors.Join(actor.ErrJobPermanentFailure, err)
	}

	// Refunds are keyed on the charge, so a second delivery of this compensation is a no-op at the provider
	return payments.Refund(ctx, res.ChargeID, c.Cause())
}
```

Just like other tasks, compensations are executed at-least-once, so `refundCharge` must handle the possibility of being called twice for the same charge. Key the undo on the forward operation's identifier.

Compensations have their own attempt policy: `WithCompensateMaxAttempts` (default 10) and `WithCompensateBackoff` (default 10s doubling to 10 minutes). The defaults are more generous than the forward ones.

## Compensating the failing step

By default, a step that failed is not compensated, on the assumption that a step which did not complete did not take effect.  
`WithCompensateOnFailure()` compensates a step even when it failed:

```go
workflow.Step("write-ledger",
	workflow.WithRun(writeLedger),
	workflow.WithCompensate(reverseLedger),
	// The write may have landed before the failure, so the undo runs either way
	workflow.WithCompensateOnFailure(),
),
```

When writing the compensation function, keep in mind that it may be undoing something that happened or not.

## Handling compensation failures

`WithCompensationFailurePolicy` chooses what happens next:

| Policy | Behavior |
|--------|----------|
| `workflow.ContinueUnwinding` *(default)* | Record the failure and keep unwinding the remaining steps. The instance terminates as `failed` with `compensation: partial`. |
| `workflow.AbortUnwinding` | Stop at the step that failed. The instance terminates as `failed` with `compensation: failed`, and `GetStatus` names exactly which steps were not unwound. |

`ContinueUnwinding` is the default, because stopping at the first problem usually leaves more incomplete work than carrying on.

```go
status, err := svc.GetStatus(ctx, id)
if status.Compensation == workflow.CompensationPartial {
	// Money may be stranded, and GetStatus has the charge step's recorded output
}
```

## Status

The instance carries a status plus a compensation outcome:

```go
const (
	CompensationNone      CompensationOutcome = "none"      // nothing needed unwinding
	CompensationCompleted CompensationOutcome = "completed" // everything unwound
	CompensationPartial   CompensationOutcome = "partial"   // some steps failed to unwind, the rest did
	CompensationFailed    CompensationOutcome = "failed"    // the unwind stopped early
)
```

The outcome is empty until the instance terminates. While it is `compensating`, each step in `GetStatus` is `compensating`, `compensated`, or `compensation-failed`.

