~learn.
?

Day 0338 minPreface+60 XP

Saga Orchestration Deep Dive.

4 stages18 code

watch it first · newThe whole lesson in a 19-minute animated lecture5 quiz checkpoints · ask the AI about any moment · captions, chapters and speed

Preface · 1 min

Preface#

Yesterday, we learned the Saga pattern — how to coordinate distributed transactions using local transactions with compensation. We built a basic orchestrator that executes steps and handles failures.

But our orchestrator had limitations:

Yesterday's orchestrator limitations
  • 1. Crash recovery
    • What if orchestrator crashes mid-step?
    • We persist state to DB, but recovery is manual
    • What if the step was half-executed?
    • Did the external API receive our request or not?
  • 2. Long-running workflows
    • What if a step takes hours? (human approval)
    • What if we need to wait for external events?
    • Holding connections open isn't scalable
    • Polling the database is inefficient
  • 3. Versioning
    • What if we need to change the saga logic?
    • In-flight sagas use old code
    • How do we migrate without breaking things?
    • What about A/B testing saga variations?
  • 4. Visibility
    • Where is this saga right now?
    • How long has it been stuck?
    • What's the history of this execution?
    • Can we replay a failed saga?

Today, we'll solve these problems with workflow orchestration engines — specifically Temporal (a fork of Uber's Cadence). These systems provide durable execution — your workflow logic takes effect once (activities are retried at-least-once, so they must be idempotent), survives any failure, and can wait for years if needed.

ExplainerThe whole lesson: saga orchestration and durable execution

A hand-built orchestrator loses its place on a crash and can't wait hours, version itself or show where a saga is. Durable execution records every step as an event; after a crash the workflow replays and completed activities return their stored results instead of running again. Temporal splits deterministic workflows from side-effecting activities, adds signals, queries and durable timers, and versions code with patched() so in-flight runs keep the old path; Uber's Cadence runs about 12 billion executions a month.

Part I · Stage 1 of 4 · 21 min

Part I: Foundations#

Chapter 1: What Is Durable Execution?#

1.1 The Problem with Normal Code#

pythonprocess_order · 19 lines
# Normal code - NOT durable

async def process_order(order_id: str):
    # Step 1: Reserve inventory
    reservation = await inventory_service.reserve(order_id)
    
    # CRASH HERE - what happens?
    # - reservation exists in inventory service
    # - but we lost track of it
    # - if we restart, do we reserve again? (double reserve!)
    # - if we don't, order is stuck
    
    # Step 2: Charge payment
    payment = await payment_service.charge(order_id)
    
    # Step 3: Ship
    shipment = await shipping_service.create(order_id)
    
    return shipment.tracking_number

The problem: Function state is lost on crash. We don't know where we were or what we've done.

Your turn · checkpoint 1not answered

Crash after the reservation

process_order reserved inventory, and the process crashed before charging the payment. When it restarts, should it run from the top or skip step 1? What would it need to know to skip safely, and where would that knowledge have to live?

Think it through, or jot it down. Then compare.

One strong answer

  • From the top: it reserves again, a double reservation, unless the reserve call is idempotent.
  • Skip: only safe if something recorded that step 1 completed and what it returned (the reservation ID, needed later for compensation).
  • Where: outside the crashed process, in durable storage written as each step completes. Restarting then means replaying the code against that record: completed steps return their stored results instead of running again.

1.2 Durable Execution: Code That Survives Anything#

pythonOrderWorkflow · 33 lines
# Durable execution with Temporal

@workflow.defn
class OrderWorkflow:
    @workflow.run
    async def run(self, order_id: str) -> str:
        # Step 1: Reserve inventory
        reservation = await workflow.execute_activity(
            reserve_inventory,
            order_id,
            start_to_close_timeout=timedelta(minutes=5)
        )
        
        # CRASH HERE - no problem!
        # Temporal recorded that reserve_inventory completed
        # On restart, it SKIPS step 1 and continues from step 2
        # The reservation result is replayed from history
        
        # Step 2: Charge payment
        payment = await workflow.execute_activity(
            charge_payment,
            order_id,
            start_to_close_timeout=timedelta(minutes=5)
        )
        
        # Step 3: Ship
        shipment = await workflow.execute_activity(
            create_shipment,
            order_id,
            start_to_close_timeout=timedelta(minutes=5)
        )
        
        return shipment["tracking_number"]

How it works:

  1. Every activity completion is recorded in Temporal's database
  2. On crash, workflow replays from the beginning
  3. Completed activities return their stored results (not re-executed)
  4. Execution continues from where it left off

1.3 The Magic of Event Sourcing for Workflows#

Workflow event sourcing

Temporal stores workflow history as events:

  • Event 1 —
    WorkflowExecutionStarted {order_id: "order-123"}
  • Event 2 —
    ActivityTaskScheduled {activity: "reserve_inventory", input: "order-123"}
  • Event 3 —
    ActivityTaskCompleted {result: {reservation_id: "res-456"}}
  • Event 4 —
    ActivityTaskScheduled {activity: "charge_payment", input: "order-123"}
  • CRASH HERE —
    on recovery, workflow replays:
  • Replay Event 1 —
    Start workflow with order_id
  • Replay Event 2 —
    Schedule reserve_inventory
  • Replay Event 3 —
    Return stored result (DON'T re-execute!)
  • Replay Event 4 —
    Schedule charge_payment
  • Continue —
    Activity worker picks up charge_payment

Key insight: Activities are NOT re-executed on replay. Their recorded results are returned to the workflow code.

ExplainerDurable execution: replay from the event history

Plain code loses its place when it crashes after reserve_inventory. Temporal records each step as an event (started, reserve_inventory scheduled and completed with res-456, charge_payment scheduled); after a crash a new worker replays the history, completed activities return their stored result instead of running again, and execution continues with charge_payment.

Chapter 2: Temporal Architecture#

2.1 Core Components#

DiagramTemporal architecture
%% title: Temporal architecture
flowchart TD
  subgraph TC["Temporal cluster"]
    direction TB
    subgraph TS["Temporal server"]
      direction TB
      FE["Frontend Service"]:::core
      HS["History Service"]:::core
      MS["Matching Service"]:::core
      DB@{ icon: "sd:database", label: "Persistence<br/>PostgreSQL / Cassandra / MySQL" }
    end
  end
  WW@{ icon: "sd:worker", label: "Workflow worker<br/>Executes workflow code<br/>(deterministic)" }
  AW@{ icon: "sd:worker", label: "Activity worker<br/>Executes activity code<br/>(side effects)" }
  CL@{ icon: "sd:code", label: "Client<br/>Starts workflows<br/>Sends signals<br/>Queries state" }
  TC -->|"gRPC"| WW
  TC -->|"gRPC"| AW
  TC -->|"gRPC"| CL
  class DB store
  class WW,AW service
  class CL client

Component responsibilities:

  • Frontend Service: API gateway for clients; rate limiting, auth; request routing
  • History Service: Workflow state management; event history storage; workflow execution
  • Matching Service: Task queue management; worker polling; task dispatch
  • Workers:
    • Workflow Worker: Runs workflow code (decision making)
    • Activity Worker: Runs activity code (actual work)
Your turn · checkpoint 2not answered

What may run twice?

Workflow code is re-run from the beginning on every recovery. Sort these into "safe to re-run" and "must happen outside replay": deciding whether total > 1000; charging the card; reading the current time; looping over the order's items; inserting a reservation row; generating a random ID.

Think it through, or jot it down. Then compare.

One strong answer

  • Safe to re-run (workflow code): the total > 1000 decision and the loop over items. Same input, same decisions.
  • Outside replay (activities): charging the card and inserting the row. They're side effects; their recorded results are returned on replay.
  • Time and random IDs: they'd differ on replay and send the code down a different branch, so they come from the engine (workflow.now(), workflow.uuid4()), which returns values consistent with the recorded history.

2.2 Workflows vs Activities#

Workflows vs activities
Workflows Activities
Deterministic code (same input → same decisions) Non-deterministic code (can have side effects)
Orchestration logic (what to do next) Actual work (API calls, DB writes)
Can run for years Timeout and retry policies
Replayed on crash (must be deterministic!) NOT replayed (result stored and returned)
NO side effects allowed Side effects ARE allowed
Examples: if/else, loops, wait for signal Examples: HTTP calls, database operations
Determinism rules for workflows

Don't do this in workflows:

  • Random numbers (use workflow.random())
  • Current time (use workflow.now())
  • UUID generation (use workflow.uuid4())
  • HTTP calls (use activities)
  • Database queries (use activities)
  • File I/O (use activities)
  • Sleep (use workflow.sleep())

Do this:

  • Pure logic (if/else, loops)
  • Call activities for side effects
  • Use workflow.* APIs for non-deterministic needs
  • Signal handling
  • State queries

2.3 Task Queues#

Task queues decouple workflow scheduling from execution.

DiagramTask queues
%% title: Task queues
flowchart TD
  TS["Temporal Server"]:::core
  WQ@{ icon: "sd:queue", label: "Workflow Task Queue<br/>'orders'" }
  AQ@{ icon: "sd:queue", label: "Activity Task Queue<br/>'orders'" }
  W1@{ icon: "sd:worker", label: "Worker 1" }
  W2@{ icon: "sd:worker", label: "Worker 2" }
  W3@{ icon: "sd:worker", label: "Worker 3" }
  A1@{ icon: "sd:worker", label: "Worker 1" }
  A2@{ icon: "sd:worker", label: "Worker 2" }
  A3@{ icon: "sd:worker", label: "Worker 3" }
  TS --> WQ
  TS --> AQ
  WQ --> W1
  WQ --> W2
  WQ --> W3
  AQ --> A1
  AQ --> A2
  AQ --> A3
  class WQ,AQ queue
  class W1,W2,W3,A1,A2,A3 service

Benefits:

  • Scale workers independently
  • Different queues for different priorities
  • Workers can be language-specific
  • Easy deployment (add/remove workers)
  • Rate limiting per queue

Chapter 3: Writing Temporal Workflows#

3.1 Basic Workflow Structure#

Note: the samples call fetch_one, as in the databases library (or a thin wrapper with that method). With asyncpg the equivalent is fetchrow, which returns a Record.

pythonOrderInput · 300 lines
# Basic Temporal Workflow

from datetime import timedelta
from temporalio import workflow, activity
from temporalio.common import RetryPolicy
from dataclasses import dataclass
from typing import Optional


# =============================================================================
# Data Classes
# =============================================================================

@dataclass
class OrderInput:
    order_id: str
    customer_id: str
    items: list
    total_amount: float
    shipping_address: str


@dataclass
class OrderResult:
    order_id: str
    status: str
    tracking_number: Optional[str] = None
    error: Optional[str] = None


# =============================================================================
# Activities (Side Effects)
# =============================================================================

@activity.defn
async def reserve_inventory(order_id: str, items: list) -> dict:
    """
    Activity: Reserve inventory.
    
    This is where actual work happens (DB calls, API calls).
    Activities can fail and will be retried according to policy.
    """
    async with get_db_connection() as db, db.transaction():
        # Activities may be retried: one reservation per order, all-or-nothing
        existing = await db.fetch_val(
            "SELECT id FROM reservations WHERE order_id = $1", order_id
        )
        if existing:
            return {"reservation_id": str(existing)}
        
        reservation_id = await db.fetch_val(
            "INSERT INTO reservations (order_id, items, status) VALUES ($1, $2, 'active') RETURNING id",
            order_id, json.dumps(items)
        )
        
        for item in items:
            result = await db.execute(
                "UPDATE inventory SET quantity = quantity - $1 WHERE product_id = $2 AND quantity >= $1",
                item['quantity'], item['product_id']
            )
            if result.rowcount == 0:
                raise InsufficientInventoryError(f"Not enough {item['product_id']}")
        
        return {"reservation_id": str(reservation_id)}


@activity.defn
async def release_inventory(reservation_id: str) -> dict:
    """Activity: Release inventory reservation (compensation)."""
    async with get_db_connection() as db, db.transaction():
        reservation = await db.fetch_one(
            "SELECT items FROM reservations WHERE id = $1 AND status = 'active' FOR UPDATE",
            reservation_id
        )
        
        if not reservation:
            return {"status": "already_released"}
        
        items = json.loads(reservation['items'])
        for item in items:
            await db.execute(
                "UPDATE inventory SET quantity = quantity + $1 WHERE product_id = $2",
                item['quantity'], item['product_id']
            )
        
        await db.execute(
            "UPDATE reservations SET status = 'released' WHERE id = $1",
            reservation_id
        )
        
        return {"status": "released"}


@activity.defn
async def charge_payment(
    customer_id: str,
    amount: float,
    idempotency_key: str
) -> dict:
    """Activity: Charge customer payment."""
    result = await stripe.charges.create(
        amount=int(amount * 100),
        currency="usd",
        customer=customer_id,
        idempotency_key=idempotency_key
    )
    return {"payment_id": result.id, "status": result.status}


@activity.defn
async def refund_payment(payment_id: str, idempotency_key: str) -> dict:
    """Activity: Refund payment (compensation)."""
    result = await stripe.refunds.create(
        charge=payment_id,  # payment_id is the Charge id from charge_payment
        idempotency_key=idempotency_key
    )
    return {"refund_id": result.id, "status": result.status}


@activity.defn
async def create_shipment(order_id: str, address: str, items: list) -> dict:
    """Activity: Create shipment with carrier."""
    shipment = await shipping_api.create_shipment(
        reference=order_id,
        destination=address,
        items=items
    )
    return {
        "shipment_id": shipment.id,
        "tracking_number": shipment.tracking_number
    }


@activity.defn
async def send_notification(
    customer_id: str,
    notification_type: str,
    data: dict
) -> dict:
    """Activity: Send notification to customer."""
    await notification_service.send(
        customer_id=customer_id,
        type=notification_type,
        data=data
    )
    return {"sent": True}


# =============================================================================
# Workflow (Orchestration Logic)
# =============================================================================

@workflow.defn
class OrderWorkflow:
    """
    Order processing workflow with saga compensation.
    
    This workflow:
    1. Reserves inventory
    2. Charges payment
    3. Creates shipment (pivot point)
    4. Sends confirmation
    
    If any step before shipment fails, it compensates.
    """
    
    def __init__(self):
        self.status = "pending"
        self.reservation_id = None
        self.payment_id = None
        self.tracking_number = None
    
    @workflow.run
    async def run(self, input: OrderInput) -> OrderResult:
        """Main workflow execution."""
        
        # Retry policy for activities
        retry_policy = RetryPolicy(
            initial_interval=timedelta(seconds=1),
            maximum_interval=timedelta(minutes=1),
            backoff_coefficient=2.0,
            maximum_attempts=5,
            non_retryable_error_types=["InsufficientInventoryError", "PaymentDeclinedError"]
        )
        
        try:
            # Step 1: Reserve inventory
            self.status = "reserving_inventory"
            reservation = await workflow.execute_activity(
                reserve_inventory,
                args=[input.order_id, input.items],
                start_to_close_timeout=timedelta(minutes=5),
                retry_policy=retry_policy
            )
            self.reservation_id = reservation["reservation_id"]
            
            # Step 2: Charge payment
            self.status = "charging_payment"
            payment = await workflow.execute_activity(
                charge_payment,
                args=[input.customer_id, input.total_amount, f"order-{input.order_id}"],
                start_to_close_timeout=timedelta(minutes=5),
                retry_policy=retry_policy
            )
            self.payment_id = payment["payment_id"]
            
            # Step 3: Create shipment (PIVOT - no compensation after this)
            self.status = "creating_shipment"
            shipment = await workflow.execute_activity(
                create_shipment,
                args=[input.order_id, input.shipping_address, input.items],
                start_to_close_timeout=timedelta(minutes=10),
                retry_policy=RetryPolicy(maximum_attempts=0)  # Retry forever after pivot
            )
            self.tracking_number = shipment["tracking_number"]
            
            # Step 4: Send confirmation
            self.status = "sending_confirmation"
            await workflow.execute_activity(
                send_notification,
                args=[input.customer_id, "order_confirmation", {
                    "order_id": input.order_id,
                    "tracking_number": self.tracking_number
                }],
                start_to_close_timeout=timedelta(minutes=1),
                retry_policy=RetryPolicy(maximum_attempts=0)  # After pivot: retry, never compensate
            )
            
            self.status = "completed"
            return OrderResult(
                order_id=input.order_id,
                status="completed",
                tracking_number=self.tracking_number
            )
            
        except Exception as e:
            # Compensation logic
            workflow.logger.error(f"Order workflow failed: {e}")
            await self._compensate(input)
            
            return OrderResult(
                order_id=input.order_id,
                status="failed",
                error=str(e)
            )
    
    async def _compensate(self, input: OrderInput):
        """Execute compensation for completed steps."""
        self.status = "compensating"
        
        compensation_policy = RetryPolicy(
            maximum_attempts=0  # Retry forever - compensation must succeed
        )
        
        # Refund payment if charged
        if self.payment_id:
            try:
                await workflow.execute_activity(
                    refund_payment,
                    args=[self.payment_id, f"refund-{input.order_id}"],
                    start_to_close_timeout=timedelta(minutes=5),
                    retry_policy=compensation_policy
                )
            except Exception as e:
                workflow.logger.error(f"Refund failed: {e}")
        
        # Release inventory if reserved
        if self.reservation_id:
            try:
                await workflow.execute_activity(
                    release_inventory,
                    args=[self.reservation_id],
                    start_to_close_timeout=timedelta(minutes=5),
                    retry_policy=compensation_policy
                )
            except Exception as e:
                workflow.logger.error(f"Release inventory failed: {e}")
        
        # Notify customer of failure
        await workflow.execute_activity(
            send_notification,
            args=[input.customer_id, "order_failed", {
                "order_id": input.order_id,
                "reason": "Payment or inventory issue"
            }],
            start_to_close_timeout=timedelta(minutes=1),
            retry_policy=compensation_policy
        )
        
        self.status = "compensated"
    
    @workflow.query
    def get_status(self) -> str:
        """Query: Get current workflow status."""
        return self.status
    
    @workflow.query
    def get_tracking_number(self) -> Optional[str]:
        """Query: Get tracking number if available."""
        return self.tracking_number

3.2 Signals: External Events#

pythonOrderWithApprovalWorkflow · 104 lines
# Workflows with Signals

@workflow.defn
class OrderWithApprovalWorkflow:
    """
    Order workflow that waits for manager approval.
    
    Demonstrates:
    - Signals for external events
    - Waiting for signals with timeout
    - Workflow state queries
    """
    
    def __init__(self):
        self.status = "pending"
        self.approved = None  # None = waiting, True/False = decided
        self.approver = None
    
    @workflow.run
    async def run(self, input: OrderInput) -> OrderResult:
        # Step 1: Reserve inventory
        reservation = await workflow.execute_activity(
            reserve_inventory,
            args=[input.order_id, input.items],
            start_to_close_timeout=timedelta(minutes=5)
        )
        
        # Step 2: Wait for approval if order > $1000
        if input.total_amount > 1000:
            self.status = "pending_approval"
            
            # Send approval request notification
            await workflow.execute_activity(
                send_notification,
                args=["managers", "approval_required", {
                    "order_id": input.order_id,
                    "amount": input.total_amount,
                    "customer": input.customer_id
                }],
                start_to_close_timeout=timedelta(minutes=1)
            )
            
            # Wait for approval signal (up to 24 hours)
            try:
                await workflow.wait_condition(
                    lambda: self.approved is not None,
                    timeout=timedelta(hours=24)
                )
            except asyncio.TimeoutError:
                # No approval received - auto-reject
                self.approved = False
                self.approver = "system_timeout"
            
            if not self.approved:
                # Rejected - compensate and return
                await self._compensate(input, reservation["reservation_id"])
                return OrderResult(
                    order_id=input.order_id,
                    status="rejected",
                    error=f"Rejected by {self.approver}"
                )
        
        # Step 3: Continue with payment and shipment
        self.status = "processing"
        # ... rest of workflow
    
    @workflow.signal
    async def approve(self, approver: str):
        """Signal: Approve the order."""
        self.approved = True
        self.approver = approver
    
    @workflow.signal
    async def reject(self, approver: str, reason: str):
        """Signal: Reject the order."""
        self.approved = False
        self.approver = approver
        self.rejection_reason = reason
    
    @workflow.query
    def get_approval_status(self) -> dict:
        """Query: Get approval status."""
        return {
            "status": self.status,
            "approved": self.approved,
            "approver": self.approver
        }


# Client code to send signals
async def approve_order(order_id: str, manager_id: str):
    """Manager approves an order."""
    client = await Client.connect("localhost:7233")
    
    handle = client.get_workflow_handle(f"order-{order_id}")
    await handle.signal(OrderWithApprovalWorkflow.approve, manager_id)


async def reject_order(order_id: str, manager_id: str, reason: str):
    """Manager rejects an order."""
    client = await Client.connect("localhost:7233")
    
    handle = client.get_workflow_handle(f"order-{order_id}")
    await handle.signal(OrderWithApprovalWorkflow.reject, args=[manager_id, reason])

3.3 Child Workflows#

pythonBatchOrderWorkflow · 56 lines
# Parent-Child Workflow Pattern

@workflow.defn
class BatchOrderWorkflow:
    """
    Process multiple orders as a batch.
    
    Each order runs as a child workflow.
    Parent tracks overall progress.
    """
    
    @workflow.run
    async def run(self, order_ids: list[str]) -> dict:
        results = {}
        
        # Start all orders as child workflows
        handles = []
        for order_id in order_ids:
            # Get order details (this would be an activity in real code)
            order_input = OrderInput(
                order_id=order_id,
                customer_id="batch-customer",
                items=[],
                total_amount=100.0,
                shipping_address="123 Main St"
            )
            
            # Start child workflow
            handle = await workflow.start_child_workflow(
                OrderWorkflow.run,
                order_input,
                id=f"order-{order_id}",
                task_queue="orders"
            )
            handles.append((order_id, handle))
        
        # Wait for all child workflows to complete
        for order_id, handle in handles:
            try:
                result = await handle
                results[order_id] = {
                    "status": result.status,
                    "tracking": result.tracking_number
                }
            except Exception as e:
                results[order_id] = {
                    "status": "failed",
                    "error": str(e)
                }
        
        return {
            "total": len(order_ids),
            "succeeded": sum(1 for r in results.values() if r["status"] == "completed"),
            "failed": sum(1 for r in results.values() if r["status"] == "failed"),
            "results": results
        }

Chapter 4: Advanced Patterns#

4.1 Saga State Machine#

pythonSagaState · 197 lines
# State Machine Pattern for Complex Sagas

from enum import Enum
from typing import Dict, Callable, Optional
from dataclasses import dataclass


class SagaState(Enum):
    INITIALIZED = "initialized"
    RESERVING_INVENTORY = "reserving_inventory"
    INVENTORY_RESERVED = "inventory_reserved"
    CHARGING_PAYMENT = "charging_payment"
    PAYMENT_CHARGED = "payment_charged"
    CREATING_SHIPMENT = "creating_shipment"
    SHIPMENT_CREATED = "shipment_created"
    COMPLETING = "completing"
    COMPLETED = "completed"
    COMPENSATING = "compensating"
    COMPENSATED = "compensated"
    FAILED = "failed"


@dataclass
class StateTransition:
    from_state: SagaState
    to_state: SagaState
    action: Optional[Callable] = None
    compensation: Optional[Callable] = None


@workflow.defn
class StateMachineOrderWorkflow:
    """
    Order workflow using explicit state machine.
    
    Benefits:
    - Clear visualization of all states
    - Explicit transitions
    - Easy to add new states
    - Better for complex branching
    """
    
    # State machine definition
    TRANSITIONS = {
        SagaState.INITIALIZED: [
            StateTransition(
                SagaState.INITIALIZED,
                SagaState.RESERVING_INVENTORY
            )
        ],
        SagaState.RESERVING_INVENTORY: [
            StateTransition(
                SagaState.RESERVING_INVENTORY,
                SagaState.INVENTORY_RESERVED,
                action="reserve_inventory",
                compensation="release_inventory"
            ),
            StateTransition(
                SagaState.RESERVING_INVENTORY,
                SagaState.FAILED  # On error
            )
        ],
        SagaState.INVENTORY_RESERVED: [
            StateTransition(
                SagaState.INVENTORY_RESERVED,
                SagaState.CHARGING_PAYMENT
            )
        ],
        SagaState.CHARGING_PAYMENT: [
            StateTransition(
                SagaState.CHARGING_PAYMENT,
                SagaState.PAYMENT_CHARGED,
                action="charge_payment",
                compensation="refund_payment"
            ),
            StateTransition(
                SagaState.CHARGING_PAYMENT,
                SagaState.COMPENSATING  # On error, start compensation
            )
        ],
        # ... more transitions
    }
    
    def __init__(self):
        self.state = SagaState.INITIALIZED
        self.history: list[SagaState] = []
        self.results: dict = {}
    
    @workflow.run
    async def run(self, input: OrderInput) -> OrderResult:
        """Execute state machine."""
        
        while self.state not in [SagaState.COMPLETED, SagaState.COMPENSATED, SagaState.FAILED]:
            next_state = await self._execute_state(input)
            self.history.append(self.state)
            self.state = next_state
            
            workflow.logger.info(f"State transition: {self.history[-1]} -> {self.state}")
        
        return OrderResult(
            order_id=input.order_id,
            status=self.state.value,
            tracking_number=self.results.get("tracking_number")
        )
    
    async def _execute_state(self, input: OrderInput) -> SagaState:
        """Execute current state and return next state."""
        
        if self.state == SagaState.INITIALIZED:
            return SagaState.RESERVING_INVENTORY
        
        elif self.state == SagaState.RESERVING_INVENTORY:
            try:
                result = await workflow.execute_activity(
                    reserve_inventory,
                    args=[input.order_id, input.items],
                    start_to_close_timeout=timedelta(minutes=5)
                )
                self.results["reservation_id"] = result["reservation_id"]
                return SagaState.INVENTORY_RESERVED
            except Exception:
                return SagaState.FAILED
        
        elif self.state == SagaState.INVENTORY_RESERVED:
            return SagaState.CHARGING_PAYMENT
        
        elif self.state == SagaState.CHARGING_PAYMENT:
            try:
                result = await workflow.execute_activity(
                    charge_payment,
                    args=[input.customer_id, input.total_amount, f"order-{input.order_id}"],
                    start_to_close_timeout=timedelta(minutes=5)
                )
                self.results["payment_id"] = result["payment_id"]
                return SagaState.PAYMENT_CHARGED
            except Exception:
                return SagaState.COMPENSATING
        
        elif self.state == SagaState.PAYMENT_CHARGED:
            return SagaState.CREATING_SHIPMENT
        
        elif self.state == SagaState.CREATING_SHIPMENT:
            # This is the pivot - retry forever
            result = await workflow.execute_activity(
                create_shipment,
                args=[input.order_id, input.shipping_address, input.items],
                start_to_close_timeout=timedelta(minutes=10),
                retry_policy=RetryPolicy(maximum_attempts=0)
            )
            self.results["tracking_number"] = result["tracking_number"]
            return SagaState.SHIPMENT_CREATED
        
        elif self.state == SagaState.SHIPMENT_CREATED:
            return SagaState.COMPLETING
        
        elif self.state == SagaState.COMPLETING:
            await workflow.execute_activity(
                send_notification,
                args=[input.customer_id, "order_confirmation", self.results],
                start_to_close_timeout=timedelta(minutes=1)
            )
            return SagaState.COMPLETED
        
        elif self.state == SagaState.COMPENSATING:
            await self._run_compensation(input)
            return SagaState.COMPENSATED
        
        return SagaState.FAILED
    
    async def _run_compensation(self, input: OrderInput):
        """Run compensation based on history."""
        # Compensate in reverse order
        for state in reversed(self.history):
            if state == SagaState.CHARGING_PAYMENT and "payment_id" in self.results:
                await workflow.execute_activity(
                    refund_payment,
                    args=[self.results["payment_id"], f"refund-{input.order_id}"],
                    start_to_close_timeout=timedelta(minutes=5),
                    retry_policy=RetryPolicy(maximum_attempts=0)
                )
            
            elif state == SagaState.RESERVING_INVENTORY and "reservation_id" in self.results:
                await workflow.execute_activity(
                    release_inventory,
                    args=[self.results["reservation_id"]],
                    start_to_close_timeout=timedelta(minutes=5),
                    retry_policy=RetryPolicy(maximum_attempts=0)
                )
    
    @workflow.query
    def get_state(self) -> dict:
        """Query current state machine status."""
        return {
            "current_state": self.state.value,
            "history": [s.value for s in self.history],
            "results": self.results
        }
Your turn · checkpoint 3not answered

Deploying a new step under running workflows

Thousands of order workflows are in flight. You insert a fraud-check activity before the charge and deploy. A worker then replays a workflow that had already charged the card under the old code. What goes wrong? And what must the new code do differently for old and new executions?

Think it through, or jot it down. Then compare.

One strong answer

  • What breaks: replay runs the new code, which now schedules a fraud check where the recorded history shows the charge. The decisions no longer match the history, so replay fails with a non-determinism error.
  • The fix: branch on a version marker. Executions whose history was recorded without the change take the old path; new executions (and ones reaching that point after the deploy) take the new one.
  • Why: workflow code must make the same decisions on replay as it made originally.

4.2 Workflow Versioning#

pythonVersionedOrderWorkflow · 66 lines
# Workflow Versioning for Safe Deployments

@workflow.defn
class VersionedOrderWorkflow:
    """
    Workflow with versioning for safe deployments.
    
    When you need to change workflow logic, use versioning
    to ensure in-flight workflows use old code while
    new workflows use new code.
    """
    
    @workflow.run
    async def run(self, input: OrderInput) -> OrderResult:
        # Reserve inventory (unchanged)
        reservation = await workflow.execute_activity(
            reserve_inventory,
            args=[input.order_id, input.items],
            start_to_close_timeout=timedelta(minutes=5)
        )
        
        # Version 1: Original flow - charge then ship
        # Version 2: New flow - fraud check before charge
        
        version = workflow.patched("fraud-check-v2")
        
        if version:
            # New code path: Add fraud check
            fraud_result = await workflow.execute_activity(
                check_fraud,
                args=[input.customer_id, input.total_amount],
                start_to_close_timeout=timedelta(minutes=2)
            )
            
            if fraud_result["risk_score"] > 0.8:
                # High risk - require manual review
                await workflow.wait_condition(
                    lambda: self.fraud_approved is not None,
                    timeout=timedelta(hours=24)
                )
                
                if not self.fraud_approved:
                    await self._compensate(input, reservation)
                    return OrderResult(
                        order_id=input.order_id,
                        status="rejected_fraud"
                    )
        
        # Continue with payment (both versions)
        payment = await workflow.execute_activity(
            charge_payment,
            args=[input.customer_id, input.total_amount, f"order-{input.order_id}"],
            start_to_close_timeout=timedelta(minutes=5)
        )
        
        # Version 3: Add loyalty points
        version_loyalty = workflow.patched("loyalty-points-v3")
        
        if version_loyalty:
            await workflow.execute_activity(
                award_loyalty_points,
                args=[input.customer_id, input.total_amount],
                start_to_close_timeout=timedelta(minutes=1)
            )
        
        # Continue with shipment...
Version behavior

Existing workflows

History already past this point before deploy

  • workflow.patched("fraud-check-v2") returns False on replay
  • Uses old code path (no fraud check)
  • Completes with original logic

New workflows

And old ones reaching this point after deploy

  • workflow.patched("fraud-check-v2") returns True
  • Uses new code path (with fraud check)
  • Gets new functionality

This allows safe deployment without breaking in-flight workflows.

Your turn · checkpoint 4not answered

How long do billing retries last?

A monthly charge activity uses a retry policy with a 1-hour initial interval, a backoff coefficient of 2 and at most 5 attempts. If every attempt fails, how long passes between the first attempt and the last? Why use hours here when the order workflow retried after one second?

Think it through, or jot it down. Then compare.

One strong answer

  • 5 attempts means 4 waits: 1 + 2 + 4 + 8 = 15 hours of retrying before the activity gives up.
  • Hours, not seconds: a declined or failing card won't recover in seconds; spacing retries over most of a day gives the customer or bank time to fix it, without hammering the payment provider.
  • And the workflow can afford it: the wait is durable timers, not a blocked thread.

4.3 Long-Running Workflows with Timers#

pythonSubscriptionWorkflow · 132 lines
# Long-Running Subscription Workflow

@workflow.defn
class SubscriptionWorkflow:
    """
    Subscription lifecycle workflow.
    
    This workflow can run for YEARS:
    - Handles monthly billing
    - Processes upgrades/downgrades
    - Manages cancellation
    """
    
    def __init__(self):
        self.status = "active"
        self.current_plan = None
        self.next_billing_date = None
        self.cancelled = False
        self.upgrade_request = None
    
    @workflow.run
    async def run(self, customer_id: str, initial_plan: str) -> dict:
        """Main subscription loop."""
        self.current_plan = initial_plan
        self.next_billing_date = workflow.now() + timedelta(days=30)
        
        while not self.cancelled:
            # Wait until next billing date OR a signal arrives
            try:
                await workflow.wait_condition(
                    lambda: self.cancelled or self.upgrade_request is not None,
                    timeout=self._time_until_billing()
                )
            except asyncio.TimeoutError:
                # Billing time!
                pass
            
            # Check for cancellation
            if self.cancelled:
                break
            
            # Check for upgrade/downgrade
            if self.upgrade_request:
                await self._process_plan_change()
                self.upgrade_request = None
                continue
            
            # Process billing
            await self._process_billing(customer_id)
            self.next_billing_date = workflow.now() + timedelta(days=30)
        
        # Subscription ended
        await self._process_cancellation(customer_id)
        
        return {
            "status": "cancelled",
            "final_billing_date": str(self.next_billing_date)
        }
    
    async def _process_billing(self, customer_id: str):
        """Process monthly billing."""
        plan_details = await workflow.execute_activity(
            get_plan_details,
            args=[self.current_plan],
            start_to_close_timeout=timedelta(minutes=1)
        )
        
        try:
            await workflow.execute_activity(
                charge_subscription,
                args=[customer_id, plan_details["price"], f"sub-{workflow.info().workflow_id}-{workflow.now().isoformat()}"],
                start_to_close_timeout=timedelta(minutes=5),
                retry_policy=RetryPolicy(
                    maximum_attempts=5,
                    initial_interval=timedelta(hours=1)  # Waits 1+2+4+8h: retries span ~15 hours
                )
            )
            self.status = "active"
            
        except Exception as e:
            self.status = "payment_failed"
            # Could implement grace period, dunning, etc.
    
    async def _process_plan_change(self):
        """Process plan upgrade/downgrade."""
        new_plan = self.upgrade_request
        
        # Prorate current billing period
        await workflow.execute_activity(
            prorate_subscription,
            args=[self.current_plan, new_plan, self.next_billing_date],
            start_to_close_timeout=timedelta(minutes=5)
        )
        
        self.current_plan = new_plan
    
    async def _process_cancellation(self, customer_id: str):
        """Process subscription cancellation."""
        await workflow.execute_activity(
            send_notification,
            args=[customer_id, "subscription_cancelled", {
                "plan": self.current_plan
            }],
            start_to_close_timeout=timedelta(minutes=1)
        )
    
    def _time_until_billing(self) -> timedelta:
        """Calculate time until next billing."""
        now = workflow.now()
        if self.next_billing_date > now:
            return self.next_billing_date - now
        return timedelta(seconds=0)
    
    @workflow.signal
    async def cancel(self):
        """Signal: Cancel subscription."""
        self.cancelled = True
    
    @workflow.signal
    async def change_plan(self, new_plan: str):
        """Signal: Change subscription plan."""
        self.upgrade_request = new_plan
    
    @workflow.query
    def get_subscription_status(self) -> dict:
        """Query: Get current subscription status."""
        return {
            "status": self.status,
            "plan": self.current_plan,
            "next_billing": str(self.next_billing_date),
            "cancelled": self.cancelled
        }
Quick check4 questions · +5 XP each

Durable execution and Temporal workflows

  1. Q1/4 A worker crashes after reserve_inventory completed and charge_payment was scheduled. What happens when another worker picks the workflow up?

    Answer & explanation

    Answer: A

    Durable execution is event sourcing: the code is replayed, and completed activities are answered from history instead of executed again. There is no saved instruction pointer; replay plus recorded results is what reconstructs where the workflow was.

  2. Q2/4 A developer uses datetime.now() inside workflow code to decide whether a promo discount applies. What goes wrong?

    Answer & explanation

    Answer: C

    Workflow code must make the same decisions on every replay; a different branch raises a non-determinism error. Use workflow.now(), which is consistent with the recorded history. Clock sync does not help: replay may happen hours later, so the real current time is simply different.

  3. Q3/4 Orders over $1000 must wait up to 24 hours for a manager's approval. Which design matches Temporal's model?

    Answer & explanation

    Answer: B

    Signals carry external events into the workflow, which can wait for days without consuming resources; the timeout turns silence into an auto-reject. Polling activities and long-held connections are exactly the "doesn't scale" problems durable execution removes.

  4. Q4/4 You add a fraud-check activity to a workflow that has thousands of in-flight executions. Why wrap it in workflow.patched("fraud-check-v2") instead of just inserting the call?

    Answer & explanation

    Answer: D

    Changing workflow logic changes the decisions made during replay. patched returns False when replaying history recorded without the patch, and True for new executions. New workflows would pick up new code anyway; the risk is entirely to executions that were already running.

Part II · Stage 2 of 4 · 5 min

Part II: Production Implementation#

Chapter 5: Worker Setup and Configuration#

pythonWorkerConfig · 121 lines
# Production Worker Configuration

import asyncio
from temporalio.client import Client
from temporalio.worker import Worker
from temporalio.worker.workflow_sandbox import SandboxedWorkflowRunner
import logging

logger = logging.getLogger(__name__)


class WorkerConfig:
    """Configuration for Temporal workers."""
    
    def __init__(
        self,
        temporal_host: str = "localhost:7233",
        namespace: str = "default",
        task_queue: str = "orders",
        max_concurrent_activities: int = 100,
        max_concurrent_workflow_tasks: int = 100,
        sticky_queue_schedule_to_start_timeout: int = 10,
    ):
        self.temporal_host = temporal_host
        self.namespace = namespace
        self.task_queue = task_queue
        self.max_concurrent_activities = max_concurrent_activities
        self.max_concurrent_workflow_tasks = max_concurrent_workflow_tasks
        self.sticky_queue_schedule_to_start_timeout = sticky_queue_schedule_to_start_timeout


async def run_worker(config: WorkerConfig):
    """Run a Temporal worker."""
    
    # Connect to Temporal
    client = await Client.connect(
        config.temporal_host,
        namespace=config.namespace
    )
    
    # Create worker
    worker = Worker(
        client,
        task_queue=config.task_queue,
        
        # Register workflows
        workflows=[
            OrderWorkflow,
            OrderWithApprovalWorkflow,
            SubscriptionWorkflow,
            BatchOrderWorkflow,
        ],
        
        # Register activities
        activities=[
            reserve_inventory,
            release_inventory,
            charge_payment,
            refund_payment,
            create_shipment,
            send_notification,
            check_fraud,
            award_loyalty_points,
        ],
        
        # Concurrency limits
        max_concurrent_activities=config.max_concurrent_activities,
        max_concurrent_workflow_tasks=config.max_concurrent_workflow_tasks,
        
        # Sticky execution (workflow affinity)
        sticky_queue_schedule_to_start_timeout=timedelta(
            seconds=config.sticky_queue_schedule_to_start_timeout
        ),
    )
    
    logger.info(f"Starting worker on queue: {config.task_queue}")
    
    # Run worker until interrupted
    await worker.run()


async def run_activity_worker(config: WorkerConfig):
    """
    Run activity-only worker.
    
    Useful for scaling activities independently from workflows.
    """
    client = await Client.connect(
        config.temporal_host,
        namespace=config.namespace
    )
    
    worker = Worker(
        client,
        task_queue=config.task_queue,
        
        # Only activities, no workflows
        activities=[
            reserve_inventory,
            release_inventory,
            charge_payment,
            refund_payment,
            create_shipment,
            send_notification,
        ],
        
        max_concurrent_activities=config.max_concurrent_activities,
    )
    
    await worker.run()


# Entry point
if __name__ == "__main__":
    config = WorkerConfig(
        temporal_host=os.getenv("TEMPORAL_HOST", "localhost:7233"),
        namespace=os.getenv("TEMPORAL_NAMESPACE", "default"),
        task_queue=os.getenv("TASK_QUEUE", "orders"),
    )
    
    asyncio.run(run_worker(config))
WorksheetRunning the order workflow at peak (traffic and timings are assumptions)
Value
Orders at peak (assumed) 500 a second
Activities per order (reserve, charge, ship, notify) 4 → 2,000 activity tasks a second
History events per order (assumed ~30: start, end, about 3 per activity and per workflow task) ~30 → ~15,000 events a second persisted
Average time an activity holds a worker slot (assumed) 200 ms → 2,000 × 0.2 = ~400 busy slots
Workers at max_concurrent_activities=100 4 at full load; run 6–8 for headroom and failures
Orders over $1,000 waiting up to 24 h for approval (assumed 1%) 5 a second × 86,400 s ≈ ~430,000 waiting workflows

Conclusion: the hundreds of thousands of waiting workflows cost stored history, not threads or connections. What you scale is activity worker slots, and the persistence layer's write rate of about 15,000 events a second.

Chapter 6: Client Usage#

pythonOrderService · 142 lines
# Production Client Usage

from temporalio.client import Client, WorkflowHandle
from temporalio.common import WorkflowIDReusePolicy
import asyncio


class OrderService:
    """
    Service for managing orders via Temporal workflows.
    """
    
    def __init__(self, temporal_client: Client):
        self.client = temporal_client
    
    async def create_order(self, input: OrderInput) -> str:
        """
        Start a new order workflow.
        
        Returns workflow ID for tracking.
        """
        # Use order ID as workflow ID for idempotency
        workflow_id = f"order-{input.order_id}"
        
        handle = await self.client.start_workflow(
            OrderWorkflow.run,
            input,
            id=workflow_id,
            task_queue="orders",
            
            # Prevent duplicate orders
            id_reuse_policy=WorkflowIDReusePolicy.REJECT_DUPLICATE,
            
            # Execution timeout (entire workflow)
            execution_timeout=timedelta(hours=24),
            
            # Run timeout (single run, before continue-as-new)
            run_timeout=timedelta(hours=1),
        )
        
        return workflow_id
    
    async def get_order_status(self, order_id: str) -> dict:
        """
        Query order status.
        
        Non-blocking - returns current state without waiting.
        """
        workflow_id = f"order-{order_id}"
        
        try:
            handle = self.client.get_workflow_handle(workflow_id)
            status = await handle.query(OrderWorkflow.get_status)
            tracking = await handle.query(OrderWorkflow.get_tracking_number)
            
            return {
                "order_id": order_id,
                "status": status,
                "tracking_number": tracking
            }
            
        except Exception as e:
            return {
                "order_id": order_id,
                "status": "unknown",
                "error": str(e)
            }
    
    async def wait_for_completion(
        self,
        order_id: str,
        timeout: timedelta = timedelta(minutes=30)
    ) -> OrderResult:
        """
        Wait for order to complete.
        
        Blocking - waits until workflow finishes.
        """
        workflow_id = f"order-{order_id}"
        
        handle = self.client.get_workflow_handle(workflow_id)
        
        try:
            result = await asyncio.wait_for(
                handle.result(),
                timeout=timeout.total_seconds()
            )
            return result
        except asyncio.TimeoutError:
            raise TimeoutError(f"Order {order_id} did not complete in {timeout}")
    
    async def cancel_order(self, order_id: str):
        """
        Cancel an order.
        
        Sends cancellation signal to workflow.
        """
        workflow_id = f"order-{order_id}"
        
        handle = self.client.get_workflow_handle(workflow_id)
        await handle.cancel()
    
    async def approve_order(self, order_id: str, approver: str):
        """Send approval signal to order waiting for approval."""
        workflow_id = f"order-{order_id}"
        
        handle = self.client.get_workflow_handle(workflow_id)
        await handle.signal(OrderWithApprovalWorkflow.approve, approver)


# FastAPI integration
from fastapi import FastAPI, HTTPException

app = FastAPI()
order_service: OrderService = None


@app.on_event("startup")
async def startup():
    global order_service
    client = await Client.connect("localhost:7233")
    order_service = OrderService(client)


@app.post("/orders")
async def create_order(request: OrderInput):
    """Create a new order."""
    workflow_id = await order_service.create_order(request)
    return {"workflow_id": workflow_id, "status": "started"}


@app.get("/orders/{order_id}")
async def get_order(order_id: str):
    """Get order status."""
    return await order_service.get_order_status(order_id)


@app.post("/orders/{order_id}/approve")
async def approve_order(order_id: str, approver: str):
    """Approve an order."""
    await order_service.approve_order(order_id, approver)
    return {"status": "approved"}
Quick check3 questions · +5 XP each

Workers and clients in production

  1. Q1/3 A client times out on POST /orders and retries the same order. The service starts workflows with ID order-{order_id} and REJECT_DUPLICATE. What happens?

    Answer & explanation

    Answer: B

    Using the business ID as the workflow ID makes starting idempotent; the duplicate start is refused, not executed. Relying only on downstream idempotency keys would still run two sagas, each reserving inventory.

  2. Q2/3 The order-status page refreshes every few seconds. Which client call should it use?

    Answer & explanation

    Answer: D

    Queries are non-blocking reads of workflow state, ideal for status pages. result() blocks until the workflow finishes, which could be hours, and signals change state rather than read it.

  3. Q3/3 Workflow tasks are processed quickly, but activity tasks are piling up because the shipping API is slow. What is the most direct fix?

    Answer & explanation

    Answer: A

    Task queues decouple scheduling from execution, so activity capacity can be scaled independently of workflow capacity. More workflow-task concurrency does nothing for the backed-up activity queue, and side effects must never move into workflow code.

Part III · Stage 3 of 4 · 5 min

Part III: Real-World Application#

Chapter 7: Case Studies#

7.1 Case Study: Uber's Cadence (Temporal's Origin)#

Uber Cadence usage

Uber built Cadence (Temporal is a fork of it by Cadence's creators). Illustrative use cases:

  1. Trip processing
    • Match rider to driver
    • Track driver arrival
    • Handle trip completion
    • Process payment
    • Handle disputes
  2. Driver onboarding
    • Background check (days)
    • Document verification (hours)
    • Training completion (weeks)
    • Vehicle inspection (scheduled)
    • Activation
  3. Surge pricing
    • Monitor demand in real-time
    • Calculate pricing zones
    • Apply time-limited surges
    • Automatic expiration
Scale at Uber
  • ~12 billion workflow executions and ~270 billion actions per month (Uber, Cadence 1.0 announcement): ≈4,600 executions/s, ≈100,000 actions/s
  • Cadence supports workflows that run for months
  • Cadence is built for low-latency activity execution
  • Cadence supports cross-datacenter replication
Key learnings
  • Workflow-per-entity pattern (one workflow per trip, per driver)
  • Signals for external events (rider cancelled, driver arrived)
  • Queries for real-time status
  • Continue-as-new for long-running workflows
Your turn · checkpoint 5not answered

JSON definitions or code?

Two jobs: (a) a content pipeline of fixed steps (transcode in parallel, make thumbnails, check quality, publish) that operations staff should be able to read and edit; (b) a loan approval with branching rules, human approval signals, week-long waits and status queries. Would you define each as a JSON workflow definition or as workflow code? What do you give up each way?

Think it through, or jot it down. Then compare.

One strong answer

  • (a) JSON definitions (Conductor's style): a fixed graph of tasks is easy to read, edit and visualise without a deploy. You give up expressiveness, which this pipeline doesn't need.
  • (b) Code (Temporal's style): arbitrary branching, signals and queries are natural in code. You give up the simple declarative graph, and you take on determinism rules and versioning.
  • Rule of thumb: declarative for simple orchestration, code for complex business logic.

7.2 Case Study: Netflix Conductor#

Netflix Conductor

Netflix's workflow orchestration (different from Temporal):

Architecture
  • JSON-based workflow definitions
  • REST API for operations
  • Pluggable task workers
  • Elasticsearch for search
Use cases at Netflix
  1. Content onboarding
    • Upload raw video
    • Transcode to multiple formats (parallel)
    • Generate thumbnails
    • Quality check
    • Metadata enrichment
    • Publish to CDN
  2. Data pipelines
    • ETL workflows
    • ML model training
    • Report generation
    • Analytics
  3. Infrastructure automation
    • Server provisioning
    • Deployment pipelines
    • Chaos engineering tests
    • Incident response
Conductor vs Temporal
Conductor Temporal
JSON workflow definitions Code-based workflow definitions
REST-based gRPC-based
Simpler, less expressive (JSON DSL; has sub-workflows and wait tasks) More expressive (arbitrary code, signals, queries)
Good for microservices orchestration Better for complex business logic

7.3 Case Study: Stripe Payments#

Complex payment flows benefit from workflow orchestration. (Illustrative design: Stripe's real PaymentIntent statuses are requires_payment_method, requires_confirmation, requires_action, processing, requires_capture, succeeded and canceled.)

DiagramSimplified payment state machine
%% title: Simplified payment state machine
stateDiagram-v2
  [*] --> Created
  Created --> RequiresAction
  Created --> Processing
  RequiresAction --> Processing
  Processing --> Succeeded
  Created --> Canceled
  Processing --> Failed
  RequiresAction --> Canceled
  Succeeded --> Refunded
  RequiresAction: Requires Action
  Refunded: Refunded (partial)
  class Created,Processing core
  class RequiresAction queue
  class Succeeded ok
  class Canceled,Failed bad
  class Refunded queue
Activities in payment workflow
  1. Fraud Check
    • Call ML model for fraud score
    • Apply rules engine
    • Decision: approve, review, block
  2. Card Network Authorization
    • Call card network (Visa, Mastercard)
    • Handle 3DS if required
    • Store authorization code
  3. Capture
    • Settle authorized transaction
    • Transfer funds
    • Update ledger
  4. Refund
    • Validate refund amount
    • Reverse settlement
    • Update ledger
Durable execution benefits
  • 3DS waits for customer action (minutes/hours)
  • Retry network failures automatically
  • Audit trail of every step
  • Easy investigation of failed payments

Chapter 8: Common Mistakes#

8.1 Mistake 1: Non-Deterministic Workflow Code#

pythonBadWorkflow · 45 lines
❌ WRONG: Non-deterministic workflow

@workflow.defn
class BadWorkflow:
    @workflow.run
    async def run(self, input: dict) -> dict:
        # DON'T: Use random in workflow
        if random.random() > 0.5:
            await self.do_something()
        
        # DON'T: Use current time directly
        timestamp = datetime.now()
        
        # DON'T: Use UUID directly
        id = str(uuid.uuid4())
        
        # DON'T: Make HTTP calls in workflow
        response = await httpx.get("https://api.example.com/data")
        
        # On replay, these will produce DIFFERENT values
        # causing non-determinism errors!


✅ CORRECT: Deterministic workflow

@workflow.defn
class GoodWorkflow:
    @workflow.run
    async def run(self, input: dict) -> dict:
        # DO: Use workflow.random()
        if workflow.random().random() > 0.5:
            await self.do_something()
        
        # DO: Use workflow.now()
        timestamp = workflow.now()
        
        # DO: Use workflow.uuid4()
        id = str(workflow.uuid4())
        
        # DO: Use activities for side effects
        response = await workflow.execute_activity(
            fetch_data,
            args=["https://api.example.com/data"],
            start_to_close_timeout=timedelta(minutes=1)
        )

8.2 Mistake 2: Unbounded Workflow History#

pythonBadSubscriptionWorkflow · 39 lines
❌ WRONG: Workflow that grows forever

@workflow.defn
class BadSubscriptionWorkflow:
    @workflow.run
    async def run(self, customer_id: str) -> None:
        while True:
            await workflow.sleep(timedelta(days=30))
            
            # Each iteration adds events to history, without bound
            # Replays get slower, and Temporal eventually terminates the run
            # (history limit: 51,200 events or 50 MB; warnings from 10K events)
            
            await workflow.execute_activity(
                charge_monthly,
                args=[customer_id],
                start_to_close_timeout=timedelta(minutes=5)
            )


✅ CORRECT: Use continue-as-new

@workflow.defn
class GoodSubscriptionWorkflow:
    @workflow.run
    async def run(self, customer_id: str, billing_count: int = 0) -> None:
        while billing_count < 12:  # One year max
            await workflow.sleep(timedelta(days=30))
            
            await workflow.execute_activity(
                charge_monthly,
                args=[customer_id],
                start_to_close_timeout=timedelta(minutes=5)
            )
            
            billing_count += 1
        
        # Continue with fresh history
        workflow.continue_as_new(args=[customer_id, 0])

8.3 Mistake 3: Missing Activity Timeouts#

pythonBadWorkflow · 43 lines
❌ WRONG: No timeouts on activities

@workflow.defn
class BadWorkflow:
    @workflow.run
    async def run(self, input: dict) -> dict:
        # No timeout - the SDK rejects this (start_to_close or schedule_to_close
        # is required); an over-generous timeout is the real-world version of this bug
        result = await workflow.execute_activity(
            call_external_api,
            args=[input]
        )


✅ CORRECT: Always set appropriate timeouts

@workflow.defn
class GoodWorkflow:
    @workflow.run
    async def run(self, input: dict) -> dict:
        result = await workflow.execute_activity(
            call_external_api,
            args=[input],
            
            # Schedule-to-start: Time in task queue
            schedule_to_start_timeout=timedelta(minutes=5),
            
            # Start-to-close: Time for activity to complete
            start_to_close_timeout=timedelta(minutes=10),
            
            # Schedule-to-close: Total time from schedule to completion
            schedule_to_close_timeout=timedelta(minutes=15),
            
            # Heartbeat timeout for long activities
            heartbeat_timeout=timedelta(minutes=1),
            
            # Retry policy
            retry_policy=RetryPolicy(
                initial_interval=timedelta(seconds=1),
                maximum_interval=timedelta(minutes=1),
                maximum_attempts=5
            )
        )

8.4 Mistake Checklist#

8.4 Mistake Checklist0/7 ready
  • Workflow code is deterministic — No random, time, UUID, HTTP calls
  • Long workflows use continue-as-new — Prevent history bloat
  • All activities have timeouts — Never hang forever
  • Compensation activities retry forever — Must eventually succeed
  • Workflow IDs are idempotent — Use business IDs (order-123)
  • Signals are idempotent — Handle duplicate signals gracefully
  • Use versioning for changes — Don't break in-flight workflows
Quick check3 questions · +5 XP each

Temporal in practice and common mistakes

  1. Q1/3 A subscription workflow loops forever: sleep 30 days, charge, repeat. Its history grows without bound, so replays slow down and it will eventually hit Temporal's history limit. What is the fix?

    Answer & explanation

    Answer: C

    Every iteration appends events to the history, which is replayed on recovery. Continue-as-new bounds history size. A parent that loops forever still grows its own history, even if the children are short.

  2. Q2/3 charge_payment hits its start-to-close timeout, but the provider actually processed the charge. The retry policy runs the activity again. What prevents a double charge?

    Answer & explanation

    Answer: B

    Recorded results only exist for activities that completed. A timed-out activity has no result, so it is retried, and the external call must be idempotent. The workflow ID policy prevents duplicate workflows, not duplicate activity attempts within one workflow.

  3. Q3/3 A video-transcoding activity normally runs about 40 minutes. You want a crashed worker detected within about a minute, not after the full start-to-close timeout. What do you set?

    Answer & explanation

    Answer: D

    Heartbeats let Temporal notice a silent long-running activity quickly and retry it elsewhere. Schedule-to-start only covers time waiting in the queue, and unlimited retries do nothing to detect the crash sooner.

Part IV · Stage 4 of 4 · 6 min

Part IV: Interview Preparation#

Chapter 9: Interview Tips#

❯ Say this

9.1 Key Phrases#

Key phrases
Introducing workflow orchestration

"For this complex saga, I'd use a workflow orchestration engine like Temporal. It provides durable execution — the workflow survives any crash, automatically retries failures, and can wait for external events for years if needed."

Explaining durable execution

"Durable execution means every step is recorded. If a worker crashes mid-workflow, a new worker picks up from exactly where it left off. Completed activities aren't re-executed — their results are replayed from the event history."

On determinism

"Workflow code must be deterministic because it replays on recovery. You can't use random numbers, current time, or make HTTP calls directly in workflow code. Those need to be in activities, which are recorded and replayed."

On long-running workflows

"For workflows that run for months or years — like subscriptions — I'd use continue-as-new to keep the event history bounded. After N billing cycles, the workflow starts fresh with new history."

On signals and queries

"External events come in as signals — like a manager approving an order. The workflow waits for the signal, then continues. Queries let external systems check workflow state without blocking."

9.2 Common Questions#

Question Good Answer
"How does Temporal handle crashes?" "Every activity completion is recorded in Temporal's database. On crash, the workflow replays from the beginning, but completed activities return their stored results instead of re-executing. The workflow continues from where it left off."
"What's the difference between workflows and activities?" "Workflows are deterministic orchestration code — they decide what to do next. Activities are where actual work happens — API calls, database operations. Workflows must be deterministic for replay; activities can have side effects."
"How do you handle workflows that run for years?" "Use continue-as-new to prevent unbounded history growth. After N iterations, the workflow completes and starts a new execution with fresh history, passing along necessary state."
"When would you use Temporal vs custom saga orchestrator?" "Custom orchestrators work for simple sagas. Temporal shines for complex workflows — long-running, waiting for external events, versioning, visibility. The operational burden of running Temporal is worth it for non-trivial workflows."

Chapter 10: Practice Problems#

Practice · problem 1

Problem 1: Travel Booking with Temporal#

Setup: Design a travel booking workflow using Temporal that books flight + hotel + car.

Requirements:

  • Flight has immediate payment
  • Hotel has pre-authorization
  • Car is free cancellation
  • User can modify booking for 24 hours

Questions:

  1. What's the workflow structure?
  2. How do you handle the 24-hour modification window?
  3. What happens if the hotel pre-auth expires?
Solution Outline
pythonTravelBookingWorkflow · 31 lines
@workflow.defn
class TravelBookingWorkflow:
    @workflow.run
    async def run(self, booking: TravelBooking) -> BookingResult:
        # Book in order of cancellation difficulty
        car = await workflow.execute_activity(book_car, booking.car)
        hotel_auth = await workflow.execute_activity(preauth_hotel, booking.hotel)
        flight = await workflow.execute_activity(book_flight, booking.flight)  # PIVOT
        
        # Wait 24 hours for modifications
        try:
            await workflow.wait_condition(
                lambda: self.modification_request is not None,
                timeout=timedelta(hours=24)
            )
            if self.modification_request:
                await self._handle_modification()
        except asyncio.TimeoutError:
            pass  # No modifications, proceed
        
        # Capture hotel payment
        hotel = await workflow.execute_activity(
            capture_hotel,
            hotel_auth["auth_id"]
        )
        
        return BookingResult(flight=flight, hotel=hotel, car=car)
    
    @workflow.signal
    async def modify(self, modification: dict):
        self.modification_request = modification

Hotel pre-auth expiration: Set workflow timeout before pre-auth expires. If workflow hasn't completed, compensate and notify user.

Practice · problem 2

Problem 2: Content Moderation Pipeline#

Setup: Design a content moderation workflow for a social media platform.

Requirements:

  • Automatic ML moderation (fast)
  • Human review queue for edge cases
  • Appeals process
  • SLA: 4 hours for human review

Questions:

  1. How do you model the review queue?
  2. How do you handle the SLA?
  3. What signals does the workflow need?
Solution Outline
pythonContentModerationWorkflow · 49 lines
@workflow.defn
class ContentModerationWorkflow:
    @workflow.run
    async def run(self, content_id: str) -> ModerationResult:
        # Step 1: ML moderation
        ml_result = await workflow.execute_activity(
            ml_moderate,
            content_id,
            start_to_close_timeout=timedelta(minutes=1)
        )
        
        if ml_result["confidence"] > 0.95:
            return ModerationResult(
                decision=ml_result["decision"],
                method="automatic"
            )
        
        # Step 2: Queue for human review
        await workflow.execute_activity(
            add_to_review_queue,
            content_id,
            ml_result
        )
        
        # Wait for human decision with SLA
        try:
            await workflow.wait_condition(
                lambda: self.human_decision is not None,
                timeout=timedelta(hours=4)
            )
        except asyncio.TimeoutError:
            # SLA breached - escalate
            await workflow.execute_activity(escalate_to_manager, content_id)
            await workflow.wait_condition(lambda: self.human_decision is not None)
        
        return ModerationResult(
            decision=self.human_decision,
            method="human_review"
        )
    
    @workflow.signal
    async def human_review_complete(self, decision: str, reviewer: str):
        self.human_decision = decision
        self.reviewer = reviewer
    
    @workflow.signal
    async def appeal(self, reason: str):
        self.appeal_requested = True
        self.appeal_reason = reason

Chapter 11: Sample Interview Dialogue#

Scenario: Design a Loan Application System#

Transcript
Interviewer

"Design a loan application processing system. Applications need credit check, income verification, and manager approval for large amounts."

You

"This sounds like a complex workflow with external dependencies and human approval. I'd use Temporal for durable execution. Let me sketch the workflow..."

You draw on the whiteboard:

Loan Application Workflow
  1. Validate Application (activity)
  2. Credit Check (activity - external API)
  3. Income Verification (activity - may take days)
  4. Risk Assessment (activity - ML model)
  5. If amount > $50K: Wait for Manager Approval (signal)
  6. Decision: Approve or Reject
  7. If Approved: Fund Disbursement (activity)
  8. Notification (activity)
Transcript
You

"Each step is an activity except the manager approval, which is a signal. The workflow can wait for days for income verification or manager approval without holding resources."

Interviewer

"What if income verification takes a week?"

You

"No problem with Temporal. The workflow sleeps — no resources consumed. When verification completes, an external system sends a signal, and the workflow continues.

python12 lines
# Wait for income verification
await workflow.execute_activity(
    request_income_verification,
    application_id,
    start_to_close_timeout=timedelta(minutes=5)
)

# Wait for result (up to 14 days)
await workflow.wait_condition(
    lambda: self.income_verified is not None,
    timeout=timedelta(days=14)
)

If we don't get verification in 14 days, we auto-reject with a timeout."

Interviewer

"How do you handle the manager approval?"

You

"Manager approval is a signal. The workflow waits for the signal, and a separate UI allows managers to approve or reject. I'd also set an SLA:

python12 lines
if application.amount > 50000:
    self.status = 'pending_approval'
    
    try:
        await workflow.wait_condition(
            lambda: self.approval_decision is not None,
            timeout=timedelta(hours=24)
        )
    except asyncio.TimeoutError:
        # Escalate to senior manager
        await workflow.execute_activity(escalate_approval, application_id)
        await workflow.wait_condition(lambda: self.approval_decision is not None)

The workflow handles escalation automatically if no response in 24 hours."

Interviewer

"What about the credit check failing?"

You

"Credit check failure isn't an error — it's a business outcome. If the external API fails, the activity retries. If the credit check passes but score is low, the workflow rejects the application. I'd separate infrastructure failures from business logic:

python14 lines
try:
    credit_result = await workflow.execute_activity(
        check_credit,
        application_id,
        retry_policy=RetryPolicy(maximum_attempts=5)
    )
except ActivityError:
    # Infrastructure failure - need manual review
    self.status = 'manual_review_required'
    return

if credit_result['score'] < 600:
    # Business rejection
    return LoanResult(status='rejected', reason='credit_score')

Summary#

Day 3 key takeaways
Durable execution
  • Code survives any crash
  • Activities recorded, not re-executed on replay
  • Workflows can run for years
  • Event sourcing for workflow state
Temporal architecture
  • Workflows: Deterministic orchestration logic
  • Activities: Actual work with side effects
  • Signals: External events into workflow
  • Queries: Non-blocking state inspection
  • Task queues: Decouple scheduling from execution
Determinism rules
  • No random, time, UUID in workflow code
  • Use workflow.random(), workflow.now(), workflow.uuid4()
  • All side effects in activities
  • Same input → same decisions
Advanced patterns
  • State machines for complex flows
  • Versioning for safe deployments
  • Continue-as-new for long-running workflows
  • Child workflows for parallelism
  • Signals for external events
Production concerns
  • Set appropriate timeouts on all activities
  • Use idempotency keys for external calls
  • Monitor workflow history size
  • Plan for versioning from the start
Default approach
  • Simple sagas: Custom orchestrator
  • Complex workflows: Temporal
  • Long-running: Temporal with continue-as-new
  • Human-in-the-loop: Temporal with signals

Further Reading#

Documentation#

Videos#

Books#

  • Book

    "Practical Process Automation" by Bernd Rücker

  • Book

    "Flow Architectures" by James Urquhart

Engineering Blogs#

this lesson in shorts · 2

play them all →

One idea from this lesson in about a minute each, made for your phone.

End of Day 03 · Saga Orchestration Deep Dive
Finish Day 3Stages clear as you read them (0/4 so far). Skipping ahead doesn't count: marking complete unlocks after ~7 more min of reading.

Go deeper · reading trail

all reading →

Real write-ups from the teams that built these systems. Each opens on the publisher's site in a new tab.

UberTalk · ~60 min

Conducting Better Business with Uber's Open Source Orchestration Tool, Cadence

2019
What
Uber's introduction to Cadence, with recorded talks from its creators Maxim Fateev and Samar Abbas.
Key idea
Workflow code runs as if it never fails: Cadence persists its state, queues and timers, so a process restart resumes the workflow where it was.
Why here
The ancestor of Temporal, the engine this lesson uses: durable execution as a replacement for hand-built saga state machines.
taught here: Durable execution
TemporalDocs · ~10 min

Temporal Workflow

What
Temporal's documentation of what a Workflow is and how it executes.
Key idea
Workflows are deterministic code whose event history is recorded; on failure a worker replays the history to rebuild state, while side effects live in Activities.
Why here
The rules behind this lesson's Temporal code: why workflow code must be deterministic and why I/O goes in activities.
taught here: Temporal workflows
TemporalTalk · ~14 min

Workflow Engine Design Principles with Temporal

Shawn Wang · 2021
What
An annotated summary of a Systems @Scale 2021 talk on designing a workflow engine from first principles.
Key idea
Any workflow engine needs task queues, durable timers, consistency between state and queues, and sharding; Temporal's design follows from those needs.
Why here
The Temporal architecture chapter from the inside: history, matching and timers as answers to concrete requirements.
taught here: Temporal architecture

New lessons by email

New lessons, side quests and case files as they ship, at most once a week. No spam, unsubscribe anytime.

Discussion · …

Public: everyone reading this lesson sees comments. Be kind and keep it on the material.

Sign in to ask a question or answer one. Reading stays open to everyone.
loading the discussion…