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 compensatedhandler, 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
| Root | Description |
|---|---|
input | The original workflow input |
compensation | Results from each node's compensation, keyed by node name |
| Node names | The 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:
compensate_book_hotelruns first (LIFO -- hotel was booked after flight).compensate_book_flightruns second.- The
on compensatedblock runs, producing the final cancellation result.
Design Guidelines
- Make compensation idempotent. The runtime may retry compensation handlers on failure.
- Log compensation actions. Use
invokein compensation activities to record the reversal in your domain model. - Accept partial failure. Sometimes compensation itself fails (e.g., a refund API is down). The runtime retries, but design for the possibility that manual intervention is needed.
- Not all steps need compensation. Read-only operations, notifications, and logging do not need undo logic.
See Also
- Workflows Overview -- DAG model and workflow lifecycle
- Activities -- Data operations and retry policies
- Durable Execution -- Sleep and signal for long-running workflows
- Temporal Integration -- How compensation maps to Temporal's saga support