Module 08: Durable Workflow Orchestration
Why this matters
Module 04 built a real, correct saga orchestrator by hand: a saga_state
table you write to after every step, a _compensate function you wrote
yourself, idempotency keys you designed and applied one by one, and — in
exercise 5 — a manual "read saga_state, skip completed steps" recovery
path for when the orchestrator process itself crashes mid-saga. It works,
and understanding it by hand is exactly what makes the next tool make
sense: a durable execution engine (Temporal is this module's example;
AWS Step Functions and Camunda solve the same problem for different
platforms) gives you everything that hand-rolled machinery did —
crash-resume, automatic retries, a durable record of every step — as a
property of the platform, not code you wrote and must keep correct
yourself. This module shows you exactly what that trade buys you, using
the same order-charge-reserve-confirm flow from module 04, so you can
compare the two approaches directly rather than taking the promise on
faith.
Concepts
The problem restated: your hand-rolled orchestrator already does this — manually
Look back at module 04's run_order_saga: every step writes to
saga_state before moving on, specifically so that a crash can be
recovered from by re-reading that table and skipping finished steps. That
pattern — persist progress, replay to resume — is exactly what a durable
execution engine automates. The difference isn't a new concept; it's who
implements the mechanism and how much of it you have to get right
yourself, by hand, in every workflow you write.
Event history: how the engine remembers, without you writing to a table
A durable execution engine like Temporal doesn't ask you to write
saga_state rows. Instead, the engine itself durably records an
event history for every workflow run — "activity X started," "activity
X completed with result Y," "timer fired," "signal received" — every time
your workflow code does something meaningful. When a worker process
restarts (crashes, redeploys, whatever), a fresh worker picking up that
workflow doesn't start over: it replays the event history from the
beginning, and your workflow code re-executes deterministically — but
every already-recorded activity call returns its already-recorded
result instantly instead of actually re-running the side effect. Only
the point past what's in history actually does new work.
Hand-rolled (module 04): Durable execution engine:
YOU write to saga_state ENGINE writes event history
after each step automatically, transparently
YOU read saga_state on restart ENGINE replays history on restart
and skip completed steps — your workflow code just runs
again; completed activities
return cached results, don't
re-execute
Workflows and activities: the same split you already made in module 04
The vocabulary maps directly onto what you already built:
- A workflow function is your orchestration logic — the sequence of
steps, exactly like
run_order_saga. Rule: workflow code must be deterministic, because it gets replayed on every recovery. No direct network calls, no reading the real clock, no random numbers inside workflow code itself — anything with a real side effect or non-deterministic result belongs in an activity instead. - An activity function is where actual side effects happen — calling the payment service, hitting a database, anything module 04 called "T1, T2, T3." Activities are not replayed — the engine records their result once and reuses it, which is exactly why workflow code can safely re-run from the top without re-charging a card.
from temporalio import workflow, activity
from datetime import timedelta
@activity.defn
async def charge_card(order_id: str) -> str:
# Real side effect — calls out, may fail, may take time.
return f"charge-{order_id}"
@workflow.defn
class OrderSaga:
@workflow.run
async def run(self, order_id: str) -> str:
# Orchestration logic ONLY — no direct side effects here.
charge_id = await workflow.execute_activity(
charge_card, order_id, start_to_close_timeout=timedelta(seconds=10)
)
return charge_id
Retries are the engine's job now, not a try/except you wrote
Module 01 (idempotency) and module 04 both leaned on retries you had to reason about and often implement yourself. A durable execution engine retries a failed activity automatically, with a configurable backoff policy, and your workflow code doesn't contain a single retry loop:
from temporalio.common import RetryPolicy
result = await workflow.execute_activity(
charge_card, order_id,
start_to_close_timeout=timedelta(seconds=10),
retry_policy=RetryPolicy(initial_interval=timedelta(milliseconds=100), maximum_attempts=5),
)
If charge_card raises, the engine automatically re-invokes it — with
backoff, up to maximum_attempts — with no code in your workflow
handling the retry. This is module 04's "the compensations must be
retried, not abandoned" discipline, built into the platform instead of
hand-written per step. (Idempotency still matters — module 01's lesson
doesn't disappear — because the activity itself might partially
succeed before failing; the engine retrying it doesn't make the
underlying side effect idempotent for you.)
Signals: getting an external event into a running, possibly long-lived workflow
Module 04's saga ran start-to-finish in seconds. Real workflows are often
much longer-lived — "wait for a human to approve this expense report,"
which could take days. A signal is how outside code delivers an event
into an already-running workflow, and workflow.wait_condition is how the
workflow waits for it without polling:
@workflow.defn
class ApprovalSaga:
def __init__(self):
self._approved = False
@workflow.signal
def approve(self):
self._approved = True
@workflow.run
async def run(self, order_id: str) -> str:
await workflow.wait_condition(lambda: self._approved)
return f"{order_id}-approved"
The workflow can sit waiting for days — the engine doesn't hold a
process or a thread open the whole time; it's durably parked in event
history until a signal arrives, then resumes exactly where it left off.
This is a capability module 04's hand-rolled saga_state table doesn't
give you for free: a truly long-running, crash-proof "wait for something
external" step.
What you give up: a platform dependency and a learning curve
This isn't a strictly-better replacement for everything module 04 taught
— it's a different point on the same cost curve module 04 kept coming
back to. You now depend on running (or paying for) the workflow engine
itself; your workflow code must follow the determinism rule (no direct
I/O, no datetime.now(), no raw randomness inside workflow functions —
mistakes here are a real, non-obvious class of bug specific to this
model); and you've added an infrastructure dependency for flows that
might have been simple enough for module 04's choreography. Reach for
a durable execution engine when: sagas are long-running (hours/days,
not seconds), have many steps/branches, need human-in-the-loop signals,
or you're maintaining enough hand-rolled orchestrators that the
platform's cost is cheaper than N more saga_state tables. Stick with
module 04's approach when: the saga is short, few steps, and adding a
new piece of infrastructure isn't worth it yet — the same escalation
judgment this whole track has taught from the start.
Command reference
This module uses Temporal, run locally with its own dev-mode server (no separate database or Docker Compose needed for local development).
| Concern | Temporal (Python SDK) |
|---|---|
| Start a local dev server | temporal server start-dev |
| Check the server is healthy | temporal operator cluster health |
| Define a workflow | @workflow.defn class with an @workflow.run method |
| Define an activity | @activity.defn async function |
| Call an activity from a workflow | await workflow.execute_activity(fn, arg, start_to_close_timeout=...) |
| Run a worker | Worker(client, task_queue=..., workflows=[...], activities=[...]) |
| Start a workflow (fire and forget) | await client.start_workflow(Fn.run, arg, id=..., task_queue=...) |
| Start and wait for the result | await client.execute_workflow(Fn.run, arg, id=..., task_queue=...) |
| Send a signal to a running workflow | await handle.signal(Fn.signal_method) |
| Get a handle to an existing workflow | client.get_workflow_handle(workflow_id) |
| Web UI (history, running workflows) | http://localhost:8233 (started automatically by start-dev) |
Hands-on exercises
pip install temporalio. Start the local dev server once, in its own
terminal, and leave it running for all exercises:
temporal server start-dev
1. Run the order saga as a durable workflow
import asyncio
from datetime import timedelta
from temporalio import workflow, activity
from temporalio.client import Client
from temporalio.worker import Worker
@activity.defn
async def charge_card(order_id: str) -> str:
return f"charge-{order_id}"
@activity.defn
async def reserve_inventory(order_id: str) -> None:
pass
@activity.defn
async def confirm_order(order_id: str) -> None:
pass
@workflow.defn
class OrderSaga:
@workflow.run
async def run(self, order_id: str) -> str:
charge_id = await workflow.execute_activity(
charge_card, order_id, start_to_close_timeout=timedelta(seconds=10)
)
await workflow.execute_activity(
reserve_inventory, order_id, start_to_close_timeout=timedelta(seconds=10)
)
await workflow.execute_activity(
confirm_order, order_id, start_to_close_timeout=timedelta(seconds=10)
)
return f"confirmed:{charge_id}"
async def main():
client = await Client.connect("localhost:7233")
async with Worker(
client, task_queue="orders-tq",
workflows=[OrderSaga], activities=[charge_card, reserve_inventory, confirm_order],
):
result = await client.execute_workflow(
OrderSaga.run, "order-1", id="saga-order-1", task_queue="orders-tq"
)
print("RESULT:", result)
if __name__ == "__main__":
asyncio.run(main())
Expected: RESULT: confirmed:charge-order-1. Open http://localhost:8233
and find this workflow run — you can see its full event history (every
activity call and result), something module 04's saga_state table
only gave you a single current row of, not a full audit trail.
Gotcha to know before you hit it: Temporal's Python SDK re-imports
your workflow-defining module inside a sandbox to guarantee determinism.
If your script calls asyncio.run(main()) at module level with no
guard, that call re-executes during the sandboxed re-import and crashes
with RuntimeError: asyncio.run() cannot be called from a running event loop. Always guard your entrypoint with
if __name__ == "__main__": — every example in this module already does.
2. Prove retries are automatic — no retry loop in your code
import asyncio
from datetime import timedelta
from temporalio import workflow, activity
from temporalio.client import Client
from temporalio.worker import Worker
from temporalio.common import RetryPolicy
@activity.defn
async def flaky_charge(order_id: str) -> str:
info = activity.info()
if info.attempt < 3:
raise RuntimeError("simulated transient failure")
return f"charge-{order_id}"
@workflow.defn
class FlakySaga:
@workflow.run
async def run(self, order_id: str) -> str:
return await workflow.execute_activity(
flaky_charge, order_id,
start_to_close_timeout=timedelta(seconds=10),
retry_policy=RetryPolicy(initial_interval=timedelta(milliseconds=100), maximum_attempts=5),
)
async def main():
client = await Client.connect("localhost:7233")
async with Worker(client, task_queue="flaky-tq", workflows=[FlakySaga], activities=[flaky_charge]):
result = await client.execute_workflow(FlakySaga.run, "order-2", id="saga-order-2", task_queue="flaky-tq")
print("RESULT:", result)
if __name__ == "__main__":
asyncio.run(main())
Expected: the activity raises on attempts 1 and 2 (visible in the
worker's log output), succeeds on attempt 3, and the workflow still
completes with RESULT: charge-order-2 — your workflow code contains no
try/except, no backoff, no attempt counter. Compare this to module
04's payment_service.charge idempotency-key discipline: idempotency
still matters here too (the retry policy will call flaky_charge again
on failure, so if the activity's side effect can partially succeed
before raising, it still needs to be safe to retry) — the engine
automates the retry mechanics, not the idempotency of your side effect.
3. Kill the worker mid-workflow and prove it resumes without re-running completed work
This is the exercise that actually proves the "durable" claim rather than asserting it. Run one process that starts the workflow, a worker that runs just long enough to complete the first step, then a second, independent worker that resumes the same workflow:
import asyncio, os
from datetime import timedelta
from temporalio import workflow, activity
from temporalio.client import Client
from temporalio.worker import Worker
SIDE_EFFECT_FILE = "side_effect.txt"
@activity.defn
async def step_one(order_id: str) -> str:
with open(SIDE_EFFECT_FILE, "a") as f:
f.write(f"step_one ran for {order_id}\n")
return "step-one-done"
@activity.defn
async def step_two(order_id: str) -> str:
with open(SIDE_EFFECT_FILE, "a") as f:
f.write(f"step_two ran for {order_id}\n")
return "step-two-done"
@workflow.defn
class CrashSaga:
@workflow.run
async def run(self, order_id: str) -> str:
r1 = await workflow.execute_activity(step_one, order_id, start_to_close_timeout=timedelta(seconds=10))
await asyncio.sleep(3) # a window to "crash" the worker in
r2 = await workflow.execute_activity(step_two, order_id, start_to_close_timeout=timedelta(seconds=10))
return f"{r1}+{r2}"
async def main():
client = await Client.connect("localhost:7233")
await client.start_workflow(CrashSaga.run, "order-3", id="saga-order-3", task_queue="crash-tq")
print("workflow started")
# "Worker A": runs briefly, completes step_one, then stops (simulated crash).
async with Worker(client, task_queue="crash-tq", workflows=[CrashSaga], activities=[step_one, step_two]):
await asyncio.sleep(1.5)
print("worker A stopped (simulated crash)")
print("side effects so far:", open(SIDE_EFFECT_FILE).read())
# "Worker B": a completely fresh worker, resuming the SAME workflow from history.
async with Worker(client, task_queue="crash-tq", workflows=[CrashSaga], activities=[step_one, step_two]):
handle = client.get_workflow_handle("saga-order-3")
result = await handle.result()
print("RESULT after resume:", result)
print("side effects after resume:", open(SIDE_EFFECT_FILE).read())
if __name__ == "__main__":
if os.path.exists(SIDE_EFFECT_FILE):
os.remove(SIDE_EFFECT_FILE)
asyncio.run(main())
Expected: after "worker A" stops, the side-effect file shows step_one
ran exactly once, and step_two hasn't run yet. After "worker B"
resumes and the workflow completes, the file shows step_one still
only once, plus step_two once — the completed step_one activity's
result came back from Temporal's event history instantly on replay; it
was never re-executed by the new worker, even though the new worker is a
totally separate process that never saw the first one run. This is
module 04's manual "read saga_state, skip finished steps" recovery,
except you didn't write any of the resume logic.
4. Build a signal-driven, human-in-the-loop approval step
import asyncio
from temporalio import workflow
from temporalio.client import Client
from temporalio.worker import Worker
@workflow.defn
class ApprovalSaga:
def __init__(self):
self._approved = False
@workflow.signal
def approve(self):
self._approved = True
@workflow.run
async def run(self, order_id: str) -> str:
await workflow.wait_condition(lambda: self._approved)
return f"{order_id}-approved"
async def main():
client = await Client.connect("localhost:7233")
async with Worker(client, task_queue="approval-tq", workflows=[ApprovalSaga], activities=[]):
handle = await client.start_workflow(
ApprovalSaga.run, "order-4", id="saga-order-4", task_queue="approval-tq"
)
await asyncio.sleep(1) # stand-in for "a human takes their time"
await handle.signal(ApprovalSaga.approve)
result = await handle.result()
print("RESULT:", result)
if __name__ == "__main__":
asyncio.run(main())
Expected: RESULT: order-4-approved. The workflow was durably parked in
wait_condition the whole time — try increasing the sleep to 30
seconds and confirm it still works exactly the same way; a signal-waiting
workflow doesn't cost a held-open connection or thread the way a
hand-rolled "poll a status column every few seconds" approach would.
5. Diagnose and fix: a workflow that behaves differently after every deploy
A team's workflow function calls datetime.now() directly inside the
workflow to compute a discount that only applies before a cutoff time,
and reads a random number to pick between two shipping carriers. It
works in testing, but in production, some workflows that get replayed
after a worker restart mysteriously flip which carrier they chose, or
apply a discount they shouldn't have.
Solution
Root cause: the workflow function called datetime.now() and a random
number generator directly inside workflow code, which violates the
determinism rule this module introduced. On replay, the engine re-runs
the workflow function from the top to reconstruct its state — if that
function calls the real clock or real randomness again, it can get a
different answer on replay than it got the first time (the clock has
moved on; the random seed isn't preserved the same way), so a decision
made once during the original execution silently changes during a
later replay.
Fix: move any need for the current time or randomness into an activity (which is not replayed — its result is recorded once and reused) — or, in engines that provide one, use the SDK's replay-safe time API instead of the standard library's clock directly. The general rule: anything non-deterministic — real time, real randomness, real network calls, real file I/O — belongs in an activity, never directly in workflow code, precisely because workflow code is replayed and must produce the same decisions every time it is.
6. Clean up
# Ctrl+C the `temporal server start-dev` terminal — an in-memory dev server,
# nothing persists once it stops.
Independent challenge
No code given. Redesign module 04's independent challenge — "book a trip" (reserve a flight, reserve a hotel, charge the customer once) — as a durable workflow instead of a hand-rolled orchestrator. Specify: (1) which parts become activities and which stay as pure workflow orchestration logic, and justify each choice against the determinism rule; (2) how you'd model "the flight reservation service is down for maintenance for the next 10 minutes" using retry policies instead of a hand-written retry loop; (3) how you'd add a genuinely long-lived step — "wait up to 24 hours for the customer to confirm the itinerary by clicking a link in an email" — using a signal, and what happens to that wait if the worker process restarts twice during those 24 hours; (4) one paragraph arguing, using this module's escalation judgment, whether "book a trip" actually justifies a durable execution engine over module 04's hand-rolled orchestrator, or whether it's simple enough that the extra infrastructure isn't worth it yet.
Stuck? One hint
Reserving the flight, reserving the hotel, and charging the card are all
activities (real side effects, real network calls to other services);
the sequencing and compensation logic is the workflow function itself
(pure orchestration, replay-safe). For (2), a RetryPolicy with a longer
maximum_interval/backoff and a high maximum_attempts handles a
service being down for minutes without any hand-written retry code —
the workflow simply awaits the activity call as normal; the engine keeps
retrying underneath. For (3), a signal plus workflow.wait_condition
(or a timeout combined with it, to handle "customer never confirmed")
survives any number of worker restarts because it's durably recorded in
event history, not held in a live process's memory — this is precisely
the capability module 04's hand-rolled saga didn't have. For (4), "book
a trip" has three steps and one genuinely long wait (24 hours for
confirmation) — the long-lived signal wait is exactly the kind of
requirement that tips the scale toward a durable execution engine, since
module 04's hand-rolled approach has no good answer for "durably wait
up to a day for a human," short of building a chunk of this
infrastructure yourself.
Common mistakes & troubleshooting
- Calling real I/O, the real clock, or real randomness directly inside workflow code. As the diagnose-and-fix exercise showed, workflow code is replayed to reconstruct state, and non-deterministic calls can return different answers on replay than they did the first time, silently corrupting decisions. Push anything non-deterministic into an activity.
- Forgetting the
if __name__ == "__main__":guard on an entrypoint script. Temporal's Python SDK re-imports the workflow-defining module inside a sandbox; unguarded module-level code (like a bareasyncio.run(main())) re-executes during that re-import and crashes with a confusingRuntimeError, as exercise 1 noted. - Assuming the engine's automatic retries remove the need for idempotency. The engine retries a failed activity call automatically, but if that activity's side effect can partially succeed before raising, a retry can still repeat real-world work. Module 01's idempotency-key discipline still applies inside activities.
- Reaching for a durable execution engine for a two-step, seconds-long saga. As the independent challenge's final question emphasizes, this is an escalation decision like every other in this track — a short, simple saga (module 04's choreography case) doesn't need a new infrastructure dependency; a long-running, branchy, human-in-the-loop workflow does.
- Confusing "activities aren't replayed" with "activities can't fail." Activities still fail, time out, and get retried per their policy — "not replayed" means a successful activity's result is reused on replay, not that failures are hidden or impossible.
Checkpoint quiz
Write down your answer to each question before expanding it — checking without attempting first is the single easiest way to fool yourself into thinking you've learned this.
- What does a durable execution engine's "event history" replace from module 04's hand-rolled orchestrator, and what does replaying that history actually do on a worker restart?
- What's the difference between a workflow function and an activity function, and why must workflow code be deterministic while activity code doesn't have that restriction?
- If an activity has a
RetryPolicywithmaximum_attempts=5and fails on the first two attempts, what code do you have to write to make the third attempt happen? - What is a signal, and what kind of requirement from module 04's saga
design does it solve that a hand-rolled
saga_statetable doesn't solve well? - Give one concrete reason a short, two-step saga might not be worth moving to a durable execution engine.
Answers
- It replaces the
saga_statetable and the manual "readsaga_stateon restart, skip completed steps" recovery logic from module 04. Replaying the event history re-runs the workflow function, but every activity call that already completed returns its already-recorded result instantly instead of re-executing the real side effect — only work past what's already in history actually happens again. - A workflow function is pure orchestration/sequencing logic and gets replayed to reconstruct state after a restart, so it must be deterministic (no direct real-time clock, randomness, network calls, or file I/O). An activity function is where real side effects happen; it is not replayed — its result is recorded once in history and reused — so it's free to call out to real services, use real time, etc.
- None — the retry is automatic. You configure a
RetryPolicywhen callingworkflow.execute_activity, and the engine handles re-invoking the failed activity with backoff up tomaximum_attempts; notry/exceptor retry loop belongs in your workflow or activity code for this. - A signal is how external code delivers an event into an already-
running (possibly long-lived) workflow, and
workflow.wait_conditionlets the workflow durably wait for it with no polling. It solves "wait an indefinite, possibly very long time for an external event (human approval, an async callback)" — a hand-rolledsaga_statetable has no equivalent durable, crash-proof "wait for this" primitive without building substantial extra infrastructure yourself. - Any of: it adds an infrastructure dependency (running or paying for the engine) for a flow simple enough not to need it; workflow code must follow the determinism rule, a real learning curve and a new class of subtle bug if violated; and module 04's choreography or a simple hand-rolled orchestrator is easier to reason about and operate for a saga that completes in seconds with few steps and no long-lived waits.
Further reading & sources
- Temporal: Core application concepts - the official overview of workflows, activities, and event history this module is built on.
- Temporal Python SDK documentation - the SDK used throughout this module's exercises.
- Temporal: Workflow determinism constraints - the exact rules behind the "no real time/randomness/I-O directly in workflow code" discipline in this module.
- AWS Step Functions: How it works - the same durable-orchestration idea on a different, JSON-state-machine-based platform.
- microservices.io: Saga pattern - the pattern this module's engine automates the mechanics of; read alongside module 04 for the full picture.
Next
09-capstone-project — you now hold the whole toolkit: per-operation consistency (SD 15), idempotency (01), locking and fencing (02), the 2PC trade-off (03), sagas (04), CQRS (05), event sourcing (06), consensus/coordination (07), and durable workflow orchestration (08). The capstone stops teaching new patterns and asks you to combine them: design and partially build a distributed order-processing system that uses idempotency keys to make requests safe, a saga to coordinate the multi-step transaction across services, and event sourcing to record the order's state history.