Compensation & Sagas

Status: [Designed] -- Area 2 of the Grove language. Syntax and semantics are defined; implementation is in progress.

When a multi-step workflow partially completes and then fails, the completed steps may need to be undone. Grove supports the saga pattern through compensation handlers: each activity can declare how to reverse its work, and the runtime rolls back completed nodes in LIFO (last-in, first-out) order. This page covers the compensation model, the on compensated handler, and how sagas work in practice.

Prerequisites: Workflows Overview, Activities. What you'll learn: How compensation works in Grove workflows, how to write on compensated handlers, the LIFO rollback order, and practical saga patterns.

The Problem

Consider an order fulfillment workflow:

workflow fulfill_order v1 {
  node reserve = reserve_inventory v1
  node charge = charge_payment v1
  node ship = create_shipment v1

  edge reserve -> charge
  edge charge -> ship
}

If create_shipment fails after reserve_inventory and charge_payment have succeeded, the system is in an inconsistent state: inventory is reserved and the customer has been charged, but no shipment was created. The completed steps need to be reversed.

The Compensation Model

Grove uses the saga pattern to handle this. Each activity can declare a compensation handler -- logic that undoes the activity's side effects. When a workflow fails, the runtime automatically invokes compensation handlers for completed nodes in reverse order (LIFO).

The execution flow on failure:

reserve (success) -> charge (success) -> ship (FAILURE)
                                            |
                                     compensate charge
                                            |
                                     compensate reserve

The on compensated Handler

Workflows declare compensation logic with the on compensated block:

workflow fulfill_order v1 {
  node reserve = reserve_inventory v1
  node charge = charge_payment v1
  node ship = create_shipment v1

  edge reserve -> charge
  edge charge -> ship

  on compensated -> CompensationResult {
    return {
      order_id: input.order_id
      status: "rolled_back"
      refunded: compensation.charge.refunded
      inventory_released: compensation.reserve.released
    }
  }
}

Root Objects in on compensated

RootDescription
inputThe original workflow input
compensationResults from each node's compensation, keyed by node name
Node namesThe original outputs of completed nodes (e.g., reserve, charge)

The on compensated block runs after all compensation handlers have executed. It produces a final result that describes the outcome of the rollback.

Activity Compensation

For compensation to work, activities that perform reversible side effects need corresponding "undo" activities. The runtime knows which activities to compensate based on the workflow's DAG structure and which nodes completed successfully before the failure.

Pattern: Paired Activities

A common pattern is to pair each forward activity with a compensating activity:

activity reserve_inventory v1 {
  data reservation {
    invoke warehouse.reserve_items {
      items: input.items
    }
  }

  apply(input: ReserveInput) -> ReserveResult {
    return {
      reservation_id: reservation.id
      items: reservation.items
      released: false
    }
  }
}

activity compensate_reserve_inventory v1 {
  data release {
    invoke warehouse.release_reservation {
      reservation_id: input.reservation_id
    }
  }

  apply(input: ReserveResult) -> CompensateResult {
    return {
      released: true
      reservation_id: input.reservation_id
    }
  }
}

The compensating activity receives the output of the original activity as its input, giving it the information needed to undo the work (e.g., the reservation_id to release).

Pattern: Charge and Refund

activity charge_payment v1 {
  data charge {
    fetch stripe.create_charge {
      amount: input.amount
      currency: input.currency
      customer_id: input.customer_id
    }
  }

  apply(input: ChargeInput) -> ChargeResult {
    return {
      charge_id: charge.id
      amount: charge.amount
      refunded: false
    }
  }
}

activity compensate_charge_payment v1 {
  data refund {
    fetch stripe.create_refund {
      charge_id: input.charge_id
      amount: input.amount
    }
  }

  apply(input: ChargeResult) -> CompensateResult {
    return {
      refunded: true
      refund_id: refund.id
    }
  }
}

LIFO Rollback Order

Compensation follows last-in, first-out order based on the workflow's execution history. The most recently completed node is compensated first:

Execution order:    A -> B -> C -> D (fails)
Compensation order: C -> B -> A

This ordering ensures that dependent operations are undone before the operations they depend on. For example, a payment refund should happen before an inventory reservation release if the payment was charged after the inventory was reserved.

Parallel Node Compensation

When nodes executed in parallel, their compensation also runs in parallel (since they have no dependency ordering between them):

Execution:    A -> [B, C] (parallel) -> D (fails)
Compensation: [B, C] (parallel) -> A

Partial Compensation

Not every activity needs compensation. Activities that are read-only (queries, lookups) or idempotent have no side effects to reverse. The runtime only compensates nodes that have registered compensation handlers.

Full Saga Example

workflow book_trip v1 {
  node flight = book_flight v1 {
    destination: input.destination
    date: input.travel_date
    passenger: input.passenger_name
  }

  node hotel = book_hotel v1 {
    city: input.destination
    check_in: input.travel_date
    nights: input.stay_duration
  }

  node car = book_rental_car v1 {
    city: input.destination
    pickup_date: input.travel_date
  }

  edge flight -> hotel
  edge hotel -> car

  on compensated -> TripCancellationResult {
    return {
      trip_id: input.trip_id
      status: "cancelled"
      flight_cancelled: compensation.flight.cancelled
      hotel_cancelled: compensation.hotel.cancelled
    }
  }
}

If book_rental_car fails:

  1. compensate_book_hotel runs first (LIFO -- hotel was booked after flight).
  2. compensate_book_flight runs second.
  3. The on compensated block runs, producing the final cancellation result.

Design Guidelines

See Also