LAB41_AWS_Step Functions: Saga Pattern for Distributed Order Transactions

Step Functions: Saga Pattern for Distributed Order Transactions

☁ AWS 🔥 Advanced Step Functions Saga Pattern Error Handling Compensating Transactions X-Ray Tracing

Scenario

Your e-commerce order workflow involves three microservices: Inventory (reserves stock), Payment (charges the customer), and Fulfilment (ships the order). If Fulfilment fails after Payment succeeds, you must refund the customer and release the inventory. You need the Saga pattern — each step has a corresponding compensating action that rolls back if a later step fails.

Your Objectives
  • Create three Lambda functions: reserve-inventory, charge-payment, create-shipment — and three compensating functions: release-inventory, refund-payment.
  • Create a Step Functions Standard state machine (for exactly-once semantics) with the Saga workflow. Each step uses Catch to route errors to the correct compensating chain.
  • Enable X-Ray tracing on the state machine.
  • Create the Step Functions execution IAM role with permissions to invoke all five Lambda functions.
  • Create an EventBridge rule to trigger the state machine whenever an OrderPlaced event arrives on a custom event bus.
  • Output the state machine ARN and the execution role ARN.

Additional Context

Saga vs 2PC: Two-phase commit (2PC) requires all participants to hold locks until a coordinator commits — it doesn't work across independently deployed services. The Saga pattern splits a distributed transaction into a sequence of local transactions, each with a compensating "undo" action. It's eventually consistent but far more resilient to partial failures.

Standard vs Express workflows: Standard workflows run up to one year, support exactly-once execution, and have full execution history. Express workflows are high-throughput (up to 100,000/second) but run for at most 5 minutes and have at-least-once semantics. For financial sagas, use Standard — you need exactly-once and a durable audit trail.

variables.tf

variable "aws_region"      { type = string; default = "us-east-1" }
variable "state_machine_name" { type = string; default = "order-saga" }
variable "event_bus_name"  { type = string; default = "orders-bus-prod" }
variable "environment"     { type = string; default = "prod" }

main.tf

provider "aws" { region = var.aws_region }
locals { tags = { Environment = var.environment, ManagedBy = "terraform" } }

# Lambda execution role shared by all five Lambda functions.
data "aws_iam_policy_document" "lambda_assume" {
  statement { effect = "Allow"; actions = ["sts:AssumeRole"]
    principals { type = "Service"; identifiers = ["lambda.amazonaws.com"] } }
}
resource "aws_iam_role" "lambda" {
  name               = "${var.state_machine_name}-lambda-role"
  assume_role_policy = data.aws_iam_policy_document.lambda_assume.json
}
resource "aws_iam_role_policy_attachment" "lambda_logs" {
  role       = aws_iam_role.lambda.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}

# All 5 Lambda functions (transaction steps + compensating actions).
locals {
  functions = toset(["reserve-inventory","charge-payment","create-shipment","release-inventory","refund-payment"])
}
resource "aws_cloudwatch_log_group" "fn" {
  for_each          = local.functions
  name              = "/aws/lambda/${var.state_machine_name}-${each.key}-${var.environment}"
  retention_in_days = 30
}
resource "aws_lambda_function" "fn" {
  for_each      = local.functions
  function_name = "${var.state_machine_name}-${each.key}-${var.environment}"
  runtime       = "python3.12"
  handler       = "handler.lambda_handler"
  role          = aws_iam_role.lambda.arn
  filename      = "${path.module}/handler.zip"
  timeout       = 30
  depends_on    = [aws_cloudwatch_log_group.fn]
  tags          = local.tags
}

# Step Functions IAM role — invoke all five Lambda functions.
data "aws_iam_policy_document" "sfn_assume" {
  statement { effect = "Allow"; actions = ["sts:AssumeRole"]
    principals { type = "Service"; identifiers = ["states.amazonaws.com"] } }
}
resource "aws_iam_role" "sfn" {
  name               = "${var.state_machine_name}-sfn-role"
  assume_role_policy = data.aws_iam_policy_document.sfn_assume.json
}
resource "aws_iam_role_policy" "sfn" {
  name = "invoke-lambdas"
  role = aws_iam_role.sfn.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      { Effect = "Allow"; Action = "lambda:InvokeFunction"
        Resource = [for k, v in aws_lambda_function.fn : v.arn] },
      { Effect = "Allow"; Action = ["xray:PutTraceSegments","xray:PutTelemetryRecords"]; Resource = "*" },
      { Effect = "Allow"; Action = ["logs:CreateLogDelivery","logs:GetLogDelivery","logs:UpdateLogDelivery",
          "logs:DeleteLogDelivery","logs:ListLogDeliveries","logs:PutLogEvents","logs:PutResourcePolicy",
          "logs:DescribeResourcePolicies","logs:DescribeLogGroups"]; Resource = "*" }
    ]
  })
}

# Step Functions state machine with Saga pattern.
resource "aws_sfn_state_machine" "order_saga" {
  name     = "${var.state_machine_name}-${var.environment}"
  role_arn = aws_iam_role.sfn.arn
  type     = "STANDARD"

  # Enable X-Ray tracing for detailed execution insights.
  tracing_configuration { enabled = true }

  definition = jsonencode({
    Comment = "Order Saga: Reserve Inventory → Charge Payment → Create Shipment"
    StartAt = "ReserveInventory"
    States = {
      ReserveInventory = {
        Type     = "Task"
        Resource = aws_lambda_function.fn["reserve-inventory"].arn
        Next     = "ChargePayment"
        Catch = [{ ErrorEquals = ["States.ALL"]; Next = "SagaFailed"; ResultPath = "$.error" }]
      }
      ChargePayment = {
        Type     = "Task"
        Resource = aws_lambda_function.fn["charge-payment"].arn
        Next     = "CreateShipment"
        Catch = [{
          ErrorEquals = ["States.ALL"]
          Next        = "ReleaseInventoryOnPaymentFail"
          ResultPath  = "$.error"
        }]
      }
      CreateShipment = {
        Type     = "Task"
        Resource = aws_lambda_function.fn["create-shipment"].arn
        Next     = "OrderSucceeded"
        Catch = [{
          ErrorEquals = ["States.ALL"]
          Next        = "RefundPaymentOnShipFail"
          ResultPath  = "$.error"
        }]
      }
      # Compensating transaction chain — shipment failed, undo payment then inventory.
      RefundPaymentOnShipFail = {
        Type     = "Task"
        Resource = aws_lambda_function.fn["refund-payment"].arn
        Next     = "ReleaseInventoryOnShipFail"
        Catch = [{ ErrorEquals = ["States.ALL"]; Next = "SagaFailed"; ResultPath = "$.error" }]
      }
      ReleaseInventoryOnShipFail = {
        Type     = "Task"
        Resource = aws_lambda_function.fn["release-inventory"].arn
        Next     = "SagaFailed"
        Catch = [{ ErrorEquals = ["States.ALL"]; Next = "SagaFailed"; ResultPath = "$.error" }]
      }
      # Compensating transaction — payment failed, only undo inventory.
      ReleaseInventoryOnPaymentFail = {
        Type     = "Task"
        Resource = aws_lambda_function.fn["release-inventory"].arn
        Next     = "SagaFailed"
      }
      OrderSucceeded = { Type = "Succeed" }
      SagaFailed = {
        Type  = "Fail"
        Error = "SagaCompensated"
        Cause = "Order saga compensated due to downstream failure."
      }
    }
  })

  tags = local.tags
}

# EventBridge rule — triggers the state machine on OrderPlaced events.
data "aws_cloudwatch_event_bus" "orders" { name = var.event_bus_name }

resource "aws_iam_role" "eb_sfn" {
  name = "${var.state_machine_name}-eb-role"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{ Effect = "Allow"; Principal = { Service = "events.amazonaws.com" }; Action = "sts:AssumeRole" }]
  })
}
resource "aws_iam_role_policy" "eb_sfn" {
  name = "start-sfn"
  role = aws_iam_role.eb_sfn.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{ Effect = "Allow"; Action = "states:StartExecution"; Resource = aws_sfn_state_machine.order_saga.arn }]
  })
}

resource "aws_cloudwatch_event_rule" "order_placed" {
  name           = "order-saga-trigger-${var.environment}"
  event_bus_name = data.aws_cloudwatch_event_bus.orders.name
  event_pattern  = jsonencode({ source = ["orders.service"]; detail-type = ["OrderPlaced"] })
}
resource "aws_cloudwatch_event_target" "sfn" {
  rule           = aws_cloudwatch_event_rule.order_placed.name
  event_bus_name = data.aws_cloudwatch_event_bus.orders.name
  target_id      = "order-saga-sfn"
  arn            = aws_sfn_state_machine.order_saga.arn
  role_arn       = aws_iam_role.eb_sfn.arn
}

outputs.tf

output "state_machine_arn"  { value = aws_sfn_state_machine.order_saga.arn }
output "execution_role_arn" { value = aws_iam_role.sfn.arn }
output "lambda_arns" {
  value = { for k, v in aws_lambda_function.fn : k => v.arn }
}

Workflow Commands

terraform init && terraform apply

# Manually start an execution to test the happy path
aws stepfunctions start-execution \
  --state-machine-arn $(terraform output -raw state_machine_arn) \
  --name "test-$(date +%s)" \
  --input '{"orderId":"ORD-001","customerId":"CUST-123","items":[{"sku":"SKU-001","qty":2}]}'

# Check execution status
aws stepfunctions list-executions \
  --state-machine-arn $(terraform output -raw state_machine_arn) \
  --status-filter RUNNING

# View execution history (includes each state transition)
EXEC_ARN=$(aws stepfunctions list-executions --state-machine-arn $(terraform output -raw state_machine_arn) --query 'executions[0].executionArn' --output text)
aws stepfunctions get-execution-history --execution-arn $EXEC_ARN --query 'events[*].{Type:type,Timestamp:timestamp}'

terraform destroy
✓ Expected Output After Apply
Apply complete! Resources: 18 added, 0 changed, 0 destroyed.

Outputs:

execution_role_arn = "arn:aws:iam::123456789012:role/order-saga-sfn-role"
lambda_arns = {
  "charge-payment"    = "..."
  "create-shipment"   = "..."
  "refund-payment"    = "..."
  "release-inventory" = "..."
  "reserve-inventory" = "..."
}
state_machine_arn  = "arn:aws:states:us-east-1:123456789012:stateMachine:order-saga-prod"