oleander-whirlwind
06/18/2026, 11:56 AMMarvin
06/18/2026, 11:56 AMMarvin
06/18/2026, 11:59 AM@flow / @task
- normal Python try/except/finally
- durable state in an external system or persisted result store
- explicit compensation functions
- reverse-order compensation stacks
I’d recommend not depending purely on “best effort” in-memory rollback logic for business-critical sagas. Persist compensation state by key.
---
# 1. Missing Compensation
Problem: a step mutates external state, but you forgot to define how to undo it.
## Bad example
python
from prefect import flow, task
@task
def charge_customer(order_id: str) -> str:
# external mutation
return f"charge-{order_id}"
@task
def reserve_inventory(order_id: str) -> str:
raise RuntimeError("inventory unavailable")
@flow
def order_flow(order_id: str):
charge_id = charge_customer(order_id)
reserve_inventory(order_id)
If reserve_inventory fails, the charge remains.
## Better Prefect 3 pattern
Define the compensation before or at the same time as the mutation is introduced into the flow.
python
from prefect import flow, task, get_run_logger
@task
def charge_customer(order_id: str) -> str:
return f"charge-{order_id}"
@task
def refund_customer(charge_id: str):
print(f"Refunding {charge_id}")
@task
def reserve_inventory(order_id: str) -> str:
raise RuntimeError("inventory unavailable")
@flow
def order_flow(order_id: str):
logger = get_run_logger()
compensations = []
try:
charge_id = charge_customer(order_id)
compensations.append(lambda: refund_customer(charge_id))
reserve_inventory(order_id)
except Exception:
logger.error("Flow failed; starting compensation")
for compensate in reversed(compensations):
compensate()
raise
## Key rule
No compensation, no mutation.
If a task changes external state, register its undo path immediately.
---
# 2. Non-Atomic Compensation
Problem: compensation itself partially fails.
Example: “cancel shipment” succeeds, but “refund payment” fails.
## Bad example
python
from prefect import flow, task
@task
def cancel_shipment(shipment_id: str):
print(f"Cancelled shipment {shipment_id}")
@task
def refund_payment(payment_id: str):
raise RuntimeError("refund API timeout")
@flow
def compensate_order(shipment_id: str, payment_id: str):
cancel_shipment(shipment_id)
refund_payment(payment_id)
Now the order is partially compensated.
## Better pattern: idempotent, retriable compensations with durable status
python
from prefect import flow, task, get_run_logger
# Replace with a real DB/table/object store in production
COMPENSATION_STATUS = {}
@task(retries=3, retry_delay_seconds=5)
def refund_payment(payment_id: str):
if COMPENSATION_STATUS.get(f"refund:{payment_id}") == "done":
return
# external refund call
print(f"Refunding payment {payment_id}")
COMPENSATION_STATUS[f"refund:{payment_id}"] = "done"
@task(retries=3, retry_delay_seconds=5)
def cancel_shipment(shipment_id: str):
if COMPENSATION_STATUS.get(f"cancel:{shipment_id}") == "done":
return
# external cancel call
print(f"Cancelling shipment {shipment_id}")
COMPENSATION_STATUS[f"cancel:{shipment_id}"] = "done"
@flow
def compensate_order(shipment_id: str, payment_id: str):
logger = get_run_logger()
try:
cancel_shipment(shipment_id)
refund_payment(payment_id)
except Exception as exc:
logger.error(f"Compensation failed: {exc}")
raise
## What matters
Each compensation must be:
- idempotent
- safe to retry
- durably tracked
If a worker crashes mid-compensation, a rerun should continue safely.
---
# 3. Wrong Compensation Order
Problem: compensation runs in the wrong sequence.
Forward path:
1. reserve inventoryMarvin
06/18/2026, 11:59 AMpython
from prefect import flow, task, get_run_logger
@task
def reserve_inventory(order_id: str) -> str:
return f"inv-{order_id}"
@task
def release_inventory(reservation_id: str):
print(f"Released inventory {reservation_id}")
@task
def charge_payment(order_id: str) -> str:
return f"pay-{order_id}"
@task
def refund_payment(payment_id: str):
print(f"Refunded payment {payment_id}")
@task
def create_shipment(order_id: str) -> str:
raise RuntimeError("shipment service failure")
@task
def cancel_shipment(shipment_id: str):
print(f"Cancelled shipment {shipment_id}")
@flow
def order_flow(order_id: str):
logger = get_run_logger()
compensations = []
try:
reservation_id = reserve_inventory(order_id)
compensations.append(lambda: release_inventory(reservation_id))
payment_id = charge_payment(order_id)
compensations.append(lambda: refund_payment(payment_id))
shipment_id = create_shipment(order_id)
compensations.append(lambda: cancel_shipment(shipment_id))
except Exception:
logger.error("Failure detected, compensating in reverse order")
for compensate in reversed(compensations):
compensate()
raise
## Why this works
The compensation stack is built in forward order and executed in reverse order, which matches Saga rollback semantics.
---
# 4. Double Compensation
Problem: compensation runs twice after restart/retry/race.
Examples:
- refund issued twice
- inventory released twice
## Better pattern: idempotency key + durable compensation record
python
from prefect import flow, task
COMPENSATION_STATUS = {}
@task
def refund_payment(payment_id: str):
key = f"refund:{payment_id}"
if COMPENSATION_STATUS.get(key) == "done":
print(f"Refund already completed for {payment_id}")
return
# call downstream API with idempotency key = key
print(f"Refunding {payment_id}")
COMPENSATION_STATUS[key] = "done"
@task
def release_inventory(reservation_id: str):
key = f"release:{reservation_id}"
if COMPENSATION_STATUS.get(key) == "done":
print(f"Inventory already released for {reservation_id}")
return
print(f"Releasing inventory {reservation_id}")
COMPENSATION_STATUS[key] = "done"
@flow(retries=2)
def compensate(payment_id: str, reservation_id: str):
refund_payment(payment_id)
release_inventory(reservation_id)
If the flow retries, compensation tasks check durable state before acting.
## Key rule
The orchestrator should replay status, not blindly replay side effects.
---
# A fuller Prefect 3 Saga example
This combines all four ideas.
```python
from prefect import flow, task, get_run_logger
SAGA_STATE = {}
@task
def reserve_inventory(order_id: str) -> str:
reservation_id = f"inv-{order_id}"
SAGA_STATE[f"forward{order id}inventory"] = reservation_id
return reservation_id
@task
def release_inventory(reservation_id: str):
key = f"compensatedinventory{reservation_id}"
if SAGA_STATE.get(key):
return
print(f"Released inventory {reservation_id}")
SAGA_STATE[key] = True
@task
def charge_payment(order_id: str) -> str:
payment_id = f"pay-{order_id}"
SAGA_STATE[f"forward{order id}payment"] = payment_id
return payment_id
@task
def refund_payment(payment_id: str):
key = f"compensatedpayment{payment_id}"
if SAGA_STATE.get(key):
return
print(f"Refunded payment {payment_id}")
SAGA_STATE[key] = True
@task
def create_shipment(order_id: str) -> str:
raise RuntimeError("shipping unavailable")
@task
def cancel_shipment(shipment_id: str):
key = f"compensatedshipment{shipment_id}"
if SAGA_STATE.get(key):
returnMarvin
06/18/2026, 11:59 AMdict examples with:
- Postgres
- Redis
- S3/GCS-backed records
- downstream API idempotency keys
- your business database
---
# Useful Prefect docs
- Prefect docs
- Flows
- Tasks
If you want, I can also turn this into:
1. a payment/inventory/order example,
2. a travel booking saga example, or
3. a Prefect 3 + async tasks + persisted compensation log example.Bring your towel and join one of the fastest growing data communities. Welcome to our second-generation open source orchestration platform, a completely rethought approach to dataflow automation.
Powered by