Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
435 changes: 435 additions & 0 deletions saga-pattern-lambda-durable-functions/README.md

Large diffs are not rendered by default.

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
*.js
!jest.config.js
*.d.ts
node_modules

# CDK asset staging directory
.cdk.staging
cdk.out
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
*.ts
!*.d.ts

# CDK asset staging directory
.cdk.staging
cdk.out
90 changes: 90 additions & 0 deletions saga-pattern-lambda-durable-functions/saga-pattern-cdk/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# Saga Pattern with Lambda Durable Functions

This CDK project implements the saga pattern for distributed transactions using AWS Lambda Durable Functions. It demonstrates how to coordinate multiple microservices (flight, hotel, and car rental) with automatic rollback capabilities when any step fails.

## Architecture Overview

The saga orchestrator coordinates a travel booking workflow that:
1. Reserves a flight
2. Reserves a hotel room
3. Reserves a car rental

If any step fails, the orchestrator automatically executes compensating transactions to rollback previously completed reservations, ensuring data consistency across all services.

## Components

### Lambda Functions

**Saga Orchestrator (Durable Function)**
- Coordinates the entire workflow across all three services
- Manages state and execution history
- Automatically triggers rollback on failure
- Execution timeout: 1 hour
- Retention period: 30 days

**Flight Service**
- `saga-reserve-flight`: Creates flight booking reservations
- `saga-cancel-flight`: Compensating transaction to cancel flight bookings

**Hotel Service**
- `saga-reserve-hotel`: Creates hotel room reservations
- `saga-cancel-hotel`: Compensating transaction to cancel hotel reservations

**Car Service**
- `saga-reserve-car`: Creates car rental reservations
- `saga-cancel-car`: Compensating transaction to cancel car rentals

### DynamoDB Tables

- `saga-flight-bookings`: Stores flight booking records
- `saga-hotel-reservations`: Stores hotel reservation records
- `saga-car-rentals`: Stores car rental records

All tables use:
- Pay-per-request billing mode
- Point-in-time recovery enabled
- Partition key based on reservation/booking/rental ID

## Prerequisites

- Node.js and npm installed
- AWS CDK CLI installed (`npm install -g aws-cdk`)
- AWS credentials configured
- Python 3.14 runtime available in your AWS region

## Deployment

### Install dependencies
```bash
npm install
```

### Build the project
```bash
npm run build
```

### Deploy to AWS
```bash
npx cdk deploy
```

The deployment will output the ARNs of all Lambda functions and DynamoDB table names.

## Useful Commands

* `npm run build` - Compile TypeScript to JavaScript
* `npm run watch` - Watch for changes and compile automatically
* `npm run test` - Run Jest unit tests
* `npx cdk deploy` - Deploy this stack to your AWS account/region
* `npx cdk diff` - Compare deployed stack with current state
* `npx cdk synth` - Generate CloudFormation template
* `npx cdk destroy` - Remove all resources from AWS

## Testing the Saga Pattern

After deployment, invoke the saga orchestrator function with a test payload to see the distributed transaction in action. If any service fails, watch the automatic rollback compensate for completed steps.

## Configuration

The `cdk.json` file tells the CDK Toolkit how to execute your app and includes context values for feature flags and environment settings.
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
#!/usr/bin/env node
import * as cdk from 'aws-cdk-lib/core';
import { SagaPatternCdkStack } from '../lib/saga-pattern-cdk-stack';

const app = new cdk.App();
new SagaPatternCdkStack(app, 'SagaPatternCdkStack', {
description: "This templates deploys AWS Lambda Durable Function workflow that implements the SAGA pattern for travel booking use case."
/* If you don't specify 'env', this stack will be environment-agnostic.
* Account/Region-dependent features and context lookups will not work,
* but a single synthesized template can be deployed anywhere. */

/* Uncomment the next line to specialize this stack for the AWS Account
* and Region that are implied by the current CLI configuration. */
// env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION },

/* Uncomment the next line if you know exactly what Account and Region you
* want to deploy the stack to. */
// env: { account: '123456789012', region: 'us-east-1' },

/* For more information, see https://docs.aws.amazon.com/cdk/latest/guide/environments.html */
});
103 changes: 103 additions & 0 deletions saga-pattern-lambda-durable-functions/saga-pattern-cdk/cdk.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
{
"app": "npx ts-node --prefer-ts-exts bin/saga-pattern-cdk.ts",
"watch": {
"include": [
"**"
],
"exclude": [
"README.md",
"cdk*.json",
"**/*.d.ts",
"**/*.js",
"tsconfig.json",
"package*.json",
"yarn.lock",
"node_modules",
"test"
]
},
"context": {
"@aws-cdk/aws-signer:signingProfileNamePassedToCfn": true,
"@aws-cdk/aws-ecs-patterns:secGroupsDisablesImplicitOpenListener": true,
"@aws-cdk/aws-lambda:recognizeLayerVersion": true,
"@aws-cdk/core:checkSecretUsage": true,
"@aws-cdk/core:target-partitions": [
"aws",
"aws-cn"
],
"@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true,
"@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true,
"@aws-cdk/aws-ecs:arnFormatIncludesClusterName": true,
"@aws-cdk/aws-iam:minimizePolicies": true,
"@aws-cdk/core:validateSnapshotRemovalPolicy": true,
"@aws-cdk/aws-codepipeline:crossAccountKeyAliasStackSafeResourceName": true,
"@aws-cdk/aws-s3:createDefaultLoggingPolicy": true,
"@aws-cdk/aws-sns-subscriptions:restrictSqsDescryption": true,
"@aws-cdk/aws-apigateway:disableCloudWatchRole": true,
"@aws-cdk/core:enablePartitionLiterals": true,
"@aws-cdk/aws-events:eventsTargetQueueSameAccount": true,
"@aws-cdk/aws-ecs:disableExplicitDeploymentControllerForCircuitBreaker": true,
"@aws-cdk/aws-iam:importedRoleStackSafeDefaultPolicyName": true,
"@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy": true,
"@aws-cdk/aws-route53-patters:useCertificate": true,
"@aws-cdk/customresources:installLatestAwsSdkDefault": false,
"@aws-cdk/aws-rds:databaseProxyUniqueResourceName": true,
"@aws-cdk/aws-codedeploy:removeAlarmsFromDeploymentGroup": true,
"@aws-cdk/aws-apigateway:authorizerChangeDeploymentLogicalId": true,
"@aws-cdk/aws-ec2:launchTemplateDefaultUserData": true,
"@aws-cdk/aws-secretsmanager:useAttachedSecretResourcePolicyForSecretTargetAttachments": true,
"@aws-cdk/aws-redshift:columnId": true,
"@aws-cdk/aws-stepfunctions-tasks:enableEmrServicePolicyV2": true,
"@aws-cdk/aws-ec2:restrictDefaultSecurityGroup": true,
"@aws-cdk/aws-apigateway:requestValidatorUniqueId": true,
"@aws-cdk/aws-kms:aliasNameRef": true,
"@aws-cdk/aws-kms:applyImportedAliasPermissionsToPrincipal": true,
"@aws-cdk/aws-autoscaling:generateLaunchTemplateInsteadOfLaunchConfig": true,
"@aws-cdk/core:includePrefixInUniqueNameGeneration": true,
"@aws-cdk/aws-efs:denyAnonymousAccess": true,
"@aws-cdk/aws-opensearchservice:enableOpensearchMultiAzWithStandby": true,
"@aws-cdk/aws-lambda-nodejs:useLatestRuntimeVersion": true,
"@aws-cdk/aws-efs:mountTargetOrderInsensitiveLogicalId": true,
"@aws-cdk/aws-rds:auroraClusterChangeScopeOfInstanceParameterGroupWithEachParameters": true,
"@aws-cdk/aws-appsync:useArnForSourceApiAssociationIdentifier": true,
"@aws-cdk/aws-rds:preventRenderingDeprecatedCredentials": true,
"@aws-cdk/aws-codepipeline-actions:useNewDefaultBranchForCodeCommitSource": true,
"@aws-cdk/aws-cloudwatch-actions:changeLambdaPermissionLogicalIdForLambdaAction": true,
"@aws-cdk/aws-codepipeline:crossAccountKeysDefaultValueToFalse": true,
"@aws-cdk/aws-codepipeline:defaultPipelineTypeToV2": true,
"@aws-cdk/aws-kms:reduceCrossAccountRegionPolicyScope": true,
"@aws-cdk/aws-eks:nodegroupNameAttribute": true,
"@aws-cdk/aws-ec2:ebsDefaultGp3Volume": true,
"@aws-cdk/aws-ecs:removeDefaultDeploymentAlarm": true,
"@aws-cdk/custom-resources:logApiResponseDataPropertyTrueDefault": false,
"@aws-cdk/aws-s3:keepNotificationInImportedBucket": false,
"@aws-cdk/core:explicitStackTags": true,
"@aws-cdk/aws-ecs:enableImdsBlockingDeprecatedFeature": false,
"@aws-cdk/aws-ecs:disableEcsImdsBlocking": true,
"@aws-cdk/aws-ecs:reduceEc2FargateCloudWatchPermissions": true,
"@aws-cdk/aws-dynamodb:resourcePolicyPerReplica": true,
"@aws-cdk/aws-ec2:ec2SumTImeoutEnabled": true,
"@aws-cdk/aws-appsync:appSyncGraphQLAPIScopeLambdaPermission": true,
"@aws-cdk/aws-rds:setCorrectValueForDatabaseInstanceReadReplicaInstanceResourceId": true,
"@aws-cdk/core:cfnIncludeRejectComplexResourceUpdateCreatePolicyIntrinsics": true,
"@aws-cdk/aws-lambda-nodejs:sdkV3ExcludeSmithyPackages": true,
"@aws-cdk/aws-stepfunctions-tasks:fixRunEcsTaskPolicy": true,
"@aws-cdk/aws-ec2:bastionHostUseAmazonLinux2023ByDefault": true,
"@aws-cdk/aws-route53-targets:userPoolDomainNameMethodWithoutCustomResource": true,
"@aws-cdk/aws-elasticloadbalancingV2:albDualstackWithoutPublicIpv4SecurityGroupRulesDefault": true,
"@aws-cdk/aws-iam:oidcRejectUnauthorizedConnections": true,
"@aws-cdk/core:enableAdditionalMetadataCollection": true,
"@aws-cdk/aws-lambda:createNewPoliciesWithAddToRolePolicy": false,
"@aws-cdk/aws-s3:setUniqueReplicationRoleName": true,
"@aws-cdk/aws-events:requireEventBusPolicySid": true,
"@aws-cdk/core:aspectPrioritiesMutating": true,
"@aws-cdk/aws-dynamodb:retainTableReplica": true,
"@aws-cdk/aws-stepfunctions:useDistributedMapResultWriterV2": true,
"@aws-cdk/s3-notifications:addS3TrustKeyPolicyForSnsSubscriptions": true,
"@aws-cdk/aws-ec2:requirePrivateSubnetsForEgressOnlyInternetGateway": true,
"@aws-cdk/aws-s3:publicAccessBlockedByDefault": true,
"@aws-cdk/aws-lambda:useCdkManagedLogGroup": true,
"@aws-cdk/aws-elasticloadbalancingv2:networkLoadBalancerWithSecurityGroupByDefault": true,
"@aws-cdk/aws-ecs-patterns:uniqueTargetGroupId": true
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
module.exports = {
testEnvironment: 'node',
roots: ['<rootDir>/test'],
testMatch: ['**/*.test.ts'],
transform: {
'^.+\\.tsx?$': 'ts-jest'
},
setupFilesAfterEnv: ['aws-cdk-lib/testhelpers/jest-autoclean'],
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import json
import os
from datetime import datetime
import boto3
from botocore.exceptions import ClientError

dynamodb = boto3.resource('dynamodb')
table_name = os.environ.get('TABLE_NAME', 'saga-car-rentals')
table = dynamodb.Table(table_name)

def lambda_handler(event, context):
"""
Cancel a car rental by updating the status
Handles both direct invocation and API Gateway events
"""
try:
# Handle different event formats
if isinstance(event, str):
event = json.loads(event)

# If event has a 'body' field (API Gateway format), parse it
if 'body' in event:
body = json.loads(event['body']) if isinstance(event['body'], str) else event['body']
else:
body = event

# Extract rental ID from event
rental_id = body.get('rentalId')

if not rental_id:
return {
'statusCode': 400,
'body': json.dumps({
'message': 'rentalId is required'
})
}

# Check if rental exists
response = table.get_item(Key={'rentalId': rental_id})

if 'Item' not in response:
return {
'statusCode': 404,
'body': json.dumps({
'message': f'Rental {rental_id} not found'
})
}

# Update rental status to CANCELLED
table.update_item(
Key={'rentalId': rental_id},
UpdateExpression='SET #status = :status, updatedAt = :updatedAt',
ExpressionAttributeNames={'#status': 'status'},
ExpressionAttributeValues={
':status': 'CANCELLED',
':updatedAt': datetime.utcnow().isoformat()
}
)

return {
'statusCode': 200,
'body': json.dumps({
'message': 'Car rental cancelled successfully',
'rentalId': rental_id,
'status': 'CANCELLED'
})
}

except ClientError as e:
error_msg = f"DynamoDB error: {e.response['Error']['Message']}"
print(error_msg)
return {
'statusCode': 500,
'body': json.dumps({
'message': 'Failed to cancel car rental',
'error': e.response['Error']['Message'],
'errorType': 'DynamoDBError'
})
}
except json.JSONDecodeError as e:
error_msg = f"JSON parsing error: {str(e)}"
print(error_msg)
return {
'statusCode': 400,
'body': json.dumps({
'message': 'Invalid JSON in request',
'error': str(e),
'errorType': 'JSONDecodeError'
})
}
except Exception as e:
error_msg = f"Unexpected error: {str(e)}"
print(error_msg)
return {
'statusCode': 500,
'body': json.dumps({
'message': 'Failed to cancel car rental',
'error': str(e),
'errorType': type(e).__name__
})
}
Loading