Project: A Real Saga With a Real Compensating Transaction¶
What was run¶
A real orchestrator_durable.py (port 8080) combining three ideas from this level into one service: the saga from
Lesson 4, the idempotency-key pattern from Lesson 6,
and durable SQLite storage like Lesson 5's outbox. A checkout was run, retried with
the same key, then the orchestrator process was killed with kill -9 and restarted, and the same checkout was
retried a third time — all against the real order_service.py, payment_service.py, and inventory_service.py
from Lesson 4.
The saga, run once, for real¶
curl -s -X POST localhost:8080/checkout -H 'Idempotency-Key: ckt-001' \
-d '{"item":"mechanical keyboard","amount_cents":9000}'
# {"saga":"COMPLETED","replayed":false,
# "order":{"id":11,"item":"mechanical keyboard","amount_cents":9000,"status":"CONFIRMED"},
# "payment":{"id":8,"order_id":11,"amount_cents":9000,"status":"CHARGED"},
# "reservation":{"id":4,"order_id":11,"item":"mechanical keyboard","status":"RESERVED"}}
existing = conn.execute("SELECT result FROM saga_runs WHERE idempotency_key = ?", (idempotency_key,)).fetchone()
if existing:
result = json.loads(existing[0])
result["replayed"] = True
return result
# ... only reached if no existing result: run the real saga, then persist the result before returning it
conn.execute("INSERT INTO saga_runs (idempotency_key, result) VALUES (?, ?)", (idempotency_key, json.dumps(result)))
Retried, then the process is really killed, then retried again¶
curl -s -X POST localhost:8080/checkout -H 'Idempotency-Key: ckt-001' -d '{...same body...}'
# {"saga":"COMPLETED","replayed":true, ...same order/payment/reservation ids as before...}
pkill -9 -f "uvicorn orchestrator_durable:app" # a real, hard crash
uvicorn orchestrator_durable:app --port 8080 & # a fresh process, fresh memory, nothing cached in RAM
curl -s -X POST localhost:8080/checkout -H 'Idempotency-Key: ckt-001' -d '{...same body...}'
# {"saga":"COMPLETED","replayed":true, ...identical order id 11, payment id 8, reservation id 4...}
curl -s localhost:8072/payments
# [...,{"id":8,"order_id":11,"amount_cents":9000,"status":"CHARGED"}] <- exactly one payment, id 8
Three real checkout requests with the same Idempotency-Key, spanning a real process crash in between, produced
exactly one real order, one real payment, and one real inventory reservation — confirmed directly against
payment_service.py's own records, not just trusted from the orchestrator's response. The crash in the middle
changed nothing, because the fact "this idempotency key already has a result" lives in saga_log.db on disk, not
in the orchestrator process's memory.
How It Actually Works¶
This project directly closes the gap that Lesson 4's own exercise raised: an orchestrator that only tracks saga progress in memory loses all of it on a crash, leaving no record that a saga was ever in flight. This version fixes the retry half of that problem — a client that doesn't know whether its request made it through (because the orchestrator crashed, or the network dropped the response) can safely retry with the same key and get the real, authoritative final answer, without risking a duplicate charge, exactly the property Lesson 6 built in isolation and this project now applies to an entire multi-step saga, not just one endpoint.
What this project does not yet fully solve, and what's worth being honest about: it only persists the saga's
final result, not its in-progress state. If the orchestrator crashes mid-saga — after charging payment but
before reserving inventory — a retry with the same key would find no saga_runs row yet and re-run the entire
saga from scratch, potentially double-charging the customer. A fully crash-safe saga needs to persist each
step's completion, not just the end result, so a resumed saga can pick up exactly where it left off rather than
restarting — the natural next step beyond this project, and close to what dedicated saga/workflow engines (like
Temporal or AWS Step Functions) are built specifically to handle.
Common mistakes¶
- Persisting only the final saga result (as this project deliberately does, to keep the example reachable) and mistaking that for full crash safety — the honest limitation above (a mid-saga crash can still double-run) is a real gap, not a hypothetical one.
- Using the idempotency key as the saga's only identifier without also being able to look up a saga by its resulting order ID, payment ID, etc. — in production you'll need both directions of lookup for support and debugging.
- Never expiring old
saga_runsrows — an idempotency log that grows forever is a real, if slow-growing, operational problem; production systems typically expire keys after a bounded window (e.g., 24 hours), long enough to cover realistic retries, short enough not to grow unbounded.
Exercise¶
Close the gap named above for real: add a status column to saga_runs (IN_PROGRESS, COMPLETED, FAILED),
write a row with IN_PROGRESS immediately after creating the order (before calling Payment), and update it as each
step completes. Then simulate a mid-saga crash by adding a time.sleep(10) right after the payment call and
killing the process during that sleep. Restart, retry with the same key, and observe what your current
implementation does (hint: it will likely re-run everything, since it only checks for a COMPLETED row) — then
write, in a comment, what check you'd need to add to detect and safely resume (or safely compensate) an
IN_PROGRESS saga instead of blindly re-running it.