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

  1. Track completed compensated steps in a stack
  2. On failure, pop and execute undo actions in reverse order
  3. New instance status: "compensated" for successful rollback

DSL Changes

  1. Add <-> operator with highest precedence
  2. Add df.with_undo(forward, undo) function
  3. 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()