Contents
Compensation & Saga Pattern
Status: Proposal
Author: pg_durable team
Date: December 2025
Overview
Add support for saga-style compensating transactions using the <-> operator and df.with_undo() function. When a step in a durable function fails, all previously successful steps run their compensation actions in reverse order.
API
Operator: <->
forward_action <-> undo_action
Pairs a forward action with its compensation. If the forward succeeds but a later step fails, the undo runs automatically.
Function: df.with_undo()
df.with_undo(forward_action, undo_action)
Equivalent to the <-> operator, useful for complex expressions.
Operator Precedence
| Precedence | Operator | Meaning |
|---|---|---|
| 1 (highest) | <-> |
Compensate with |
| 2 | |=> |
Bind result |
| 3 | & |
Parallel join |
| 4 | | |
Race |
| 5 | ~> |
Then (sequence) |
| 6 | ?> !> |
If-then-else |
| 7 (lowest) | @> |
Loop |
Examples
Basic Saga
df.start(
'INSERT INTO orders (customer_id) VALUES (42) RETURNING id'
<-> 'DELETE FROM orders WHERE id = $order_id'
|=> 'order_id'
~> 'UPDATE inventory SET qty = qty - 1 WHERE sku = ''WIDGET'''
<-> 'UPDATE inventory SET qty = qty + 1 WHERE sku = ''WIDGET'''
~> df.http('POST', '/payments/charge', '{"order": $order_id}')
<-> df.http('POST', '/payments/refund', '{"charge": $charge_id}')
|=> 'charge_id'
~> 'UPDATE orders SET status = ''confirmed'' WHERE id = $order_id'
);
Bank Transfer
df.start(
'UPDATE accounts SET balance = balance - 500 WHERE id = 1'
<-> 'UPDATE accounts SET balance = balance + 500 WHERE id = 1'
~> 'UPDATE accounts SET balance = balance + 500 WHERE id = 2'
<-> 'UPDATE accounts SET balance = balance - 500 WHERE id = 2'
~> 'INSERT INTO transfers (from_id, to_id, amount) VALUES (1, 2, 500)'
);
Cross-Database with FDW
df.start(
'INSERT INTO orders VALUES (...) RETURNING id'
<-> 'DELETE FROM orders WHERE id = $order_id'
|=> 'order_id'
~> 'UPDATE remote.inventory SET qty = qty - 1 WHERE sku = $sku'
<-> 'UPDATE remote.inventory SET qty = qty + 1 WHERE sku = $sku'
~> df.http('POST', '/shipping/create', '{"order": $order_id}')
<-> df.http('DELETE', '/shipping/$shipment_id')
|=> 'shipment_id'
);
Parallel Steps with Compensation
df.start(
'INSERT INTO bookings...' <-> 'DELETE FROM bookings...' |=> 'booking_id'
~> (
'UPDATE rooms SET status = ''booked'' WHERE id = 101'
<-> 'UPDATE rooms SET status = ''available'' WHERE id = 101'
&
'UPDATE parking SET reserved = true WHERE spot = ''A1'''
<-> 'UPDATE parking SET reserved = false WHERE spot = ''A1'''
)
~> 'INSERT INTO confirmations (booking_id) VALUES ($booking_id)'
);
Execution Semantics
Step 1: A <-> A' -- succeeds
Step 2: B <-> B' -- succeeds
Step 3: C <-> C' -- FAILS
Compensation Flow:
1. B' executes (compensate step 2)
2. A' executes (compensate step 1)
3. Saga status: "compensated"
Variable Availability
- The
|=>binding applies to the forward action’s result - Bound variables are available in:
- Subsequent forward steps
- The undo action of the same step
- Undo actions of earlier steps (during compensation)
'INSERT... RETURNING id' <-> 'DELETE WHERE id = $order_id' |=> 'order_id'
-- $order_id available in the DELETE (undo uses it)
Implementation
Node Type
{
"node_type": "COMPENSATED",
"left_node": "forward_action_node_id",
"right_node": "undo_action_node_id",
"result_name": "optional_binding"
}
Orchestration Changes
- Track completed compensated steps in a stack
- On failure, pop and execute undo actions in reverse order
- New instance status:
"compensated"for successful rollback
DSL Changes
- Add
<->operator with highest precedence - Add
df.with_undo(forward, undo)function - Auto-wrap plain SQL strings in both positions
Future Considerations
- Partial compensation (compensate only some steps)
- Compensation timeout/retry policies
- Nested sagas
- Compensation observability in
df.explain()