Chapter 37: Workflow Orchestration

Temporal, Airflow, State Machines, and Compensation Patterns

1. Why Workflow Orchestration

The Problem

Modern systems require coordinating multi-step processes that span services, involve human approvals, run for hours or days, and must handle failures gracefully. Consider these scenarios:

  • E-commerce order: Reserve inventory → charge payment → ship order → send notifications (each step can fail)
  • Data pipeline: Extract → transform → validate → load (with retries, scheduling, dependencies)
  • Document processing: Upload → OCR → classify → extract → validate → index (long-running, need progress tracking)
  • Account provisioning: Create user → provision resources → send credentials → wait for activation (hours/days, timeouts)
Interview Question: Why not just use a queue (SQS/RabbitMQ) to coordinate these workflows?

Answer: Queues handle single-step async work. Workflows need: state persistence across steps, retry logic per step, timeouts, conditional branching, parallel execution, compensation on failure, visibility into progress, and ability to pause/resume. A queue alone requires you to manually implement all this orchestration logic in your application code, which becomes error-prone and hard to maintain.

What Workflow Orchestration Provides

Core Capabilities

  • Durable execution: Process state survives restarts
  • Automatic retries: Per-step retry policies
  • Timeout handling: Step-level and workflow-level
  • Visibility: Track progress, debug failures
  • Compensation: Rollback on failure (saga pattern)

Advanced Features

  • Signals/events: External triggers, human approval
  • Scheduling: Cron-like, backfills
  • Parallelism: Fan-out/fan-in, dynamic branching
  • Versioning: Upgrade workflows without breaking in-flight instances
  • Testing: Time-travel, deterministic replay

Orchestration vs Choreography

Aspect Orchestration Choreography
Coordination Central orchestrator directs steps Services react to events independently
Visibility Single place to see workflow state Distributed, need tracing to reconstruct
Complexity Logic centralized, easier to reason about Logic distributed, harder to debug
Coupling Services coupled to orchestrator Services loosely coupled via events
Use case Complex workflows, approval flows, compensations Event-driven, simple reactions

2. Temporal

Temporal is a durable execution platform that guarantees workflows run to completion despite failures. It's the successor to AWS SWF and Uber's Cadence.

Core Concepts

  • Workflow: Durable function representing the orchestration logic (must be deterministic)
  • Activity: Individual task that can have side effects (API calls, DB writes)
  • Worker: Process that executes workflow/activity code
  • Task Queue: Named queue that routes work to workers
  • Event History: Complete log of workflow execution (enables replay)

Architecture

graph TB Client[Client App] Server[Temporal Server
Event History + Scheduler] Worker1[Worker Process
Workflow Code] Worker2[Worker Process
Activity Code] DB[(Cassandra/PostgreSQL
Event Storage)] Client -->|Start Workflow| Server Client -->|Signal/Query| Server Server -->|Workflow Tasks| Worker1 Server -->|Activity Tasks| Worker2 Worker1 -->|Execute & Return| Server Worker2 -->|Execute & Return| Server Server --> DB style Server fill:#4A90E2 style Worker1 fill:#7ED321 style Worker2 fill:#7ED321

Key Features

Automatic Retries

// Activity automatically retries on failure
ActivityOptions {
  startToCloseTimeout: 5min
  retryPolicy: {
    initialInterval: 1s
    maxInterval: 1min
    maxAttempts: 10
    backoffCoefficient: 2.0
  }
}

Timers & Signals

// Wait for approval or timeout
select {
  case approved := <-approvalSignal:
    // Continue workflow
  case <-sleep(24h):
    // Timeout, cancel order
}
How Temporal Guarantees Completion:
  1. Every workflow action is recorded as an event in persistent storage
  2. If a worker crashes mid-workflow, another worker picks it up
  3. The new worker replays the event history to reconstruct state
  4. Replay is deterministic (same inputs → same outputs)
  5. Workflow continues from where it left off

Signals, Queries, Updates

Mechanism Purpose Example
Signal Send async message to running workflow User clicks "Approve Order"
Query Read current workflow state (no side effects) Check order status
Update Synchronous signal with validation + response Change order address (validate before accepting)

3. Temporal Programming Model

Workflow Functions (Deterministic)

Workflows must be deterministic for replay to work. Rules:

  • No direct I/O (no HTTP calls, no DB access, no random numbers)
  • All side effects must be in Activities
  • Use workflow-provided time/random functions (deterministic)
  • No threading or async/await outside workflow APIs
// Order processing workflow (Python-style pseudocode)
@workflow.defn
class OrderWorkflow:
    def __init__(self):
        self.order_status = "pending"

    @workflow.run
    async def run(self, order_id: str) -> str:
        # Step 1: Reserve inventory (activity)
        inventory_result = await workflow.execute_activity(
            reserve_inventory,
            args=[order_id],
            start_to_close_timeout=timedelta(minutes=5)
        )

        if not inventory_result.success:
            return "insufficient_inventory"

        # Step 2: Charge payment (activity with retries)
        try:
            payment_result = await workflow.execute_activity(
                charge_payment,
                args=[order_id],
                start_to_close_timeout=timedelta(minutes=2),
                retry_policy=RetryPolicy(
                    max_attempts=3,
                    initial_interval=timedelta(seconds=1),
                    backoff_coefficient=2.0
                )
            )
        except ActivityError as e:
            # Payment failed, compensate: release inventory
            await workflow.execute_activity(
                release_inventory,
                args=[order_id]
            )
            return "payment_failed"

        # Step 3: Ship order (activity)
        shipping_result = await workflow.execute_activity(
            ship_order,
            args=[order_id],
            start_to_close_timeout=timedelta(hours=1)
        )

        # Step 4: Wait for delivery confirmation or timeout
        delivery_confirmed = False
        try:
            await workflow.wait_condition(
                lambda: self.order_status == "delivered",
                timeout=timedelta(days=7)
            )
            delivery_confirmed = True
        except TimeoutError:
            # Timeout, trigger investigation
            await workflow.execute_activity(
                investigate_delivery,
                args=[order_id]
            )

        # Step 5: Send notification
        await workflow.execute_activity(
            send_notification,
            args=[order_id, delivery_confirmed]
        )

        return "completed"

    @workflow.signal
    def update_status(self, status: str):
        self.order_status = status

    @workflow.query
    def get_status(self) -> str:
        return self.order_status

Activities (Side Effects)

// Activities perform actual work (can fail, non-deterministic)
@activity.defn
async def charge_payment(order_id: str) -> PaymentResult:
    # This is where real I/O happens
    response = await stripe_client.charge(
        order_id=order_id,
        amount=get_order_amount(order_id)
    )

    # Activity can be retried automatically
    if response.status == "insufficient_funds":
        raise ApplicationError("Payment declined")

    return PaymentResult(
        success=True,
        transaction_id=response.id
    )

@activity.defn
async def ship_order(order_id: str) -> ShippingResult:
    # Call shipping API
    tracking_number = await shipping_api.create_shipment(order_id)

    # Write to database
    await db.update_order_status(order_id, "shipped", tracking_number)

    return ShippingResult(tracking_number=tracking_number)

Child Workflows

// Complex workflows can spawn child workflows
@workflow.run
async def parent_workflow(customer_id: str):
    # Process multiple orders in parallel
    order_ids = await workflow.execute_activity(get_pending_orders, [customer_id])

    # Start child workflow for each order
    child_handles = []
    for order_id in order_ids:
        handle = await workflow.start_child_workflow(
            OrderWorkflow,
            args=[order_id],
            id=f"order-{order_id}"
        )
        child_handles.append(handle)

    # Wait for all to complete
    results = await asyncio.gather(*[h.result() for h in child_handles])

    return results
Interview Question: How does Temporal handle workflow code changes while workflows are running?

Answer: Temporal uses workflow versioning. Old workflow instances continue executing the old code version (via replay of their event history). New workflow instances use the new code. For migrating running workflows, you can use workflow.get_version() to branch logic: "if version >= 2, do new logic, else do old logic." This allows gradual migration. The key is that replay must still work—you can't break the determinism contract.

4. Apache Airflow

Apache Airflow is a platform for programmatically authoring, scheduling, and monitoring data pipelines as Directed Acyclic Graphs (DAGs).

Core Concepts

  • DAG: Directed Acyclic Graph defining task dependencies
  • Operator: Template for a task (PythonOperator, BashOperator, SQLOperator)
  • Task: Instance of an operator
  • Scheduler: Triggers DAGs based on schedule, monitors completion
  • Executor: Runs tasks (LocalExecutor, CeleryExecutor, KubernetesExecutor)
  • XCom: Cross-communication between tasks (small data sharing)

Architecture

graph TB WebUI[Web UI] Scheduler[Scheduler
Triggers DAGs] Executor[Executor
Runs Tasks] Worker1[Worker 1] Worker2[Worker 2] Worker3[Worker 3] DB[(Metadata DB
PostgreSQL)] Queue[Message Queue
Redis/RabbitMQ] WebUI --> DB Scheduler --> DB Scheduler -->|Enqueue Tasks| Queue Executor --> Queue Executor -->|Distribute| Worker1 Executor -->|Distribute| Worker2 Executor -->|Distribute| Worker3 Worker1 --> DB Worker2 --> DB Worker3 --> DB style Scheduler fill:#4A90E2 style Executor fill:#7ED321 style Worker1 fill:#F5A623 style Worker2 fill:#F5A623 style Worker3 fill:#F5A623

DAG Definition

from airflow import DAG
from airflow.operators.python import PythonOperator
from airflow.operators.bash import BashOperator
from datetime import datetime, timedelta

default_args = {
    'owner': 'data-team',
    'depends_on_past': False,
    'email_on_failure': True,
    'retries': 3,
    'retry_delay': timedelta(minutes=5)
}

with DAG(
    'data_pipeline',
    default_args=default_args,
    description='ETL pipeline for sales data',
    schedule_interval='0 2 * * *',  # 2 AM daily
    start_date=datetime(2024, 1, 1),
    catchup=False,
    tags=['etl', 'sales']
) as dag:

    # Task 1: Extract data from API
    extract = PythonOperator(
        task_id='extract_sales_data',
        python_callable=extract_sales_data,
        op_kwargs={'date': '{{ ds }}'}  # Templated execution date
    )

    # Task 2: Transform data
    transform = PythonOperator(
        task_id='transform_data',
        python_callable=transform_sales_data
    )

    # Task 3: Validate data quality
    validate = PythonOperator(
        task_id='validate_data',
        python_callable=validate_sales_data
    )

    # Task 4: Load to warehouse
    load = PythonOperator(
        task_id='load_to_warehouse',
        python_callable=load_to_warehouse
    )

    # Task 5: Run analytics
    analytics = BashOperator(
        task_id='run_analytics',
        bash_command='dbt run --models sales_analytics'
    )

    # Define dependencies
    extract >> transform >> validate >> load >> analytics

    # Alternative syntax:
    # extract.set_downstream(transform)
    # transform.set_downstream([validate, load])  # Parallel

Executors

Executor Parallelism Use Case
SequentialExecutor 1 task at a time Development, testing
LocalExecutor Multi-process on single machine Small deployments, simple pipelines
CeleryExecutor Distributed workers (Celery) Production, horizontal scaling
KubernetesExecutor Each task = K8s pod Cloud-native, resource isolation, autoscaling
DaskExecutor Dask cluster Data science, dynamic task graphs

XComs (Cross-Communication)

def extract_sales_data(**context):
    data = fetch_from_api()

    # Push data to XCom (small data only, stored in DB)
    context['ti'].xcom_push(key='sales_data', value=data)

    return len(data)  # Also auto-pushed to XCom with key='return_value'

def transform_sales_data(**context):
    # Pull data from previous task
    ti = context['ti']
    sales_data = ti.xcom_pull(
        task_ids='extract_sales_data',
        key='sales_data'
    )

    transformed = transform(sales_data)
    return transformed
XCom Limitations: XComs are stored in the metadata DB (PostgreSQL) and meant for small data (IDs, metadata). For large datasets, pass file paths/S3 URIs, not the data itself. Some backends (like MySQL) have size limits (64 KB). For large data, use external storage and pass references.

Backfills & Catchup

# Backfill: Re-run historical DAG runs
# If DAG was paused/failed, catch up on missed runs
with DAG(
    'daily_reports',
    schedule_interval='@daily',
    start_date=datetime(2024, 1, 1),
    catchup=True  # Run all missed intervals since start_date
) as dag:
    # Tasks...

# Command line: backfill specific date range
# airflow dags backfill -s 2024-01-01 -e 2024-01-31 daily_reports

5. AWS Step Functions

AWS Step Functions is a serverless orchestration service using state machines defined in Amazon States Language (ASL) JSON.

Workflow Types

Type Duration Execution Rate Use Case
Standard Up to 1 year 2,000/sec Long-running, exactly-once, audit trail
Express (Sync) 5 min Unlimited High-volume, synchronous response (API Gateway)
Express (Async) 5 min Unlimited High-volume, fire-and-forget, at-least-once

State Types

  • Task: Invoke Lambda, API Gateway, DynamoDB, ECS, Batch, SNS, SQS, etc.
  • Choice: Conditional branching (if-else logic)
  • Parallel: Execute branches concurrently
  • Map: Iterate over array, execute state for each item
  • Wait: Delay for duration or until timestamp
  • Pass: Pass input to output (transform, inject data)
  • Succeed/Fail: Terminal states

State Machine Diagram

graph TD Start([Start]) --> ValidateInput[Task: Validate Input] ValidateInput --> CheckValid{Choice: Valid?} CheckValid -->|Yes| ProcessOrder[Task: Process Order] CheckValid -->|No| InvalidInput([Fail: Invalid Input]) ProcessOrder --> ChargePayment[Task: Charge Payment] ChargePayment --> CheckPayment{Choice: Success?} CheckPayment -->|Yes| Parallel[Parallel: Ship & Notify] CheckPayment -->|No| WaitRetry[Wait: 5 minutes] WaitRetry --> ChargePayment Parallel --> ShipOrder[Task: Ship Order] Parallel --> SendEmail[Task: Send Email] ShipOrder --> Done SendEmail --> Done Done([Succeed]) style CheckValid fill:#FFD700 style CheckPayment fill:#FFD700 style Parallel fill:#9370DB

ASL Definition

{
  "Comment": "Order processing state machine",
  "StartAt": "ValidateInput",
  "States": {
    "ValidateInput": {
      "Type": "Task",
      "Resource": "arn:aws:lambda:us-east-1:123456789:function:validate-input",
      "Next": "CheckValid",
      "Retry": [
        {
          "ErrorEquals": ["Lambda.ServiceException"],
          "IntervalSeconds": 2,
          "MaxAttempts": 3,
          "BackoffRate": 2.0
        }
      ],
      "Catch": [
        {
          "ErrorEquals": ["ValidationError"],
          "ResultPath": "$.error",
          "Next": "InvalidInput"
        }
      ]
    },
    "CheckValid": {
      "Type": "Choice",
      "Choices": [
        {
          "Variable": "$.isValid",
          "BooleanEquals": true,
          "Next": "ProcessOrder"
        }
      ],
      "Default": "InvalidInput"
    },
    "ProcessOrder": {
      "Type": "Task",
      "Resource": "arn:aws:states:::dynamodb:putItem",
      "Parameters": {
        "TableName": "Orders",
        "Item": {
          "orderId": {"S.$": "$.orderId"},
          "status": {"S": "processing"}
        }
      },
      "Next": "ChargePayment"
    },
    "ChargePayment": {
      "Type": "Task",
      "Resource": "arn:aws:lambda:us-east-1:123456789:function:charge-payment",
      "ResultPath": "$.paymentResult",
      "Next": "CheckPayment"
    },
    "CheckPayment": {
      "Type": "Choice",
      "Choices": [
        {
          "Variable": "$.paymentResult.success",
          "BooleanEquals": true,
          "Next": "ParallelShipAndNotify"
        }
      ],
      "Default": "PaymentFailed"
    },
    "ParallelShipAndNotify": {
      "Type": "Parallel",
      "Branches": [
        {
          "StartAt": "ShipOrder",
          "States": {
            "ShipOrder": {
              "Type": "Task",
              "Resource": "arn:aws:lambda:us-east-1:123456789:function:ship-order",
              "End": true
            }
          }
        },
        {
          "StartAt": "SendEmail",
          "States": {
            "SendEmail": {
              "Type": "Task",
              "Resource": "arn:aws:states:::sns:publish",
              "Parameters": {
                "TopicArn": "arn:aws:sns:us-east-1:123456789:order-notifications",
                "Message.$": "$.orderId"
              },
              "End": true
            }
          }
        }
      ],
      "Next": "Success"
    },
    "PaymentFailed": {
      "Type": "Fail",
      "Error": "PaymentError",
      "Cause": "Payment processing failed"
    },
    "InvalidInput": {
      "Type": "Fail",
      "Error": "InvalidInput",
      "Cause": "Input validation failed"
    },
    "Success": {
      "Type": "Succeed"
    }
  }
}

Map State (Dynamic Parallelism)

{
  "ProcessOrders": {
    "Type": "Map",
    "ItemsPath": "$.orders",
    "MaxConcurrency": 10,
    "Iterator": {
      "StartAt": "ProcessSingleOrder",
      "States": {
        "ProcessSingleOrder": {
          "Type": "Task",
          "Resource": "arn:aws:lambda:us-east-1:123456789:function:process-order",
          "End": true
        }
      }
    },
    "ResultPath": "$.processedOrders",
    "Next": "AllOrdersProcessed"
  }
}
Interview Question: When would you choose Step Functions over Lambda alone?

Answer: Use Step Functions when you need: (1) workflows longer than Lambda's 15-minute timeout, (2) complex conditional logic/branching that's cleaner in a state machine than code, (3) built-in retry/error handling per step, (4) visibility into workflow progress via AWS console, (5) parallel execution with wait-all semantics, (6) human approval steps (wait for callback). Lambda alone is fine for simple single-step processing.

6. State Machines

Formal Definition

A finite state machine (FSM) is a mathematical model defined by:

  • States: Finite set of conditions (S = {s1, s2, ..., sn})
  • Initial state: Starting point (s0 ∈ S)
  • Transitions: Rules for moving between states (δ: S × E → S)
  • Events: Inputs that trigger transitions (E = {e1, e2, ..., em})
  • Actions: Operations performed during transitions (optional)
  • Final states: Terminal conditions (F ⊆ S)

Order State Machine Example

stateDiagram-v2 [*] --> Created Created --> PaymentPending: Submit Order PaymentPending --> PaymentFailed: Payment Declined PaymentPending --> Paid: Payment Success PaymentFailed --> Cancelled: Max Retries PaymentFailed --> PaymentPending: Retry Paid --> Shipped: Ship Order Shipped --> InTransit: Carrier Pickup InTransit --> Delivered: Delivery Confirmed Delivered --> [*] Cancelled --> [*] Created --> Cancelled: Cancel Order PaymentPending --> Cancelled: Cancel Order Paid --> Cancelled: Refund & Cancel

Advantages of State Machines

Advantage Benefit
Explicit states No ambiguity—system is always in a known state
Defined transitions Valid state changes are explicit, invalid ones rejected
Testability Enumerate all paths, test each transition independently
Visualization Diagram makes logic immediately understandable
Prevents bugs Can't reach invalid states (e.g., shipped before paid)

Implementation Pattern

// State machine implementation (pseudocode)
class OrderStateMachine {
  state: State = State.CREATED

  // State transition table
  transitions = {
    CREATED: {
      SUBMIT: State.PAYMENT_PENDING,
      CANCEL: State.CANCELLED
    },
    PAYMENT_PENDING: {
      PAYMENT_SUCCESS: State.PAID,
      PAYMENT_FAILED: State.PAYMENT_FAILED,
      CANCEL: State.CANCELLED
    },
    PAID: {
      SHIP: State.SHIPPED,
      REFUND: State.CANCELLED
    },
    SHIPPED: {
      PICKUP: State.IN_TRANSIT
    },
    IN_TRANSIT: {
      DELIVER: State.DELIVERED
    }
  }

  // Trigger event
  trigger(event: Event) {
    allowed_transitions = transitions[this.state]

    if event not in allowed_transitions:
      throw InvalidTransitionError(
        f"Cannot {event} from state {this.state}"
      )

    old_state = this.state
    new_state = allowed_transitions[event]

    // Execute exit action for old state
    on_exit(old_state)

    // Perform transition
    this.state = new_state

    // Execute entry action for new state
    on_entry(new_state)

    // Log transition
    log_transition(old_state, event, new_state)
  }

  // State entry/exit hooks
  on_entry(state: State) {
    match state:
      PAYMENT_PENDING: initiate_payment()
      SHIPPED: notify_customer()
      DELIVERED: close_order()
  }

  on_exit(state: State) {
    // Cleanup actions when leaving state
  }
}

Hierarchical State Machines

Complex workflows benefit from nested states (substates):

stateDiagram-v2 [*] --> Active state Active { [*] --> Processing Processing --> Validating Validating --> Enriching Enriching --> Complete Complete --> [*] } Active --> Failed: Error Active --> Suspended: User Action Suspended --> Active: Resume Failed --> [*]

7. Compensation & Rollback

The Problem

Distributed transactions can't use traditional ACID transactions across services. If step 5 of 10 fails, you need to undo steps 1-4. This is compensation.

Saga Pattern

A saga is a sequence of local transactions. If one fails, execute compensating transactions to undo previous steps.

graph LR T1[T1: Reserve
Inventory] --> T2[T2: Charge
Payment] T2 --> T3[T3: Ship
Order] T3 --> T4[T4: Send
Notification] T4 -.->|Fail| C3[C3: Cancel
Shipment] C3 -.-> C2[C2: Refund
Payment] C2 -.-> C1[C1: Release
Inventory] style T1 fill:#7ED321 style T2 fill:#7ED321 style T3 fill:#7ED321 style T4 fill:#7ED321 style C1 fill:#D0021B style C2 fill:#D0021B style C3 fill:#D0021B

Compensation Implementation

// Temporal compensation pattern
@workflow.run
async def booking_workflow(booking_id: str):
    compensations = []

    try:
        # Step 1: Reserve hotel
        hotel_result = await workflow.execute_activity(reserve_hotel, [booking_id])
        compensations.append(lambda: cancel_hotel_reservation(hotel_result.reservation_id))

        # Step 2: Book flight
        flight_result = await workflow.execute_activity(book_flight, [booking_id])
        compensations.append(lambda: cancel_flight(flight_result.ticket_id))

        # Step 3: Charge customer
        payment_result = await workflow.execute_activity(charge_payment, [booking_id])
        compensations.append(lambda: refund_payment(payment_result.transaction_id))

        # Step 4: Send confirmation
        await workflow.execute_activity(send_confirmation, [booking_id])

        return "success"

    except Exception as e:
        # Execute compensations in reverse order
        for compensation in reversed(compensations):
            try:
                await workflow.execute_activity(compensation)
            except Exception as comp_error:
                # Log compensation failure, may need manual intervention
                await workflow.execute_activity(
                    log_compensation_failure,
                    [booking_id, str(comp_error)]
                )

        raise e  # Re-raise to mark workflow as failed

Compensation Strategies

Strategy Description Example
Backward recovery Undo completed steps in reverse order Refund payment, cancel shipment, release inventory
Forward recovery Continue from failure point (retry, alternate path) Retry payment with different method, use alternate supplier
Semantic rollback Business logic to reverse action (not DB transaction) Issue credit note (can't "uncharge" already-settled payment)
Dead letter queue Move failed workflow to DLQ for manual intervention Compensation failed 3 times → alert operations team

Idempotency

Compensation actions must be idempotent (safe to retry). Use idempotency keys:

// Idempotent refund activity
async def refund_payment(transaction_id: str):
    idempotency_key = f"refund-{transaction_id}"

    # Check if refund already processed
    existing_refund = await db.get_refund_by_key(idempotency_key)
    if existing_refund:
        return existing_refund  # Already done, return cached result

    # Process refund
    refund = await payment_gateway.refund(transaction_id)

    # Store with idempotency key
    await db.store_refund(refund, idempotency_key)

    return refund

Human-in-the-Loop

Some failures require human approval to proceed or compensate:

@workflow.run
async def high_value_order(order_id: str, amount: float):
    # Process order normally
    result = await process_order(order_id)

    # If payment is large, wait for manual approval
    if amount > 10000:
        approved = await workflow.wait_condition(
            lambda: self.approval_status == "approved",
            timeout=timedelta(hours=24)
        )

        if not approved:
            # Timeout, compensate
            await compensate_order(order_id)
            return "approval_timeout"

    # Continue...

@workflow.signal
def approve_order(self):
    self.approval_status = "approved"
Interview Question: Why not use distributed transactions (2PC) instead of sagas?

Answer: Two-phase commit (2PC) requires all participants to hold locks until all are ready to commit. This doesn't scale—it's slow, reduces availability (any participant down blocks all), and doesn't work across organizational boundaries (can't hold locks in a partner's DB). Sagas trade consistency for availability: each step commits immediately, and we use compensations if later steps fail. This is eventual consistency—acceptable for most business workflows.

8. Workflow Patterns

1. Saga Orchestration

Centralized orchestrator directs saga steps and compensations (already covered above).

2. Fan-Out/Fan-In

Execute multiple tasks in parallel, then wait for all to complete before proceeding.

graph TB Start([Start]) --> FanOut[Fan-Out] FanOut --> Task1[Task 1] FanOut --> Task2[Task 2] FanOut --> Task3[Task 3] FanOut --> Task4[Task 4] Task1 --> FanIn[Fan-In: Wait All] Task2 --> FanIn Task3 --> FanIn Task4 --> FanIn FanIn --> Next[Next Step] style FanOut fill:#9370DB style FanIn fill:#9370DB
// Temporal fan-out/fan-in
@workflow.run
async def process_batch(item_ids: List[str]):
    # Fan-out: start activities in parallel
    activity_futures = []
    for item_id in item_ids:
        future = workflow.execute_activity(
            process_item,
            args=[item_id],
            start_to_close_timeout=timedelta(minutes=5)
        )
        activity_futures.append(future)

    # Fan-in: wait for all to complete
    results = await asyncio.gather(*activity_futures)

    # Aggregate results
    summary = aggregate_results(results)
    return summary

3. Human Approval

Pause workflow until human provides input (signal/callback).

// Temporal human approval
@workflow.run
async def approval_workflow(request_id: str):
    # Submit request
    await workflow.execute_activity(submit_request, [request_id])

    # Wait for approval signal (or timeout)
    try:
        approved = await workflow.wait_condition(
            lambda: self.approval_status is not None,
            timeout=timedelta(days=3)
        )
    except TimeoutError:
        # Timeout, auto-reject
        await workflow.execute_activity(notify_timeout, [request_id])
        return "timeout"

    if self.approval_status == "approved":
        await workflow.execute_activity(execute_request, [request_id])
        return "approved"
    else:
        await workflow.execute_activity(cancel_request, [request_id])
        return "rejected"

@workflow.signal
def approve(self):
    self.approval_status = "approved"

@workflow.signal
def reject(self):
    self.approval_status = "rejected"

4. Scheduled Workflows

Trigger workflows on a schedule (cron).

// Airflow scheduled DAG
with DAG(
    'daily_report',
    schedule_interval='0 9 * * *',  # 9 AM daily
    start_date=datetime(2024, 1, 1)
) as dag:
    generate_report = PythonOperator(
        task_id='generate_report',
        python_callable=generate_daily_report
    )

// Temporal scheduled workflow
async def main():
    client = await Client.connect("localhost:7233")

    # Schedule workflow to run daily
    await client.schedule_workflow(
        workflow=DailyReportWorkflow,
        schedule_id="daily-report",
        schedule={
            "cron": "0 9 * * *",  # 9 AM daily
            "timezone": "America/New_York"
        }
    )

5. Event-Driven Triggers

Start workflow in response to events (S3 upload, SQS message, webhook).

// AWS Step Functions triggered by EventBridge
{
  "source": ["aws.s3"],
  "detail-type": ["Object Created"],
  "detail": {
    "bucket": {"name": ["my-upload-bucket"]}
  }
}
// -> Triggers Step Functions execution

// Temporal workflow started from event
async def event_handler(event: S3Event):
    client = await Client.connect("localhost:7233")

    await client.start_workflow(
        ProcessUploadWorkflow,
        args=[event.bucket, event.key],
        id=f"upload-{event.key}",
        task_queue="upload-processing"
    )

6. Polling Pattern

Periodically check external system until condition met.

// Temporal polling with sleep
@workflow.run
async def wait_for_external_system(job_id: str):
    max_attempts = 60  # 1 hour (60 * 1 min)

    for attempt in range(max_attempts):
        # Check status
        status = await workflow.execute_activity(
            check_job_status,
            args=[job_id],
            start_to_close_timeout=timedelta(seconds=30)
        )

        if status == "completed":
            return "success"
        elif status == "failed":
            return "failed"

        # Wait 1 minute before next check
        await asyncio.sleep(60)

    # Timeout
    return "timeout"

7. Dynamic Workflow

Workflow structure determined at runtime (data-driven).

// Dynamic task graph based on config
@workflow.run
async def dynamic_pipeline(config: PipelineConfig):
    results = {}

    for stage in config.stages:
        # Determine activity based on stage type
        if stage.type == "transform":
            activity = transform_activity
        elif stage.type == "validate":
            activity = validate_activity
        elif stage.type == "enrich":
            activity = enrich_activity
        else:
            raise ValueError(f"Unknown stage type: {stage.type}")

        # Execute activity with stage-specific config
        result = await workflow.execute_activity(
            activity,
            args=[results, stage.config],
            start_to_close_timeout=timedelta(minutes=stage.timeout)
        )

        results[stage.name] = result

    return results

9. Comparison

Feature Temporal Airflow Step Functions Cadence Argo Workflows
Primary Use Case Microservice orchestration, long-running business processes Data pipelines, ETL, batch jobs AWS-native serverless workflows Microservice orchestration (Uber's version, pre-Temporal) Kubernetes-native workflows, CI/CD, ML pipelines
Workflow Definition Code (Go, Java, Python, TypeScript) Code (Python DAGs) JSON (ASL) Code (Go, Java) YAML (K8s CRDs)
Durable Execution Yes (event sourcing) Yes (metadata DB) Yes (managed) Yes (event sourcing) Yes (K8s etcd)
Max Duration Unlimited Unlimited Standard: 1 year
Express: 5 min
Unlimited Unlimited
Retry Logic Automatic, configurable per activity Configurable per task Configurable per state Automatic, configurable Configurable per step
Signals/Events Yes (signals, queries, updates) Limited (XComs) Yes (callbacks, EventBridge) Yes (signals) Yes (via K8s events)
Scheduling Yes (via schedules API) Yes (cron, primary feature) Yes (via EventBridge) Yes Yes (via CronWorkflow)
Human-in-the-Loop Yes (signals, easy) Limited (manual task sensors) Yes (callbacks) Yes (signals) Yes (suspend template)
Testing Excellent (time-travel, replay) Good (unit test tasks) Limited (AWS mocks) Excellent Good (K8s test clusters)
Scalability Millions of workflows Thousands of DAGs Unlimited (managed) Millions of workflows Depends on K8s cluster
Deployment Self-hosted or Cloud Self-hosted (or managed: Astronomer, MWAA) AWS managed only Self-hosted K8s (self-hosted or cloud)
Vendor Lock-in None (open source) None (open source) AWS only None (open source) K8s dependency
Language Support Go, Java, Python, TypeScript, .NET Python Any (via Lambda/API) Go, Java Any (containerized)
UI Yes (Web UI) Yes (excellent UI) Yes (AWS Console) Yes (Web UI) Yes (Argo UI)
Cost Self-host: infra cost
Cloud: pay per workflow
Self-host: infra cost
Managed: $$$
Pay per state transition Self-host: infra cost K8s infra cost
Community Growing, strong Mature, very large N/A (AWS managed) Smaller (Temporal fork) Strong (CNCF)
Learning Curve Medium (new paradigm) Low-Medium (Python) Low (JSON, AWS familiarity) Medium Medium (K8s knowledge required)

Architecture Comparison

Temporal/Cadence

  • Event-sourced (all workflow history stored)
  • Replay-based recovery (deterministic)
  • Workers pull tasks from queues
  • Code-first (workflow = code)

Airflow

  • Task state stored in metadata DB
  • Scheduler polls DAGs, enqueues tasks
  • Workers execute tasks via executor
  • Code-first (DAG = code)

Step Functions

  • Fully managed (no servers to maintain)
  • State machine JSON (ASL)
  • Integrates natively with AWS services
  • Config-first (state machine = JSON)

Argo Workflows

  • K8s-native (CRDs for workflows)
  • Each step = K8s pod (container)
  • Strong support for ML/CI-CD pipelines
  • Config-first (workflow = YAML)
Interview Question: Your team is building a document processing pipeline that involves OCR (10 min), classification (30 sec), extraction (2 min), and human validation (hours/days). Which orchestration tool would you choose?

Answer: Temporal is the best fit because: (1) mixed durations (seconds to days) with no arbitrary timeout limits, (2) human-in-the-loop approval is first-class via signals, (3) strong visibility into which step each document is on, (4) automatic retries per step (OCR might fail intermittently), (5) can handle millions of concurrent document workflows. Airflow is designed for scheduled batch jobs, not real-time event-driven processing. Step Functions could work but ties you to AWS and has cost implications at scale (pay per state transition). Argo is overkill unless you're already K8s-native.

10. When to Use What

Decision Tree

graph TD Start{Need workflow
orchestration?} Start -->|Yes| Duration{Max workflow
duration?} Start -->|No| SimpleQueue[Use simple queue
SQS/RabbitMQ] Duration -->|Seconds| Latency{Latency
requirements?} Duration -->|Minutes to Days| UseCase{Primary
use case?} Latency -->|<100ms| StepExpress[Step Functions
Express Sync] Latency -->|Best effort| StepExpressAsync[Step Functions
Express Async] UseCase -->|Data pipelines| Scheduling{Need complex
scheduling?} UseCase -->|Microservices| Platform{Cloud
platform?} UseCase -->|ML/CI-CD| Kubernetes{Using
Kubernetes?} Scheduling -->|Yes| Airflow[Airflow] Scheduling -->|No| Temporal1[Temporal or
Step Functions] Platform -->|AWS only| StepStandard[Step Functions
Standard] Platform -->|Multi-cloud| Temporal2[Temporal] Kubernetes -->|Yes| Argo[Argo Workflows] Kubernetes -->|No| Temporal3[Temporal] style Airflow fill:#7ED321 style Temporal1 fill:#7ED321 style Temporal2 fill:#7ED321 style Temporal3 fill:#7ED321 style StepStandard fill:#7ED321 style StepExpress fill:#7ED321 style StepExpressAsync fill:#7ED321 style Argo fill:#7ED321

Use Temporal When...

  • Building microservice orchestration (saga pattern)
  • Long-running processes (hours, days, weeks)
  • Need human-in-the-loop approvals
  • Complex state management across steps
  • Requirement for exactly-once execution guarantees
  • Want to write workflows in your application language
  • Need multi-cloud or on-prem deployment

Use Airflow When...

  • Building data pipelines (ETL, ELT)
  • Need complex scheduling (cron, backfills, data-aware schedules)
  • Task dependencies form a DAG
  • Team is Python-focused
  • Need extensive integrations (Airflow has 1000+ operators)
  • Batch processing workloads

Use Step Functions When...

  • Already on AWS and want managed service
  • Tight integration with AWS services (Lambda, ECS, DynamoDB, etc.)
  • Workflows are relatively simple (< 25k events)
  • Want zero infrastructure management
  • High-volume, short-duration workflows (Express)

Use Argo Workflows When...

  • Already on Kubernetes
  • ML pipelines (can integrate with Kubeflow)
  • CI/CD workflows
  • Each step needs isolated environment (containers)
  • Need resource management per step (CPU/memory limits)

Use Simple Queue When...

  • Single-step async processing (no multi-step coordination)
  • No need for workflow visibility
  • Simple retry logic sufficient
  • Low complexity (adding orchestration would be overkill)

Hybrid Approaches

Often systems use multiple tools:

  • Airflow + Temporal: Airflow schedules batch jobs, each triggers Temporal workflows for per-item processing
  • Step Functions + Lambda: Step Functions orchestrates, Lambda executes individual tasks
  • Kafka + Temporal: Kafka for event streaming, Temporal for stateful workflow orchestration
  • Argo + Airflow: Airflow schedules pipelines, Argo executes containerized steps on K8s

Cost Considerations

Tool Cost Model Rough Cost (1M workflow runs)
Temporal (self-host) Infrastructure (servers, DB) $500-2000/month (depends on instance size)
Temporal Cloud Per action (signal, activity, etc.) $200-1000/month (depends on actions)
Airflow (self-host) Infrastructure (scheduler, workers, DB) $300-1500/month
AWS MWAA Per environment (fixed) + worker hours $500-2000/month (min ~$500 for small env)
Step Functions (Standard) Per state transition ($0.025/1000) $250/month (10M transitions @ 10 states/workflow)
Step Functions (Express) Per request + duration $30-100/month (depends on duration)
Argo (self-host) K8s infrastructure (cluster, pods) $400-2000/month (depends on cluster)
Rule of Thumb: If you need scheduling + visibility + coordination, use an orchestration tool. If you just need "run this after that" with retries, a queue is sufficient. Don't over-engineer, but don't underestimate the complexity of distributed workflows—manual coordination code is error-prone and hard to maintain.