diff --git a/lambda-durable-functions-human-in-the-loop/README.md b/lambda-durable-functions-human-in-the-loop/README.md new file mode 100644 index 000000000..762d6b48a --- /dev/null +++ b/lambda-durable-functions-human-in-the-loop/README.md @@ -0,0 +1,131 @@ +# Human in the Loop with Lambda durable functions + +This pattern demonstrates how to integrate human review or approval processes into workflows using AWS Lambda durable functions. The workflow sends email notifications via Simple Notification Service (SNS) and waits for human approval through callback links, suspending execution until the decision is made. + +**Important**: This application uses various AWS services and there are costs associated with these services after the Free Tier usage - please see the [AWS Pricing page](https://aws.amazon.com/pricing/) for details. You are responsible for any AWS costs incurred. No warranty is implied in this example. + +## Requirements + +- Create an AWS account if you do not already have one and log in. The IAM user that you use must have sufficient permissions to make necessary AWS service calls and manage AWS resources. +- [AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/install-cliv2.html) installed and configured +- [Git Installed](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) +- [Node.js and npm](https://nodejs.org/en/download/) installed +- [AWS CDK](https://docs.aws.amazon.com/cdk/latest/guide/getting_started.html) installed + +## Deployment Instructions + +1. Create a new directory, navigate to that directory in a terminal and clone the GitHub repository: + ```bash + git clone https://github.com/aws-samples/serverless-patterns + ``` + +2. Change directory to the pattern directory: + ```bash + cd lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk + ``` + +3. Install dependencies: + ```bash + npm install + ``` + +4. Bootstrap your AWS environment (if you don't have a CDK environment setup): + ```bash + cdk bootstrap + ``` + +5. Deploy the CDK stack with your email address: + ```bash + cdk deploy --context Email=your-email@example.com + ``` + Replace `your-email@example.com` with the email address that should receive approval notifications. + +6. **Confirm your SNS subscription**: After deployment, check your email inbox for a message with the subject **"AWS Notification - Subscription Confirmation"**. The email will look like this: + + ``` + You have chosen to subscribe to the topic: + arn:aws:sns:::hitl-approval-notifications + + To confirm this subscription, click or visit the link below + (If this was in error no action is necessary): + Confirm subscription + ``` + + **Click the "Confirm subscription" link.** This step is required before you can receive approval emails. + + > **Note**: Check your spam/junk folder if you don't see the email within a few minutes. + +7. Note the outputs from the CDK deployment process. These contain the resource names and/or ARNs which are used for testing: + - `Hitl-ApiUrl`: The API Gateway URL for callbacks + - `Hitl-Sns-TopicArn`: The SNS Topic ARN for approval notifications + - `Hitl-Durable-Function-Name`: The name of the durable function + +## How it works + +![Architecture](./images/human-in-the-loop-architecture.svg) + +1. **Document Submission**: An employee submits a document (e.g., expense report) that requires reviewer approval +2. **Validation**: The durable function validates the submission and extracts key details +3. **Token Storage**: The workflow generates a short approval ID (e.g., `a1b2c3d4`) and stores the callback token securely in DynamoDB +4. **Approval Request**: The workflow sends a formatted email via SNS to the manager with document details and approve/reject links +5. **Workflow Pause**: The workflow pauses using `waitForCallback()` - execution suspends without compute charges +6. **Manager Review**: The reviewer receives the email and reviews the document details +7. **Decision**: The reviewer clicks either the APPROVE or REJECT link +8. **API Gateway**: API Gateway receives the callback request with the approval ID and invokes the callback handler +9. **Token Lookup**: The callback handler looks up the actual callback token from DynamoDB using the approval ID +10. **Resume Execution**: The callback handler resumes the durable execution with the approval result +11. **Process Decision**: The workflow continues and processes the approval or rejection (e.g., initiates payment, notifies submitter) + + +![Durable Function Workflow](images/durable-operation.png) + +### Email Notification + +The email contains two clickable links with short approval IDs: + +![Email Message](images/email-message-example.png) + +### Execution Monitoring + +You can monitor the durable execution in the Lambda console, seeing each step's status. + +## Testing + +1. After deployment, you will receive an email titled "AWS Notification - Subscription Confirmation". Click on the link in the email to confirm your subscription. This will allow SNS to send you emails. + +2. Navigate to the AWS Lambda console and select the `hitl-durable-function` function. + +3. Create an asynchronous test event with the `hitl-lambda-durable-function-cdk/events/events-large-amount.json` payload + +4. Invoke the function. The durable execution will start and send an approval email. + +5. **Test via Email Links** + + Check your email for the approval request. The email will contain two links: + + + - **APPROVE**: Click this to approve the request + - **REJECT**: Click this to reject the request + + Click one of the links to complete the approval process. + +6. Observe the execution in the Lambda console. The durable function will complete with the approval result: + + ![Durable-Functions-Successful-execution](images/durable-function-success.png) + +7. Check the Durable Functions Logs for the durable function to see the workflow execution details. See the following rejection example: +![Rejected-Human-Decicison](images/human-decision-rejected.png) + +## Cleanup + +To delete the resources created by this template, use the following command: + +```bash +cdk destroy -c Email=your-email@example.com +``` + +--- + +Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved. + +SPDX-License-Identifier: MIT-0 diff --git a/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/.gitignore b/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/.gitignore new file mode 100644 index 000000000..f60797b6a --- /dev/null +++ b/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/.gitignore @@ -0,0 +1,8 @@ +*.js +!jest.config.js +*.d.ts +node_modules + +# CDK asset staging directory +.cdk.staging +cdk.out diff --git a/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/.npmignore b/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/.npmignore new file mode 100644 index 000000000..c1d6d45dc --- /dev/null +++ b/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/.npmignore @@ -0,0 +1,6 @@ +*.ts +!*.d.ts + +# CDK asset staging directory +.cdk.staging +cdk.out diff --git a/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/README.md b/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/README.md new file mode 100644 index 000000000..c96388ed8 --- /dev/null +++ b/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/README.md @@ -0,0 +1,285 @@ +# Human in the Loop - CDK Implementation + +This directory contains the AWS CDK implementation of the Human-in-the-Loop pattern using Lambda durable functions. + +## What's Included + +This CDK stack deploys: + +- **Lambda Durable Function** - Orchestrates the HITL workflow with automatic checkpointing and retry logic +- **Callback Handler Lambda** - Processes approval/rejection callbacks from email links +- **DynamoDB Table** - Stores callback tokens securely with 1-hour TTL (matches callback timeout) +- **SNS Topic** - Sends email notifications with approval links +- **API Gateway** - Provides callback endpoint for email links +- **IAM Roles** - Properly scoped permissions for all resources +- **CloudWatch Logs** - Structured JSON logging for all functions + +## Architecture + +``` +┌─────────────────┐ +│ Durable │ +│ Function │──────┐ +│ (Orchestrator) │ │ +└─────────────────┘ │ + │ │ + ▼ ▼ + ┌─────────────┐ ┌─────────────┐ + │ DynamoDB │ │ SNS Topic │ + │ (Tokens) │ └─────────────┘ + └─────────────┘ │ + ▲ ▼ + │ ┌─────────────┐ + │ │ Email │ + │ │ (short IDs) │ + │ └─────────────┘ + │ │ + │ ▼ + │ ┌─────────────┐ + │ │ API Gateway │ + │ │ /verify │ + │ └─────────────┘ + │ │ + │ ▼ + │ ┌─────────────┐ + └────────│ Callback │ + │ Handler │ + └─────────────┘ +``` + +## Prerequisites + +- [Node.js 20+](https://nodejs.org/) +- [AWS CLI](https://aws.amazon.com/cli/) configured with credentials +- [AWS CDK](https://docs.aws.amazon.com/cdk/latest/guide/getting_started.html) installed globally: `npm install -g aws-cdk` +- An AWS account with appropriate permissions + +## Deployment + +1. Install dependencies: + ```bash + npm install + ``` + +2. Bootstrap CDK (first time only): + ```bash + cdk bootstrap + ``` + +3. Deploy the stack with your email address: + ```bash + cdk deploy --context Email=your-email@example.com + ``` + +4. **Confirm your SNS subscription**: Check your email inbox for a message with the subject **"AWS Notification - Subscription Confirmation"**. The email will contain: + + ``` + You have chosen to subscribe to the topic: + arn:aws:sns:::hitl-approval-notifications + + To confirm this subscription, click or visit the link below + (If this was in error no action is necessary): + Confirm subscription + ``` + + **Click the "Confirm subscription" link.** Without this confirmation, you won't receive approval emails. + + > **Tip**: Check your spam/junk folder if you don't see the email. + +5. Note the stack outputs: + - `Hitl-ApiUrl` - API Gateway endpoint for callbacks + - `Hitl-Sns-TopicArn` - SNS topic ARN + - `Hitl-Durable-Function-Name` - Durable function name + - `Hitl-Callback-Table-Name` - DynamoDB table for callback tokens + +## Testing + +1. **Confirm SNS subscription**: Check your email for "AWS Notification - Subscription Confirmation" and click the confirmation link. + +2. **Invoke the durable function** in the Lambda console with this test event (expense report example): + ```json + { + "submissionId": "DOC-2026-001234", + "submissionType": "expense_report", + "submitter": { + "name": "Alice Johnson", + "email": "alice.johnson@example.com", + "department": "Engineering", + "employeeId": "EMP-5678" + }, + "document": { + "title": "Q1 2026 Conference Travel Expenses", + "amount": 2450.00, + "currency": "USD", + "category": "Travel & Entertainment", + "description": "AWS re:Invent 2026 conference attendance", + "items": [ + { + "description": "Round-trip flight", + "amount": 850.00, + "date": "2026-11-28" + } + ], + "attachments": ["s3://receipts/flight.pdf"] + }, + "submittedAt": "2026-02-13T14:30:00Z" + } + ``` + + See `events/` folder for more examples. + +3. **Check your email** for the approval request with document details and APPROVE/REJECT links. + +4. **Click a link** to complete the workflow, or use the console "Send success" feature to manually complete the callback. + +## Project Structure + +``` +hitl-lambda-durable-function-cdk/ +├── bin/ +│ └── hitl-lambda-durable-function-cdk.ts # CDK app entry point +├── lib/ +│ └── hitl-lambda-durable-function-cdk-stack.ts # Stack definition +├── lambdas/ +│ ├── hitl-durable-functions/ +│ │ ├── index.mjs # Durable function orchestrator +│ │ └── package.json # Dependencies +│ └── callback-handler/ +│ ├── index.mjs # Callback handler +│ └── package.json # Dependencies +├── events/ +│ ├── event.json # Test event +│ ├── callback-approve.json # Approval payload +│ └── callback-reject.json # Rejection payload +├── test/ +│ └── hitl-lambda-durable-function-cdk.test.ts # Unit tests +├── cdk.json # CDK configuration +├── package.json # Project dependencies +└── tsconfig.json # TypeScript configuration +``` + +## Key CDK Constructs + +### Durable Function Configuration + +```typescript +const hitlDurableFunction = new NodejsFunction(this, 'HitlDurableFunction', { + runtime: aws_lambda.Runtime.NODEJS_22_X, + durableConfig: { + executionTimeout: Duration.hours(1), + retentionPeriod: Duration.days(30) + }, + environment: { + SNS_TOPIC_ARN: approvalTopic.topicArn, + API_URL: api.url, + }, +}); +``` + +### SNS Email Subscription + +```typescript +const approvalTopic = new aws_sns.Topic(this, 'ApprovalTopic', { + displayName: 'Human Approval Notifications', +}); + +approvalTopic.addSubscription(new EmailSubscription(email)); +``` + +### API Gateway with Callback Handler + +```typescript +const api = new aws_apigateway.RestApi(this, 'HitlCallbackApi', { + restApiName: 'HITL-Callback-API', +}); + +const verifyResource = api.root.addResource('verify'); +verifyResource.addMethod('GET', new aws_apigateway.LambdaIntegration(callbackHandler)); +``` + +## Environment Variables + +The durable function uses these environment variables: + +- `SNS_TOPIC_ARN` - ARN of the SNS topic for sending emails +- `API_URL` - API Gateway URL for callback links +- `CALLBACK_TABLE_NAME` - DynamoDB table name for storing callback tokens + +The callback handler uses: + +- `CALLBACK_TABLE_NAME` - DynamoDB table name for looking up callback tokens + +## IAM Permissions + +The stack creates these IAM policies: + +- **Durable Function**: SNS publish permissions, DynamoDB write permissions +- **Callback Handler**: Lambda durable execution callback permissions, DynamoDB read permissions + +## Monitoring + +View logs in CloudWatch: + +```bash +# Durable function logs +aws logs tail /aws/lambda/hitl-durable-function --follow + +# Callback handler logs +aws logs tail /aws/lambda/hitl-callback-handler --follow +``` + +Monitor durable executions in the Lambda console under the "Durable executions" tab. + +## Cleanup + +Remove all resources: + +```bash +cdk destroy +``` + +This will delete: +- Lambda functions +- API Gateway +- SNS topic and subscriptions +- IAM roles +- CloudWatch log groups + +## Useful CDK Commands + +- `npm run build` - Compile TypeScript to JavaScript +- `npm run watch` - Watch for changes and compile +- `npm run test` - Run Jest unit tests +- `cdk diff` - Compare deployed stack with current state +- `cdk synth` - Emit synthesized CloudFormation template +- `cdk deploy` - Deploy stack to AWS +- `cdk destroy` - Remove all stack resources + +## Troubleshooting + +**Issue**: Email not received +- Check spam/junk folder +- Verify SNS subscription was confirmed +- Check SNS topic subscriptions in AWS Console + +**Issue**: Callback link doesn't work +- Verify API Gateway URL in environment variables +- Check CloudWatch logs for errors +- Ensure callback handler has correct IAM permissions + +**Issue**: Deployment fails +- Ensure CDK is bootstrapped: `cdk bootstrap` +- Check AWS credentials are configured +- Verify you have necessary IAM permissions + +## Learn More + +- [AWS Lambda Durable Functions](https://aws.amazon.com/lambda/lambda-durable-functions/) +- [AWS CDK Documentation](https://docs.aws.amazon.com/cdk/) +- [Lambda Durable Functions SDK (JavaScript)](https://github.com/aws/durable-execution-sdk-js) + +--- + +Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +SPDX-License-Identifier: MIT-0 diff --git a/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/bin/hitl-lambda-durable-function-cdk.ts b/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/bin/hitl-lambda-durable-function-cdk.ts new file mode 100644 index 000000000..95a51ea24 --- /dev/null +++ b/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/bin/hitl-lambda-durable-function-cdk.ts @@ -0,0 +1,21 @@ +#!/usr/bin/env node +import * as cdk from 'aws-cdk-lib/core'; +import { HitlLambdaDurableFunctionCdkStack } from '../lib/hitl-lambda-durable-function-cdk-stack'; + +const app = new cdk.App(); +new HitlLambdaDurableFunctionCdkStack(app, 'HitlLambdaDurableFunctionCdkStack', { + description: "This is a human in the loop pattern using AWS Lambda Durable Functions." + /* 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 */ +}); diff --git a/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/cdk.json b/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/cdk.json new file mode 100644 index 000000000..6d90fc02d --- /dev/null +++ b/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/cdk.json @@ -0,0 +1,103 @@ +{ + "app": "npx ts-node --prefer-ts-exts bin/hitl-lambda-durable-function-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 + } +} diff --git a/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/events/event-large-amount.json b/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/events/event-large-amount.json new file mode 100644 index 000000000..a65528f03 --- /dev/null +++ b/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/events/event-large-amount.json @@ -0,0 +1,34 @@ +{ + "submissionId": "DOC-2026-001236", + "submissionType": "equipment_purchase", + "submitter": { + "name": "Carol Davis", + "email": "carol.davis@example.com", + "department": "IT", + "employeeId": "EMP-3456" + }, + "document": { + "title": "Development Team Laptop Upgrades", + "amount": 15750.00, + "currency": "USD", + "category": "Equipment & Hardware", + "description": "Purchase of 15 MacBook Pro laptops for engineering team to support new AI/ML development initiatives", + "items": [ + { + "description": "MacBook Pro 16\" M3 Max (Qty: 15)", + "amount": 14250.00, + "date": "2026-02-12" + }, + { + "description": "AppleCare+ Protection (Qty: 15)", + "amount": 1500.00, + "date": "2026-02-12" + } + ], + "attachments": [ + "s3://expense-receipts/2026/carol-davis/apple-quote.pdf", + "s3://expense-receipts/2026/carol-davis/budget-justification.pdf" + ] + }, + "submittedAt": "2026-02-13T09:15:00Z" +} diff --git a/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/events/event-simple.json b/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/events/event-simple.json new file mode 100644 index 000000000..bf1865c26 --- /dev/null +++ b/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/events/event-simple.json @@ -0,0 +1,28 @@ +{ + "submissionId": "DOC-2026-001235", + "submissionType": "expense_report", + "submitter": { + "name": "Bob Smith", + "email": "bob.smith@example.com", + "department": "Sales", + "employeeId": "EMP-9012" + }, + "document": { + "title": "Client Lunch Meeting", + "amount": 125.50, + "currency": "USD", + "category": "Meals & Entertainment", + "description": "Lunch meeting with potential client to discuss Q2 partnership opportunities", + "items": [ + { + "description": "Restaurant bill", + "amount": 125.50, + "date": "2026-02-10" + } + ], + "attachments": [ + "s3://expense-receipts/2026/bob-smith/lunch-receipt.pdf" + ] + }, + "submittedAt": "2026-02-13T16:45:00Z" +} diff --git a/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/events/event.json b/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/events/event.json new file mode 100644 index 000000000..ea9c8f264 --- /dev/null +++ b/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/events/event.json @@ -0,0 +1,39 @@ +{ + "submissionId": "DOC-2026-001234", + "submissionType": "expense_report", + "submitter": { + "name": "Alice Johnson", + "email": "alice.johnson@example.com", + "department": "Engineering", + "employeeId": "EMP-5678" + }, + "document": { + "title": "Q1 2026 Conference Travel Expenses", + "amount": 2450.00, + "currency": "USD", + "category": "Travel & Entertainment", + "description": "AWS re:Invent 2026 conference attendance including flights, hotel, and meals", + "items": [ + { + "description": "Round-trip flight to Las Vegas", + "amount": 850.00, + "date": "2026-11-28" + }, + { + "description": "Hotel accommodation (4 nights)", + "amount": 1200.00, + "date": "2026-11-28" + }, + { + "description": "Meals and transportation", + "amount": 400.00, + "date": "2026-11-28" + } + ], + "attachments": [ + "s3://expense-receipts/2026/alice-johnson/flight-receipt.pdf", + "s3://expense-receipts/2026/alice-johnson/hotel-invoice.pdf" + ] + }, + "submittedAt": "2026-02-13T14:30:00Z" +} diff --git a/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/jest.config.js b/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/jest.config.js new file mode 100644 index 000000000..fe2e9f679 --- /dev/null +++ b/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/jest.config.js @@ -0,0 +1,9 @@ +module.exports = { + testEnvironment: 'node', + roots: ['/test'], + testMatch: ['**/*.test.ts'], + transform: { + '^.+\\.tsx?$': 'ts-jest' + }, + setupFilesAfterEnv: ['aws-cdk-lib/testhelpers/jest-autoclean'], +}; diff --git a/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/lambdas/callback-handler/index.mjs b/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/lambdas/callback-handler/index.mjs new file mode 100644 index 000000000..e2f0fdc38 --- /dev/null +++ b/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/lambdas/callback-handler/index.mjs @@ -0,0 +1,74 @@ +import { LambdaClient, SendDurableExecutionCallbackSuccessCommand } from "@aws-sdk/client-lambda"; +import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; +import { DynamoDBDocumentClient, GetCommand } from "@aws-sdk/lib-dynamodb"; + +const lambdaClient = new LambdaClient({}); +const ddbClient = new DynamoDBClient({}); +const docClient = DynamoDBDocumentClient.from(ddbClient); + +export const handler = async (event) => { + // Extract approval ID and action from query string + const approvalId = event.queryStringParameters?.id; + const action = event.queryStringParameters?.action || 'approve'; + + console.log('Callback received:', { approvalId, action }); + + if (!approvalId) { + return { + statusCode: 400, + headers: { 'Content-Type': 'text/html' }, + body: '

An Error occurred!

Missing approval ID

', + }; + } + + try { + // Look up the callback token from DynamoDB + const result = await docClient.send(new GetCommand({ + TableName: process.env.CALLBACK_TABLE_NAME, + Key: { approvalId: approvalId } + })); + + if (!result.Item) { + return { + statusCode: 404, + headers: { 'Content-Type': 'text/html' }, + body: '

An Error occurred!

Approval ID not found or expired

', + }; + } + + const callbackId = result.Item.callbackId; + + // Send callback success to resume the durable function + const callbackResult = { + approved: action === 'approve', + approvalId: approvalId, + timestamp: new Date().toISOString() + }; + + const command = new SendDurableExecutionCallbackSuccessCommand({ + CallbackId: callbackId, + Result: JSON.stringify(callbackResult) + }); + + await lambdaClient.send(command); + + const message = action === 'approve' + ? '

Request Approved

Thank you for approving this request!

' + : '

Request Rejected

You have rejected this request!

'; + + return { + statusCode: 200, + headers: { + 'Content-Type': 'text/html', + }, + body: `${message}`, + }; + } catch (error) { + console.error('Error resuming workflow:', error); + return { + statusCode: 500, + headers: { 'Content-Type': 'text/html' }, + body: '

An Error occurred

Failed to process approval. The link may have expired.

', + }; + } +}; diff --git a/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/lambdas/callback-handler/package.json b/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/lambdas/callback-handler/package.json new file mode 100644 index 000000000..0bbc6f41b --- /dev/null +++ b/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/lambdas/callback-handler/package.json @@ -0,0 +1,10 @@ +{ + "name": "callback-handler", + "version": "1.0.0", + "type": "module", + "dependencies": { + "@aws-sdk/client-lambda": "^3.0.0", + "@aws-sdk/client-dynamodb": "^3.0.0", + "@aws-sdk/lib-dynamodb": "^3.0.0" + } +} diff --git a/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/lambdas/hitl-durable-function/index.mjs b/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/lambdas/hitl-durable-function/index.mjs new file mode 100644 index 000000000..259b9d8dc --- /dev/null +++ b/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/lambdas/hitl-durable-function/index.mjs @@ -0,0 +1,223 @@ +import { + withDurableExecution, +} from "@aws/durable-execution-sdk-js"; +import { SNSClient, PublishCommand } from "@aws-sdk/client-sns"; +import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; +import { DynamoDBDocumentClient, PutCommand } from "@aws-sdk/lib-dynamodb"; +import { randomUUID } from 'crypto'; + +const snsClient = new SNSClient({}); +const ddbClient = new DynamoDBClient({}); +const docClient = DynamoDBDocumentClient.from(ddbClient); + +const storeCallbackToken = async (callbackId, submission) => { + const approvalId = randomUUID().split('-')[0]; // Short ID like "a1b2c3d4" + // TTL matches callback timeout (1 hour) + 5 minute buffer + const ttl = Math.floor(Date.now() / 1000) + (65 * 60); // 1 hour 5 minutes + + await docClient.send(new PutCommand({ + TableName: process.env.CALLBACK_TABLE_NAME, + Item: { + approvalId: approvalId, + callbackId: callbackId, + submissionId: submission.submissionId, + submitterEmail: submission.submitter.email, + ttl: ttl, + createdAt: new Date().toISOString() + } + })); + + return approvalId; +}; + +const sendApprovalRequest = async (submission, callbackId) => { + try { + // Store callback token in DynamoDB and get short approval ID + const approvalId = await storeCallbackToken(callbackId, submission); + + const baseUrl = `${process.env.API_URL}verify`; + const approveUrl = `${baseUrl}?id=${approvalId}&action=approve`; + const rejectUrl = `${baseUrl}?id=${approvalId}&action=reject`; + + // Format line items for email + const itemsList = submission.document.items + .map(item => ` • ${item.description}: $${item.amount.toFixed(2)}`) + .join('\n'); + + const message = ` + APPROVAL REQUIRED: ${submission.document.title} + + Submission ID: ${submission.submissionId} + Submitted by: ${submission.submitter.name} (${submission.submitter.department}) + Employee ID: ${submission.submitter.employeeId} + + Document Details: + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Category: ${submission.document.category} + Total Amount: $${submission.document.amount.toFixed(2)} ${submission.document.currency} + + Description: + ${submission.document.description} + + Expense Breakdown: + ${itemsList} + + Attachments: ${submission.document.attachments.length} file(s) + ${submission.document.attachments.map(a => ` • ${a.split('/').pop()}`).join('\n')} + + Submitted: ${new Date(submission.submittedAt).toLocaleString()} + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + Please review and take action: + + ✓ APPROVE: ${approveUrl} + + ✗ REJECT: ${rejectUrl} + + This link will expire in 1 hour. + + Note: Approval ID for reference: ${approvalId} + `.trim(); + + const params = { + TopicArn: process.env.SNS_TOPIC_ARN, + Subject: `Approval Required: ${submission.document.title} - $${submission.document.amount.toFixed(2)}`, + Message: message + }; + const command = new PublishCommand(params); + const result = await snsClient.send(command); + return { + status: "succeeded", + result: result, + approvalId: approvalId + }; + } catch (error) { + console.log("An error occurred sending approval request: ", error) + return { + status: "failed", + error: error + }; + } +}; + +const handler = async (event, context) => { + try { + // Use the context logger for structured logging + context.logger.info("Starting HITL workflow for document submission", { + submissionId: event.submissionId, + submissionType: event.submissionType + }); + + // Validate required fields + if (!event.submissionId) { + return { error: "Missing submissionId" }; + } + if (!event.submitter || !event.submitter.email || !event.submitter.name) { + return { error: "Missing submitter information" }; + } + if (!event.document) { + return { error: "Missing document information" }; + } + + // Step 1: Validate and load submission data + const submission = await context.step("validate-submission", async () => { + context.logger.info("Validating submission", { + submissionId: event.submissionId, + amount: event.document.amount + }); + + return { + submissionId: event.submissionId, + submissionType: event.submissionType, + submitter: event.submitter, + document: event.document, + submittedAt: event.submittedAt, + status: "pending_approval" + }; + }); + + context.logger.info("Submission validated", { + submissionId: submission.submissionId, + status: submission.status + }); + + // Step 2: Send approval request and wait for human decision + context.logger.info("Sending approval request to reviewer..."); + + const approval_result = await context.waitForCallback( + "wait-for-reviewer-approval", + async (callbackId) => { + await sendApprovalRequest(submission, callbackId); + }, + { + timeout: { hours: 1 } + } + ); + + context.logger.info("Approval decision received", { + approved: approval_result.approved, + approvalId: approval_result.approvalId + }); + + // Step 3: Process the approval decision + const result = await context.step("process-approval-decision", async () => { + if (approval_result.approved === true) { + context.logger.info(`Submission APPROVED`, { + submissionId: submission.submissionId, + submitter: submission.submitter.name + }); + + return { + status: "approved", + submissionId: submission.submissionId, + submitter: submission.submitter, + document: submission.document, + approvalDetails: { + approvedAt: approval_result.timestamp, + approvalId: approval_result.approvalId + }, + nextSteps: [ + "Payment processing initiated", + "Submitter notified", + "Documents archived" + ] + }; + } else { + context.logger.info(`Submission REJECTED`, { + submissionId: submission.submissionId, + submitter: submission.submitter.name + }); + + return { + status: "rejected", + submissionId: submission.submissionId, + submitter: submission.submitter, + document: submission.document, + rejectionDetails: { + rejectedAt: approval_result.timestamp, + approvalId: approval_result.approvalId + }, + nextSteps: [ + "Submitter notified of rejection", + "Resubmission instructions sent" + ] + }; + } + }); + + context.logger.info("HITL workflow completed", { + status: result.status, + submissionId: result.submissionId + }); + + return result; + } catch (error) { + context.logger.error("Workflow failed", { + error: error.message, + stack: error.stack + }); + throw error; + } +}; + +export const lambdaHandler = withDurableExecution(handler); diff --git a/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/lambdas/hitl-durable-function/package.json b/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/lambdas/hitl-durable-function/package.json new file mode 100644 index 000000000..19a442dd5 --- /dev/null +++ b/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/lambdas/hitl-durable-function/package.json @@ -0,0 +1,19 @@ +{ + "name": "human-in-the-loop", + "version": "1.0.0", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "", + "license": "ISC", + "description": "", + "dependencies": { + "@aws/durable-execution-sdk-js": "^1.0.2", + "@aws/durable-execution-sdk-js-eslint-plugin": "^0.0.1", + "@aws/durable-execution-sdk-js-testing": "^0.0.1", + "@aws-sdk/client-sns": "^3.0.0", + "@aws-sdk/client-dynamodb": "^3.0.0", + "@aws-sdk/lib-dynamodb": "^3.0.0" + } +} diff --git a/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/lib/hitl-lambda-durable-function-cdk-stack.ts b/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/lib/hitl-lambda-durable-function-cdk-stack.ts new file mode 100644 index 000000000..fc6649f08 --- /dev/null +++ b/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/lib/hitl-lambda-durable-function-cdk-stack.ts @@ -0,0 +1,136 @@ +import { aws_lambda, aws_logs, aws_sns, aws_apigateway, aws_iam, aws_dynamodb } from 'aws-cdk-lib'; +import { EmailSubscription } from 'aws-cdk-lib/aws-sns-subscriptions'; +import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs'; +import * as cdk from 'aws-cdk-lib/core'; +import { Duration, RemovalPolicy } from 'aws-cdk-lib/core'; +import { Construct } from 'constructs'; + +export class HitlLambdaDurableFunctionCdkStack extends cdk.Stack { + constructor(scope: Construct, id: string, props?: cdk.StackProps) { + super(scope, id, props); + + const email = this.node.tryGetContext('Email') as string; + if (!email) { + throw new Error('Email address is required. Please provide it using the --context flag.'); + } + + // DynamoDB table for storing callback tokens + const callbackTable = new aws_dynamodb.Table(this, 'CallbackTable', { + tableName: 'hitl-callback-tokens', + partitionKey: { + name: 'approvalId', + type: aws_dynamodb.AttributeType.STRING, + }, + billingMode: aws_dynamodb.BillingMode.PAY_PER_REQUEST, + removalPolicy: RemovalPolicy.DESTROY, + timeToLiveAttribute: 'ttl', // Auto-expire old tokens + }); + + // SNS Topic for email notifications + const approvalTopic = new aws_sns.Topic(this, 'ApprovalTopic', { + displayName: 'Human Approval Notifications', + topicName: 'hitl-approval-notifications', + }); + + // Subscribe email to SNS topic + approvalTopic.addSubscription(new EmailSubscription(email)); + + // API Gateway for callback handling + const api = new aws_apigateway.RestApi(this, 'HitlCallbackApi', { + restApiName: 'HITL-Callback-API', + description: 'this API handles human-in-the-loop callbacks', + deployOptions: { + stageName: 'prod', + }, + }); + + const verifyResource = api.root.addResource('verify'); + + // HITL orchestrator durable function + const hitlDurableFunctionLogGroup = new aws_logs.LogGroup(this, 'HitlDurableFunctionLogGroup', { + logGroupName: '/aws/lambda/hitl-durable-function', + retention: aws_logs.RetentionDays.ONE_WEEK, + removalPolicy: RemovalPolicy.DESTROY, + }); + + const hitlDurableFunction = new NodejsFunction(this, 'HitlDurableFunction', { + runtime: aws_lambda.Runtime.NODEJS_22_X, + tracing: aws_lambda.Tracing.ACTIVE, + functionName: 'hitl-durable-function', + description: 'Orchestrates human-in-the-loop workflow with email verification and approval', + loggingFormat: aws_lambda.LoggingFormat.JSON, + handler: 'index.lambdaHandler', + logGroup: hitlDurableFunctionLogGroup, + durableConfig: { + executionTimeout: Duration.hours(1), + retentionPeriod: Duration.days(30) + }, + code: aws_lambda.Code.fromAsset('lambdas/hitl-durable-function'), + environment: { + SNS_TOPIC_ARN: approvalTopic.topicArn, + API_URL: api.url, + CALLBACK_TABLE_NAME: callbackTable.tableName, + }, + }); + + // Grant permissions + approvalTopic.grantPublish(hitlDurableFunction); + callbackTable.grantWriteData(hitlDurableFunction); + + // Callback handler Lambda + const callbackHandlerLogGroup = new aws_logs.LogGroup(this, 'CallbackHandlerLogGroup', { + logGroupName: '/aws/lambda/hitl-callback-handler', + retention: aws_logs.RetentionDays.ONE_WEEK, + removalPolicy: RemovalPolicy.DESTROY, + }); + + const callbackHandler = new aws_lambda.Function(this, 'CallbackHandler', { + runtime: aws_lambda.Runtime.NODEJS_22_X, + tracing: aws_lambda.Tracing.ACTIVE, + functionName: 'hitl-callback-handler', + timeout: Duration.minutes(1), + description: 'Handles callback from human approval links in API Gateway', + loggingFormat: aws_lambda.LoggingFormat.JSON, + handler: 'index.handler', + logGroup: callbackHandlerLogGroup, + code: aws_lambda.Code.fromAsset('lambdas/callback-handler'), + environment: { + CALLBACK_TABLE_NAME: callbackTable.tableName, + }, + }); + + // Grant callback handler permission to send durable execution callbacks + callbackHandler.addToRolePolicy(new aws_iam.PolicyStatement({ + actions: ['lambda:SendDurableExecutionCallbackSuccess', 'lambda:SendDurableExecutionCallbackFailure'], + resources: ['*'], // Callback operations don't target specific function ARNs + })); + + // Grant callback handler permission to read from DynamoDB + callbackTable.grantReadData(callbackHandler); + + // API Gateway integration - GET for email callback links + const integration = new aws_apigateway.LambdaIntegration(callbackHandler); + verifyResource.addMethod('GET', integration); + + // Outputs + new cdk.CfnOutput(this, 'Hitl-ApiUrl', { + value: api.url, + description: 'API Gateway URL for callbacks', + }); + + new cdk.CfnOutput(this, 'Hitl-Sns-TopicArn', { + value: approvalTopic.topicArn, + description: 'SNS Topic ARN for approval notifications', + }); + + new cdk.CfnOutput(this, 'Hitl-Durable-Function-Name', { + value: hitlDurableFunction.functionName, + description: 'HITL Durable Function Name', + }); + + new cdk.CfnOutput(this, 'Hitl-Callback-Table-Name', { + value: callbackTable.tableName, + description: 'DynamoDB table for callback tokens', + }); + } +} diff --git a/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/package.json b/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/package.json new file mode 100644 index 000000000..992efcc2e --- /dev/null +++ b/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/package.json @@ -0,0 +1,26 @@ +{ + "name": "hitl-lambda-durable-function-cdk", + "version": "0.1.0", + "bin": { + "hitl-lambda-durable-function-cdk": "bin/hitl-lambda-durable-function-cdk.js" + }, + "scripts": { + "build": "tsc", + "watch": "tsc -w", + "test": "jest", + "cdk": "cdk" + }, + "devDependencies": { + "@types/jest": "^30", + "@types/node": "^24.10.1", + "jest": "^30", + "ts-jest": "^29", + "aws-cdk": "2.1100.3", + "ts-node": "^10.9.2", + "typescript": "~5.9.3" + }, + "dependencies": { + "aws-cdk-lib": "^2.232.2", + "constructs": "^10.0.0" + } +} diff --git a/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/test/hitl-lambda-durable-function-cdk.test.ts b/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/test/hitl-lambda-durable-function-cdk.test.ts new file mode 100644 index 000000000..2f0b4da9d --- /dev/null +++ b/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/test/hitl-lambda-durable-function-cdk.test.ts @@ -0,0 +1,17 @@ +// import * as cdk from 'aws-cdk-lib/core'; +// import { Template } from 'aws-cdk-lib/assertions'; +// import * as HitlLambdaDurableFunctionCdk from '../lib/hitl-lambda-durable-function-cdk-stack'; + +// example test. To run these tests, uncomment this file along with the +// example resource in lib/hitl-lambda-durable-function-cdk-stack.ts +test('SQS Queue Created', () => { +// const app = new cdk.App(); +// // WHEN +// const stack = new HitlLambdaDurableFunctionCdk.HitlLambdaDurableFunctionCdkStack(app, 'MyTestStack'); +// // THEN +// const template = Template.fromStack(stack); + +// template.hasResourceProperties('AWS::SQS::Queue', { +// VisibilityTimeout: 300 +// }); +}); diff --git a/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/tsconfig.json b/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/tsconfig.json new file mode 100644 index 000000000..bfc61bf83 --- /dev/null +++ b/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/tsconfig.json @@ -0,0 +1,32 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": [ + "es2022" + ], + "declaration": true, + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "noImplicitThis": true, + "alwaysStrict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": false, + "inlineSourceMap": true, + "inlineSources": true, + "experimentalDecorators": true, + "strictPropertyInitialization": false, + "skipLibCheck": true, + "typeRoots": [ + "./node_modules/@types" + ] + }, + "exclude": [ + "node_modules", + "cdk.out" + ] +} diff --git a/lambda-durable-functions-human-in-the-loop/images/durable-function-success.png b/lambda-durable-functions-human-in-the-loop/images/durable-function-success.png new file mode 100644 index 000000000..5d471c654 Binary files /dev/null and b/lambda-durable-functions-human-in-the-loop/images/durable-function-success.png differ diff --git a/lambda-durable-functions-human-in-the-loop/images/durable-operation.png b/lambda-durable-functions-human-in-the-loop/images/durable-operation.png new file mode 100644 index 000000000..a87120c42 Binary files /dev/null and b/lambda-durable-functions-human-in-the-loop/images/durable-operation.png differ diff --git a/lambda-durable-functions-human-in-the-loop/images/email-message-example.png b/lambda-durable-functions-human-in-the-loop/images/email-message-example.png new file mode 100644 index 000000000..e20f771ba Binary files /dev/null and b/lambda-durable-functions-human-in-the-loop/images/email-message-example.png differ diff --git a/lambda-durable-functions-human-in-the-loop/images/human-decision-rejected.png b/lambda-durable-functions-human-in-the-loop/images/human-decision-rejected.png new file mode 100644 index 000000000..00ccd1392 Binary files /dev/null and b/lambda-durable-functions-human-in-the-loop/images/human-decision-rejected.png differ diff --git a/lambda-durable-functions-human-in-the-loop/images/human-in-the-loop-architecture.svg b/lambda-durable-functions-human-in-the-loop/images/human-in-the-loop-architecture.svg new file mode 100644 index 000000000..55188639b --- /dev/null +++ b/lambda-durable-functions-human-in-the-loop/images/human-in-the-loop-architecture.svg @@ -0,0 +1,4 @@ + + + +
Human Reviewer
AWS Lambda Durable Function

AWS Cloud
Callback API
Amazon API Gateway

Callback handler
AWS Lambda
1. Email is sent user to approve or deny while the workflow is paused

Add Human Approval Review
Human Reviewer Email
Amazon SNS
3. API Gateway calls a proxy Lambda to process review decision 
4. Lambda sends success callback result with approval or rejection decision back to Durable Function workflow 
Update Human Approval Review Decision
2. User opens email and clicks either approve or deny link
\ No newline at end of file diff --git a/saga-pattern-lambda-durable-functions/README.md b/saga-pattern-lambda-durable-functions/README.md new file mode 100644 index 000000000..e8b3a1358 --- /dev/null +++ b/saga-pattern-lambda-durable-functions/README.md @@ -0,0 +1,435 @@ +# Saga Pattern with Lambda durable functions + +This pattern demonstrates how to implement the Saga pattern for distributed transactions using AWS Lambda durable functions. The example implements a travel booking system that coordinates flight, hotel, and car reservations with automatic compensating transactions (rollbacks) on failure. + +Durable functions are regular Lambda functions that allow you to write sequential code in your preferred programming language. They track progress, automatically retry on failures, and suspend execution for up to one year at defined points, without paying for idle compute during waits. + +This saga is built using the `aws-durable-execution-sdk` with Python 3.14 runtime and deployed using AWS CDK (TypeScript). + +**Important:** This application uses various AWS services and there are costs associated with these services after the Free Tier usage. Please see the [AWS Pricing page](https://aws.amazon.com/pricing/) for details. You are responsible for any AWS costs incurred. No warranty is implied in this example. + +## Architecture + +![Saga Architecture](./images/saga-lambda-durable-functions-architecture.svg) + +### Components + +- **Saga Orchestrator**: Durable Lambda function that coordinates the distributed transaction +- **Service Functions**: Individual Lambda functions for each service (flight, hotel, car) + - Reserve functions: Create reservations in DynamoDB + - Cancel functions: Rollback reservations (compensating transactions) +- **DynamoDB Tables**: Store reservation state for each service + +### Saga Flow + +**Success Path:** +``` +Reserve Flight → Reserve Hotel → Reserve Car → Complete +``` + +**Failure Path (with Compensation):** +``` +Reserve Flight → Reserve Hotel → Reserve Car (FAILS) + ↓ + Compensation (Reverse Order): + ↓ + Cancel Hotel + ↓ + Cancel Flight +``` + +## Requirements + +- [AWS Account](https://aws.amazon.com/free/) +- [AWS CLI](https://aws.amazon.com/cli/) installed and configured +- [Node.js 18+](https://nodejs.org/) installed +- [AWS CDK](https://aws.amazon.com/cdk/) installed (`npm install -g aws-cdk`) +- [Git](https://git-scm.com/) installed + +## Deployment Instructions + +### 1. Clone the Repository + +```bash +git clone https://github.com/aws-samples/serverless-patterns +cd serverless-patterns/saga-pattern-lambda-durable-functions/saga-pattern-cdk +``` + +### 2. Install Dependencies + +```bash +npm install +``` + +### 3. Bootstrap AWS CDK (First Time Only) + +```bash +cdk bootstrap +``` + +Or with a specific profile: + +```bash +cdk bootstrap --profile your-profile-name +``` + +### 4. Deploy the Stack + +```bash +npm run build +cdk deploy +``` + +The deployment creates: +- 3 DynamoDB tables (flight-bookings, hotel-reservations, car-rentals) +- 7 Lambda functions (1 orchestrator + 6 service functions) +- IAM roles and permissions + +### 5. Note the Outputs + +After deployment, save the function ARNs from the stack outputs: +- `SagaDurableFunctionArn` - Main orchestrator function +- `ReserveFlightFunctionArn` - Flight reservation service +- `CancelFlightFunctionArn` - Flight cancellation service +- `ReserveHotelFunctionArn` - Hotel reservation service +- `CancelHotelFunctionArn` - Hotel cancellation service +- `ReserveCarFunctionArn` - Car rental service +- `CancelCarFunctionArn` - Car cancellation service + +## How It Works + +The Saga pattern maintains data consistency across microservices without using distributed transactions. Instead, it uses: + +1. **Sequential Execution**: Services are invoked one after another +2. **State Tracking**: Each successful operation is tracked +3. **Compensating Transactions**: On failure, successful operations are undone in reverse order +4. **Idempotency**: Operations can be safely retried + +### Key Features + +- **No Distributed Locks**: Each service manages its own data independently +- **Eventual Consistency**: System reaches consistent state through compensations +- **Fault Tolerance**: Handles partial failures gracefully +- **Auditability**: Complete log trail of all operations +- **Automatic Retry**: Durable functions handle retries automatically + +## Testing + +The saga pattern implementation includes built-in failure flags for easy testing of compensating transactions. + +### Failure Flags + +Each reserve function supports a failure flag that simulates service failures: +- `failBookFlight` - Causes flight reservation to fail +- `failBookHotel` - Causes hotel reservation to fail +- `failBookCar` - Causes car reservation to fail + +When set to `true`, the service throws an exception before creating any records, triggering the saga compensation logic. + +### Test Scenario 1: Success Path (All Services Work) + +**Using AWS CLI (Async Invocation):** + +```bash +aws lambda invoke \ + --function-name saga-durable-function \ + --invocation-type Event \ + --cli-binary-format raw-in-base64-out \ + --payload file://test-success.json \ + response.json +``` + +**test-success.json:** +```json +{ + "passengerName": "Michael Johnson", + "flightNumber": "AA456", + "departure": "LAX", + "destination": "MIA", + "flightPrice": 380.00, + "guestName": "Michael Johnson", + "hotelName": "Hilton Downtown Miami", + "roomType": "Ocean View Suite", + "checkIn": "2026-04-10", + "checkOut": "2026-04-15", + "hotelPrice": 320.00, + "driverName": "Michael Johnson", + "carType": "Convertible", + "pickupLocation": "Miami Airport", + "dropoffLocation": "Miami Airport", + "pickupDate": "2026-04-10", + "dropoffDate": "2026-04-15", + "carPrice": 150.00 +} +``` + +**Expected Response:** +```json +{ + "success": true, + "transactionId": "uuid-here", + "message": "All travel arrangements completed successfully", + "bookings": { + "flight": "booking-id", + "hotel": "reservation-id", + "car": "rental-id" + } +} +``` +You can view the function output in the AWS Lambda Durable console as well: +![saga success response](./images/saga-success.png) + +**Validation:** +- All three services succeed +- DynamoDB records show `status: "RESERVED"` +- No compensations triggered + +### Test Scenario 2: Flight Fails Immediately + +**Using AWS CLI:** + +```bash +aws lambda invoke \ + --function-name saga-durable-function \ + --invocation-type Event \ + --cli-binary-format raw-in-base64-out \ + --payload file://test-fail-flight-booking.json \ + response.json +``` + +**test-fail-flight-booking.json:** +```json +{ + "passengerName": "Sarah Williams", + "departure": "ORD", + "destination": "SEA", + "failBookFlight": true +} +``` + +**Using Lambda Console:** + +1. Navigate to AWS Lambda Console +2. Open `saga-durable-function` +3. Go to "Test" tab +4. Click "Create new event" +5. Name it (e.g., "FailFlightTest") +6. Select invocation type: "Event" (for async execution) +7. Paste the JSON payload above +8. Click "Save" then "Test" + +**Expected Behavior:** +- Flight fails immediately +- Hotel and car never attempted +- No compensations needed (nothing to rollback) +- DynamoDB: No records created +![saga-failure-with-car-booking](./images/saga-failure-with-car.png) +### Test Scenario 3: Hotel Fails After Flight Succeeds + +**Using AWS CLI:** + +```bash +aws lambda invoke \ + --function-name saga-durable-function \ + --invocation-type Event \ + --cli-binary-format raw-in-base64-out \ + --payload file://test-fail-hotel-booking.json \ + response.json +``` + +**test-fail-hotel-booking.json:** +```json +{ + "passengerName": "David Martinez", + "departure": "DFW", + "destination": "BOS", + "flightPrice": 420.00, + "guestName": "David Martinez", + "hotelName": "Boston Harbor Hotel", + "failBookHotel": true +} +``` + +**Using Lambda Console:** + +Create a test event named "FailHotelTest" with invocation type "Event" (async) and the JSON payload above. + +**Expected Behavior:** +- Flight reserved +- Hotel fails +- Car never attempted +- Compensation: Flight gets cancelled +- DynamoDB: Flight record with `status: "CANCELLED"` + +**Console View:** + +After the failure, you should see the error state in the Lambda console showing the compensation process: + +![Saga Failure Console](./images/saga-failure-with-car.png) + + +### Test Scenario 4: Car Fails After Flight and Hotel Succeed + +**Using AWS CLI:** + +```bash +aws lambda invoke \ + --function-name saga-durable-function \ + --invocation-type Event \ + --cli-binary-format raw-in-base64-out \ + --payload file://test-fail-car-booking.json \ + response.json +``` + +**test-fail-car-booking.json:** +```json +{ + "passengerName": "Jane Smith", + "departure": "SFO", + "destination": "NYC", + "guestName": "Jane Smith", + "hotelName": "Marriott Times Square", + "driverName": "Jane Smith", + "failBookCar": true +} +``` + +**Using Lambda Console:** + +Create a test event named "FailCarTest" with invocation type "Event" (async) and the JSON payload above. + +**Expected Behavior:** +- Flight reserved +- Hotel reserved +- Car fails +- Compensation: Hotel cancelled, then flight cancelled (reverse order) +- DynamoDB: Flight and hotel records with `status: "CANCELLED"` + +**Console View:** + +After the failure, you should see the error state in the Lambda console showing the compensation process: + +![Saga Failure Console](./images/saga-failure-with-car.png) + +**CloudWatch Logs:** +``` +Step 1: Reserving flight... +Flight reserved successfully: +Step 2: Reserving hotel... +Hotel reserved successfully: +Step 3: Reserving car... +SIMULATED FAILURE: failBookCar flag is set to True +Error in saga workflow: Simulated car rental failure +Starting compensation (rollback) process... +Compensating: Cancelling hotel reservation +Compensating: Cancelling flight booking +Compensation process completed +``` + +### Verify Compensation in DynamoDB + +After triggering a failure, verify the compensation worked: + +```bash +# Check flight bookings +aws dynamodb scan --table-name saga-flight-bookings \ + --projection-expression "bookingId, #s, updatedAt" \ + --expression-attribute-names '{"#s":"status"}' + +# Check hotel reservations +aws dynamodb scan --table-name saga-hotel-reservations \ + --projection-expression "reservationId, #s, updatedAt" \ + --expression-attribute-names '{"#s":"status"}' + +# Check car rentals +aws dynamodb scan --table-name saga-car-rentals \ + --projection-expression "rentalId, #s, updatedAt" \ + --expression-attribute-names '{"#s":"status"}' +``` + +Look for: +- `status` field: `"RESERVED"` or `"CANCELLED"` +- `updatedAt` timestamp changes after cancellation +- No orphaned records + +### Testing in Lambda Console + +1. Navigate to AWS Lambda Console +2. Open `saga-durable-function` +3. Go to "Test" tab +4. Click "Create new event" +5. Name your test event (e.g., "SuccessTest", "FailCarTest") +6. **Important:** Select invocation type "Event" for async execution (recommended for durable functions) +7. Paste the JSON payload from any test scenario above +8. Click "Save" +9. Click "Test" to execute +10. Check CloudWatch Logs for execution results (async invocations return immediately) + +### Async vs Sync Invocation + +**Async Invocation (Event) - Recommended:** +- Lambda returns immediately with 202 Accepted +- Function executes in the background +- Check CloudWatch Logs for results +- Use `--invocation-type Event` in CLI +- Select "Event" in Lambda Console test configuration + +**Sync Invocation (RequestResponse):** +- Lambda waits for function to complete +- Returns the actual response +- Use `--invocation-type RequestResponse` in CLI (default) +- Select "RequestResponse" in Lambda Console test configuration + +For durable functions that can run for extended periods, async invocation is recommended. + +### Additional Test Files + +See `saga-pattern-cdk/lambda/test-events.json` for more test scenarios and `saga-pattern-cdk/TESTING.md` for comprehensive testing documentation. + +## Monitoring + +### CloudWatch Logs + +Each Lambda function logs to CloudWatch: +- `/aws/lambda/saga-durable-function` - Orchestrator logs +- `/aws/lambda/saga-reserve-flight` - Flight service logs +- `/aws/lambda/saga-reserve-hotel` - Hotel service logs +- `/aws/lambda/saga-reserve-car` - Car service logs + +### CloudWatch Insights Query + +Find all compensation events: + +``` +fields @timestamp, @message +| filter @message like /compensation/ +| sort @timestamp desc +| limit 100 +``` + +## Cleanup + +To avoid incurring charges, delete all resources: + +```bash +cdk destroy +``` + +Confirm the deletion when prompted. This will remove: +- All Lambda functions +- All DynamoDB tables and their data +- IAM roles and policies + +## Additional Resources + +- [AWS Lambda Durable Functions Documentation](https://docs.aws.amazon.com/lambda/latest/dg/durable-functions.html) +- [Saga Pattern Overview](https://microservices.io/patterns/data/saga.html) +- [AWS CDK Documentation](https://docs.aws.amazon.com/cdk/) + + + +--- + +Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved. + +SPDX-License-Identifier: MIT-0 diff --git a/saga-pattern-lambda-durable-functions/images/saga-failure-with-car.png b/saga-pattern-lambda-durable-functions/images/saga-failure-with-car.png new file mode 100644 index 000000000..e91123061 Binary files /dev/null and b/saga-pattern-lambda-durable-functions/images/saga-failure-with-car.png differ diff --git a/saga-pattern-lambda-durable-functions/images/saga-lambda-durable-functions-architecture.svg b/saga-pattern-lambda-durable-functions/images/saga-lambda-durable-functions-architecture.svg new file mode 100644 index 000000000..771ac1046 --- /dev/null +++ b/saga-pattern-lambda-durable-functions/images/saga-lambda-durable-functions-architecture.svg @@ -0,0 +1,4 @@ + + + +
AWS Lambda Durable Functions

AWS Cloud
Reserve_Rental

Flights Table
Rental Table
Hotels Table
Cancel Flight
Reserve_Flight
failure
Reserve_Hotel
 success
success
Cancel_Hotel
Success
 success
failure
Failed
Cancel_Rental

failure
\ No newline at end of file diff --git a/saga-pattern-lambda-durable-functions/images/saga-success.png b/saga-pattern-lambda-durable-functions/images/saga-success.png new file mode 100644 index 000000000..8eb455a4e Binary files /dev/null and b/saga-pattern-lambda-durable-functions/images/saga-success.png differ diff --git a/saga-pattern-lambda-durable-functions/saga-pattern-cdk/.gitignore b/saga-pattern-lambda-durable-functions/saga-pattern-cdk/.gitignore new file mode 100644 index 000000000..f60797b6a --- /dev/null +++ b/saga-pattern-lambda-durable-functions/saga-pattern-cdk/.gitignore @@ -0,0 +1,8 @@ +*.js +!jest.config.js +*.d.ts +node_modules + +# CDK asset staging directory +.cdk.staging +cdk.out diff --git a/saga-pattern-lambda-durable-functions/saga-pattern-cdk/.npmignore b/saga-pattern-lambda-durable-functions/saga-pattern-cdk/.npmignore new file mode 100644 index 000000000..c1d6d45dc --- /dev/null +++ b/saga-pattern-lambda-durable-functions/saga-pattern-cdk/.npmignore @@ -0,0 +1,6 @@ +*.ts +!*.d.ts + +# CDK asset staging directory +.cdk.staging +cdk.out diff --git a/saga-pattern-lambda-durable-functions/saga-pattern-cdk/README.md b/saga-pattern-lambda-durable-functions/saga-pattern-cdk/README.md new file mode 100644 index 000000000..87de4a44f --- /dev/null +++ b/saga-pattern-lambda-durable-functions/saga-pattern-cdk/README.md @@ -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. diff --git a/saga-pattern-lambda-durable-functions/saga-pattern-cdk/bin/saga-pattern-cdk.ts b/saga-pattern-lambda-durable-functions/saga-pattern-cdk/bin/saga-pattern-cdk.ts new file mode 100644 index 000000000..ca99b24c1 --- /dev/null +++ b/saga-pattern-lambda-durable-functions/saga-pattern-cdk/bin/saga-pattern-cdk.ts @@ -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 */ +}); diff --git a/saga-pattern-lambda-durable-functions/saga-pattern-cdk/cdk.json b/saga-pattern-lambda-durable-functions/saga-pattern-cdk/cdk.json new file mode 100644 index 000000000..4b5e9c99a --- /dev/null +++ b/saga-pattern-lambda-durable-functions/saga-pattern-cdk/cdk.json @@ -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 + } +} diff --git a/saga-pattern-lambda-durable-functions/saga-pattern-cdk/jest.config.js b/saga-pattern-lambda-durable-functions/saga-pattern-cdk/jest.config.js new file mode 100644 index 000000000..fe2e9f679 --- /dev/null +++ b/saga-pattern-lambda-durable-functions/saga-pattern-cdk/jest.config.js @@ -0,0 +1,9 @@ +module.exports = { + testEnvironment: 'node', + roots: ['/test'], + testMatch: ['**/*.test.ts'], + transform: { + '^.+\\.tsx?$': 'ts-jest' + }, + setupFilesAfterEnv: ['aws-cdk-lib/testhelpers/jest-autoclean'], +}; diff --git a/saga-pattern-lambda-durable-functions/saga-pattern-cdk/lambda/car/cancel_car.py b/saga-pattern-lambda-durable-functions/saga-pattern-cdk/lambda/car/cancel_car.py new file mode 100644 index 000000000..9fe3dd248 --- /dev/null +++ b/saga-pattern-lambda-durable-functions/saga-pattern-cdk/lambda/car/cancel_car.py @@ -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__ + }) + } diff --git a/saga-pattern-lambda-durable-functions/saga-pattern-cdk/lambda/car/reserve_car.py b/saga-pattern-lambda-durable-functions/saga-pattern-cdk/lambda/car/reserve_car.py new file mode 100644 index 000000000..36b258931 --- /dev/null +++ b/saga-pattern-lambda-durable-functions/saga-pattern-cdk/lambda/car/reserve_car.py @@ -0,0 +1,115 @@ +import json +import os +import uuid +from datetime import datetime +from decimal import Decimal +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): + """ + Reserve a car rental by creating a rental record in DynamoDB + Handles both direct invocation and API Gateway events + Supports failBookCar flag for testing saga compensation + """ + 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 + + # Check for failure flag to test saga compensation + if body.get('failBookCar', False): + print("SIMULATED FAILURE: failBookCar flag is set to True") + raise Exception("Simulated car rental failure for testing saga compensation") + + # Extract rental details from event + rental_id = body.get('rentalId', str(uuid.uuid4())) + driver_name = body.get('driverName', 'John Doe') + car_type = body.get('carType', 'Sedan') + pickup_location = body.get('pickupLocation', 'Airport') + dropoff_location = body.get('dropoffLocation', 'Airport') + pickup_date = body.get('pickupDate', datetime.utcnow().date().isoformat()) + dropoff_date = body.get('dropoffDate', datetime.utcnow().date().isoformat()) + price = Decimal(str(body.get('price', 89.99))) # Convert to Decimal for DynamoDB + + # Validate required fields + if not driver_name or not car_type: + return { + 'statusCode': 400, + 'body': json.dumps({ + 'message': 'Missing required fields: driverName, carType' + }) + } + + # Create rental record + item = { + 'rentalId': rental_id, + 'driverName': driver_name, + 'carType': car_type, + 'pickupLocation': pickup_location, + 'dropoffLocation': dropoff_location, + 'pickupDate': pickup_date, + 'dropoffDate': dropoff_date, + 'price': price, + 'status': 'RESERVED', + 'createdAt': datetime.utcnow().isoformat(), + 'updatedAt': datetime.utcnow().isoformat() + } + + # Put item in DynamoDB + table.put_item(Item=item) + + return { + 'statusCode': 200, + 'body': json.dumps({ + 'message': 'Car reserved successfully', + 'rentalId': rental_id, + 'carType': car_type, + 'status': 'RESERVED', + 'price': float(price) # Convert back to float for JSON response + }) + } + + 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 reserve car', + '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 reserve car', + 'error': str(e), + 'errorType': type(e).__name__ + }) + } diff --git a/saga-pattern-lambda-durable-functions/saga-pattern-cdk/lambda/flight/cancel_flight.py b/saga-pattern-lambda-durable-functions/saga-pattern-cdk/lambda/flight/cancel_flight.py new file mode 100644 index 000000000..7ecb322e8 --- /dev/null +++ b/saga-pattern-lambda-durable-functions/saga-pattern-cdk/lambda/flight/cancel_flight.py @@ -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-flight-bookings') +table = dynamodb.Table(table_name) + +def lambda_handler(event, context): + """ + Cancel a flight booking 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 booking ID from event + booking_id = body.get('bookingId') + + if not booking_id: + return { + 'statusCode': 400, + 'body': json.dumps({ + 'message': 'bookingId is required' + }) + } + + # Check if booking exists + response = table.get_item(Key={'bookingId': booking_id}) + + if 'Item' not in response: + return { + 'statusCode': 404, + 'body': json.dumps({ + 'message': f'Booking {booking_id} not found' + }) + } + + # Update booking status to CANCELLED + table.update_item( + Key={'bookingId': booking_id}, + UpdateExpression='SET #status = :status, updatedAt = :updatedAt', + ExpressionAttributeNames={'#status': 'status'}, + ExpressionAttributeValues={ + ':status': 'CANCELLED', + ':updatedAt': datetime.utcnow().isoformat() + } + ) + + return { + 'statusCode': 200, + 'body': json.dumps({ + 'message': 'Flight booking cancelled successfully', + 'bookingId': booking_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 flight booking', + '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 flight booking', + 'error': str(e), + 'errorType': type(e).__name__ + }) + } diff --git a/saga-pattern-lambda-durable-functions/saga-pattern-cdk/lambda/flight/reserve_flight.py b/saga-pattern-lambda-durable-functions/saga-pattern-cdk/lambda/flight/reserve_flight.py new file mode 100644 index 000000000..c1e6ecfd7 --- /dev/null +++ b/saga-pattern-lambda-durable-functions/saga-pattern-cdk/lambda/flight/reserve_flight.py @@ -0,0 +1,111 @@ +import json +import os +import uuid +from datetime import datetime +from decimal import Decimal +import boto3 +from botocore.exceptions import ClientError + +dynamodb = boto3.resource('dynamodb') +table_name = os.environ.get('TABLE_NAME', 'saga-flight-bookings') +table = dynamodb.Table(table_name) + +def lambda_handler(event, context): + """ + Reserve a flight by creating a booking record in DynamoDB + Handles both direct invocation and API Gateway events + Supports failBookFlight flag for testing saga compensation + """ + try: + # Handle different event formats (direct invocation vs API Gateway) + 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 + + # Check for failure flag to test saga compensation + if body.get('failBookFlight', False): + print("SIMULATED FAILURE: failBookFlight flag is set to True") + raise Exception("Simulated flight booking failure for testing saga compensation") + + # Extract booking details from event + booking_id = body.get('bookingId', str(uuid.uuid4())) + passenger_name = body.get('passengerName', 'John Doe') + flight_number = body.get('flightNumber', f'FL{uuid.uuid4().hex[:6].upper()}') + departure = body.get('departure', 'JFK') + destination = body.get('destination', 'LAX') + price = Decimal(str(body.get('price', 299.99))) # Convert to Decimal for DynamoDB + + # Validate required fields + if not passenger_name or not departure or not destination: + return { + 'statusCode': 400, + 'body': json.dumps({ + 'message': 'Missing required fields: passengerName, departure, destination' + }) + } + + # Create booking record + item = { + 'bookingId': booking_id, + 'passengerName': passenger_name, + 'flightNumber': flight_number, + 'departure': departure, + 'destination': destination, + 'price': price, + 'status': 'RESERVED', + 'createdAt': datetime.utcnow().isoformat(), + 'updatedAt': datetime.utcnow().isoformat() + } + + # Put item in DynamoDB + table.put_item(Item=item) + + return { + 'statusCode': 200, + 'body': json.dumps({ + 'message': 'Flight reserved successfully', + 'bookingId': booking_id, + 'flightNumber': flight_number, + 'status': 'RESERVED', + 'price': float(price) # Convert back to float for JSON response + }) + } + + 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 reserve flight', + '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 reserve flight', + 'error': str(e), + 'errorType': type(e).__name__ + }) + } diff --git a/saga-pattern-lambda-durable-functions/saga-pattern-cdk/lambda/hotel/cancel_hotel.py b/saga-pattern-lambda-durable-functions/saga-pattern-cdk/lambda/hotel/cancel_hotel.py new file mode 100644 index 000000000..5b3c31b86 --- /dev/null +++ b/saga-pattern-lambda-durable-functions/saga-pattern-cdk/lambda/hotel/cancel_hotel.py @@ -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-hotel-reservations') +table = dynamodb.Table(table_name) + +def lambda_handler(event, context): + """ + Cancel a hotel reservation 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 reservation ID from event + reservation_id = body.get('reservationId') + + if not reservation_id: + return { + 'statusCode': 400, + 'body': json.dumps({ + 'message': 'reservationId is required' + }) + } + + # Check if reservation exists + response = table.get_item(Key={'reservationId': reservation_id}) + + if 'Item' not in response: + return { + 'statusCode': 404, + 'body': json.dumps({ + 'message': f'Reservation {reservation_id} not found' + }) + } + + # Update reservation status to CANCELLED + table.update_item( + Key={'reservationId': reservation_id}, + UpdateExpression='SET #status = :status, updatedAt = :updatedAt', + ExpressionAttributeNames={'#status': 'status'}, + ExpressionAttributeValues={ + ':status': 'CANCELLED', + ':updatedAt': datetime.utcnow().isoformat() + } + ) + + return { + 'statusCode': 200, + 'body': json.dumps({ + 'message': 'Hotel reservation cancelled successfully', + 'reservationId': reservation_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 hotel reservation', + '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 hotel reservation', + 'error': str(e), + 'errorType': type(e).__name__ + }) + } diff --git a/saga-pattern-lambda-durable-functions/saga-pattern-cdk/lambda/hotel/reserve_hotel.py b/saga-pattern-lambda-durable-functions/saga-pattern-cdk/lambda/hotel/reserve_hotel.py new file mode 100644 index 000000000..1e73aeb38 --- /dev/null +++ b/saga-pattern-lambda-durable-functions/saga-pattern-cdk/lambda/hotel/reserve_hotel.py @@ -0,0 +1,114 @@ +import json +import os +import uuid +from datetime import datetime +from decimal import Decimal +import boto3 +from botocore.exceptions import ClientError + +dynamodb = boto3.resource('dynamodb') +table_name = os.environ.get('TABLE_NAME', 'saga-hotel-reservations') +table = dynamodb.Table(table_name) + +def lambda_handler(event, context): + """ + Reserve a hotel by creating a reservation record in DynamoDB + Handles both direct invocation and API Gateway events + Supports failBookHotel flag for testing saga compensation + """ + 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 + + # Check for failure flag to test saga compensation + if body.get('failBookHotel', False): + print("SIMULATED FAILURE: failBookHotel flag is set to True") + raise Exception("Simulated hotel reservation failure for testing saga compensation") + + # Extract reservation details from event + reservation_id = body.get('reservationId', str(uuid.uuid4())) + guest_name = body.get('guestName', 'John Doe') + hotel_name = body.get('hotelName', 'Grand Hotel') + room_type = body.get('roomType', 'Deluxe Suite') + check_in = body.get('checkIn', datetime.utcnow().date().isoformat()) + check_out = body.get('checkOut', datetime.utcnow().date().isoformat()) + price = Decimal(str(body.get('price', 199.99))) # Convert to Decimal for DynamoDB + + # Validate required fields + if not guest_name or not hotel_name: + return { + 'statusCode': 400, + 'body': json.dumps({ + 'message': 'Missing required fields: guestName, hotelName' + }) + } + + # Create reservation record + item = { + 'reservationId': reservation_id, + 'guestName': guest_name, + 'hotelName': hotel_name, + 'roomType': room_type, + 'checkIn': check_in, + 'checkOut': check_out, + 'price': price, + 'status': 'RESERVED', + 'createdAt': datetime.utcnow().isoformat(), + 'updatedAt': datetime.utcnow().isoformat() + } + + # Put item in DynamoDB + table.put_item(Item=item) + + return { + 'statusCode': 200, + 'body': json.dumps({ + 'message': 'Hotel reserved successfully', + 'reservationId': reservation_id, + 'hotelName': hotel_name, + 'roomType': room_type, + 'status': 'RESERVED', + 'price': float(price) # Convert back to float for JSON response + }) + } + + 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 reserve hotel', + '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 reserve hotel', + 'error': str(e), + 'errorType': type(e).__name__ + }) + } diff --git a/saga-pattern-lambda-durable-functions/saga-pattern-cdk/lambda/saga-workflow/index.py b/saga-pattern-lambda-durable-functions/saga-pattern-cdk/lambda/saga-workflow/index.py new file mode 100644 index 000000000..8b23172ee --- /dev/null +++ b/saga-pattern-lambda-durable-functions/saga-pattern-cdk/lambda/saga-workflow/index.py @@ -0,0 +1,238 @@ +from aws_durable_execution_sdk_python.config import Duration +from aws_durable_execution_sdk_python.context import DurableContext, StepContext, durable_step +from aws_durable_execution_sdk_python.execution import durable_execution +import random +import datetime +import boto3 +import logging +import json +import os +import uuid + +dynamodb = boto3.client('dynamodb') + +# Get function names from environment variables +RESERVE_FLIGHT_FUNCTION = os.environ.get('RESERVE_FLIGHT_FUNCTION', 'saga-reserve-flight') +CANCEL_FLIGHT_FUNCTION = os.environ.get('CANCEL_FLIGHT_FUNCTION', 'saga-cancel-flight') +RESERVE_HOTEL_FUNCTION = os.environ.get('RESERVE_HOTEL_FUNCTION', 'saga-reserve-hotel') +CANCEL_HOTEL_FUNCTION = os.environ.get('CANCEL_HOTEL_FUNCTION', 'saga-cancel-hotel') +RESERVE_CAR_FUNCTION = os.environ.get('RESERVE_CAR_FUNCTION', 'saga-reserve-car') +CANCEL_CAR_FUNCTION = os.environ.get('CANCEL_CAR_FUNCTION', 'saga-cancel-car') + + +@durable_execution +def lambda_handler(event: dict, context: DurableContext) -> dict: + """ + Saga orchestrator that coordinates distributed transactions across flight, hotel, and car services. + Implements compensating transactions (cancellations) if any step fails. + Uses direct context.invoke() calls instead of durable steps. + """ + print(f"Saga workflow started at: {datetime.datetime.now()}") + context.logger.info(f"Received event: {json.dumps(event)}") + + # Generate unique IDs for this saga transaction + transaction_id = str(uuid.uuid4()) + booking_id = None + reservation_id = None + rental_id = None + + try: + # Step 1: Reserve the flight + context.logger.info("Step 1: Reserving flight...") + flight_data = { + "bookingId": str(uuid.uuid4()), + "passengerName": event.get("passengerName", "John Doe"), + "flightNumber": event.get("flightNumber", f"FL{uuid.uuid4().hex[:6].upper()}"), + "departure": event.get("departure", "JFK"), + "destination": event.get("destination", "LAX"), + "price": event.get("flightPrice", 299.99), + "failBookFlight": event.get("failBookFlight", False) # Pass failure flag + } + + flight_result = context.invoke( + function_name=RESERVE_FLIGHT_FUNCTION, + payload=flight_data, + name="reserve_flight_invocation" + ) + + if flight_result is None: + context.logger.error("Flight reservation returned None - invocation may have failed") + raise Exception("Flight reservation returned None - invocation may have failed") + + # If it's a string, parse it first + if isinstance(flight_result, str): + flight_result = json.loads(flight_result) + + # Parse the Lambda response format + if isinstance(flight_result, dict) and 'body' in flight_result: + flight_body = json.loads(flight_result['body']) if isinstance(flight_result['body'], str) else flight_result['body'] + if flight_result.get('statusCode') != 200: + raise Exception(f"Flight reservation failed: {flight_body.get('message')}") + booking_id = flight_body.get('bookingId') + elif isinstance(flight_result, dict): + booking_id = flight_result.get('bookingId') + else: + raise Exception(f"Unexpected flight result format: {type(flight_result)}") + + context.logger.info(f"Flight reserved successfully: {booking_id}") + + # Step 2: Reserve hotel + context.logger.info("Step 2: Reserving hotel...") + hotel_data = { + "reservationId": str(uuid.uuid4()), + "guestName": event.get("guestName", "John Doe"), + "hotelName": event.get("hotelName", "Grand Hotel"), + "roomType": event.get("roomType", "Deluxe Suite"), + "checkIn": event.get("checkIn", datetime.datetime.utcnow().date().isoformat()), + "checkOut": event.get("checkOut", datetime.datetime.utcnow().date().isoformat()), + "price": event.get("hotelPrice", 199.99), + "failBookHotel": event.get("failBookHotel", False) # Pass failure flag + } + + hotel_result = context.invoke( + function_name=RESERVE_HOTEL_FUNCTION, + payload=hotel_data, + name="reserve_hotel_invocation" + ) + + context.logger.info(f"Hotel result after invoke: {hotel_result}") + + if hotel_result is None: + raise Exception("Hotel reservation returned None - invocation may have failed") + + # If it's a string, parse it first + if isinstance(hotel_result, str): + hotel_result = json.loads(hotel_result) + + # parse the Lambda response format + if isinstance(hotel_result, dict) and 'body' in hotel_result: + hotel_body = json.loads(hotel_result['body']) if isinstance(hotel_result['body'], str) else hotel_result['body'] + if hotel_result.get('statusCode') != 200: + raise Exception(f"Hotel reservation failed: {hotel_body.get('message')}") + reservation_id = hotel_body.get('reservationId') + elif isinstance(hotel_result, dict): + reservation_id = hotel_result.get('reservationId') + else: + raise Exception(f"Unexpected hotel result format: {type(hotel_result)}") + + context.logger.info(f"Hotel reserved successfully: {reservation_id}") + + # Step 3: Reserve car + context.logger.info("Step 3: Reserving car...") + car_data = { + "rentalId": str(uuid.uuid4()), + "driverName": event.get("driverName", "John Doe"), + "carType": event.get("carType", "Sedan"), + "pickupLocation": event.get("pickupLocation", "Airport"), + "dropoffLocation": event.get("dropoffLocation", "Airport"), + "pickupDate": event.get("pickupDate", datetime.datetime.utcnow().date().isoformat()), + "dropoffDate": event.get("dropoffDate", datetime.datetime.utcnow().date().isoformat()), + "price": event.get("carPrice", 89.99), + "failBookCar": event.get("failBookCar", False) # Pass failure flag + } + + car_result = context.invoke( + function_name=RESERVE_CAR_FUNCTION, + payload=car_data, + name="reserve_car_invocation" + ) + + context.logger.info(f"Car result after invoke: {car_result}") + + if car_result is None: + raise Exception("Car reservation returned None - invocation may have failed") + + # If it's a string, parse it first + if isinstance(car_result, str): + car_result = json.loads(car_result) + + # Parse the Lambda response format + if isinstance(car_result, dict) and 'body' in car_result: + car_body = json.loads(car_result['body']) if isinstance(car_result['body'], str) else car_result['body'] + if car_result.get('statusCode') != 200: + raise Exception(f"Car reservation failed: {car_body.get('message')}") + rental_id = car_body.get('rentalId') + elif isinstance(car_result, dict): + rental_id = car_result.get('rentalId') + else: + raise Exception(f"Unexpected car result format: {type(car_result)}") + + context.logger.info(f"Car reserved successfully: {rental_id}") + + # All reservations successful + context.logger.info("All reservations completed successfully!") + + return { + "success": True, + "transactionId": transaction_id, + "message": "All travel arrangements completed successfully", + "bookings": { + "flight": booking_id, + "hotel": reservation_id, + "car": rental_id + } + } + + except Exception as e: + # Saga compensation: rollback all successful reservations + context.logger.error(f"Error in saga workflow: {str(e)}") + context.logger.info("Starting compensation (rollback) process...") + + compensation_results = [] + + # Cancel car if it was reserved + if rental_id: + try: + context.logger.info(f"Compensating: Cancelling car rental {rental_id}") + # Direct invoke call for cancellation + cancel_car_result = context.invoke( + function_name=CANCEL_CAR_FUNCTION, + payload={"rentalId": rental_id}, + name="cancel_car_invocation" + ) + compensation_results.append({"car": "cancelled", "rentalId": rental_id}) + except Exception as cancel_error: + context.logger.error(f"Failed to cancel car: {str(cancel_error)}") + compensation_results.append({"car": "cancellation_failed", "error": str(cancel_error)}) + + # Cancel hotel if it was reserved + if reservation_id: + try: + context.logger.info(f"Compensating: Cancelling hotel reservation {reservation_id}") + # Direct invoke call for cancellation + cancel_hotel_result = context.invoke( + function_name=CANCEL_HOTEL_FUNCTION, + payload={"reservationId": reservation_id}, + name="cancel_hotel_invocation" + ) + compensation_results.append({"hotel": "cancelled", "reservationId": reservation_id}) + except Exception as cancel_error: + context.logger.error(f"Failed to cancel hotel: {str(cancel_error)}") + compensation_results.append({"hotel": "cancellation_failed", "error": str(cancel_error)}) + + # Cancel flight if it was reserved + if booking_id: + try: + context.logger.info(f"Compensating: Cancelling flight booking {booking_id}") + # Direct invoke call for cancellation + cancel_flight_result = context.invoke( + function_name=CANCEL_FLIGHT_FUNCTION, + payload={"bookingId": booking_id}, + name="cancel_flight_invocation" + ) + compensation_results.append({"flight": "cancelled", "bookingId": booking_id}) + except Exception as cancel_error: + context.logger.error(f"Failed to cancel flight: {str(cancel_error)}") + compensation_results.append({"flight": "cancellation_failed", "error": str(cancel_error)}) + + context.logger.info("Compensation process completed") + + # Raise an exception with compensation details + error_details = { + "transactionId": transaction_id, + "originalError": str(e), + "compensations": compensation_results, + "message": f"Transaction failed and rolled back: {str(e)}" + } + + raise Exception(f"Saga transaction failed and compensated. Details: {json.dumps(error_details)}") diff --git a/saga-pattern-lambda-durable-functions/saga-pattern-cdk/lambda/saga-workflow/saga-layer.zip b/saga-pattern-lambda-durable-functions/saga-pattern-cdk/lambda/saga-workflow/saga-layer.zip new file mode 100644 index 000000000..b5a336461 Binary files /dev/null and b/saga-pattern-lambda-durable-functions/saga-pattern-cdk/lambda/saga-workflow/saga-layer.zip differ diff --git a/saga-pattern-lambda-durable-functions/saga-pattern-cdk/lib/saga-pattern-cdk-stack.ts b/saga-pattern-lambda-durable-functions/saga-pattern-cdk/lib/saga-pattern-cdk-stack.ts new file mode 100644 index 000000000..80e7635f8 --- /dev/null +++ b/saga-pattern-lambda-durable-functions/saga-pattern-cdk/lib/saga-pattern-cdk-stack.ts @@ -0,0 +1,274 @@ +import * as cdk from 'aws-cdk-lib/core'; +import { Construct } from 'constructs'; +import { aws_dynamodb, aws_lambda, aws_logs, Duration, RemovalPolicy } from 'aws-cdk-lib'; + +export class SagaPatternCdkStack extends cdk.Stack { + constructor(scope: Construct, id: string, props?: cdk.StackProps) { + super(scope, id, props); + + // Hotel reservations table + const hotelTable = new aws_dynamodb.Table(this, 'HotelReservationsTable', { + tableName: 'saga-hotel-reservations', + partitionKey: { name: 'reservationId', type: aws_dynamodb.AttributeType.STRING }, + billingMode: aws_dynamodb.BillingMode.PAY_PER_REQUEST, + removalPolicy: RemovalPolicy.DESTROY, + }); + + // Flight bookings table + const flightTable = new aws_dynamodb.Table(this, 'FlightBookingsTable', { + tableName: 'saga-flight-bookings', + partitionKey: { name: 'bookingId', type: aws_dynamodb.AttributeType.STRING }, + billingMode: aws_dynamodb.BillingMode.PAY_PER_REQUEST, + removalPolicy: RemovalPolicy.DESTROY, + }); + + // Car rentals table + const carTable = new aws_dynamodb.Table(this, 'CarRentalsTable', { + tableName: 'saga-car-rentals', + partitionKey: { name: 'rentalId', type: aws_dynamodb.AttributeType.STRING }, + billingMode: aws_dynamodb.BillingMode.PAY_PER_REQUEST, + removalPolicy: RemovalPolicy.DESTROY, + }); + + // Flight Lambda functions + const reserveFlightLogGroup = new aws_logs.LogGroup(this, 'ReserveFlightLogGroup', { + logGroupName: '/aws/lambda/saga-reserve-flight', + retention: aws_logs.RetentionDays.ONE_WEEK, + removalPolicy: RemovalPolicy.DESTROY, + }); + + const reserveFlight = new aws_lambda.Function(this, 'ReserveFlightFunction', { + functionName: 'saga-reserve-flight', + description: 'Creates a new flight booking reservation and stores it in DynamoDB as part of the saga transaction', + runtime: aws_lambda.Runtime.PYTHON_3_14, + handler: 'reserve_flight.lambda_handler', + code: aws_lambda.Code.fromAsset('lambda/flight'), + tracing: aws_lambda.Tracing.ACTIVE, + loggingFormat: aws_lambda.LoggingFormat.JSON, + timeout: Duration.minutes(1), + logGroup: reserveFlightLogGroup, + environment: { + TABLE_NAME: flightTable.tableName + }, + }); + + const cancelFlightLogGroup = new aws_logs.LogGroup(this, 'CancelFlightLogGroup', { + logGroupName: '/aws/lambda/saga-cancel-flight', + retention: aws_logs.RetentionDays.ONE_WEEK, + removalPolicy: RemovalPolicy.DESTROY, + }); + + const cancelFlight = new aws_lambda.Function(this, 'CancelFlightFunction', { + functionName: 'saga-cancel-flight', + description: 'Compensating transaction that cancels a flight booking when the saga needs to rollback', + runtime: aws_lambda.Runtime.PYTHON_3_14, + handler: 'cancel_flight.lambda_handler', + code: aws_lambda.Code.fromAsset('lambda/flight'), + tracing: aws_lambda.Tracing.ACTIVE, + loggingFormat: aws_lambda.LoggingFormat.JSON, + timeout: Duration.minutes(1), + logGroup: cancelFlightLogGroup, + environment: { + TABLE_NAME: flightTable.tableName + }, + }); + + // Hotel Lambda functions + const reserveHotelLogGroup = new aws_logs.LogGroup(this, 'ReserveHotelLogGroup', { + logGroupName: '/aws/lambda/saga-reserve-hotel', + retention: aws_logs.RetentionDays.ONE_WEEK, + removalPolicy: RemovalPolicy.DESTROY, + }); + + const reserveHotel = new aws_lambda.Function(this, 'ReserveHotelFunction', { + functionName: 'saga-reserve-hotel', + description: 'Creates a new hotel room reservation and stores it in DynamoDB as part of the saga transaction', + runtime: aws_lambda.Runtime.PYTHON_3_14, + handler: 'reserve_hotel.lambda_handler', + code: aws_lambda.Code.fromAsset('lambda/hotel'), + tracing: aws_lambda.Tracing.ACTIVE, + loggingFormat: aws_lambda.LoggingFormat.JSON, + timeout: Duration.minutes(1), + logGroup: reserveHotelLogGroup, + environment: { + TABLE_NAME: hotelTable.tableName + }, + }); + + const cancelHotelLogGroup = new aws_logs.LogGroup(this, 'CancelHotelLogGroup', { + logGroupName: '/aws/lambda/saga-cancel-hotel', + retention: aws_logs.RetentionDays.ONE_WEEK, + removalPolicy: RemovalPolicy.DESTROY, + }); + + const cancelHotel = new aws_lambda.Function(this, 'CancelHotelFunction', { + functionName: 'saga-cancel-hotel', + description: 'Compensating transaction that cancels a hotel reservation when the saga needs to rollback', + runtime: aws_lambda.Runtime.PYTHON_3_14, + handler: 'cancel_hotel.lambda_handler', + code: aws_lambda.Code.fromAsset('lambda/hotel'), + tracing: aws_lambda.Tracing.ACTIVE, + loggingFormat: aws_lambda.LoggingFormat.JSON, + timeout: Duration.minutes(1), + logGroup: cancelHotelLogGroup, + environment: { + TABLE_NAME: hotelTable.tableName + }, + }); + + // Car Lambda functions + const reserveCarLogGroup = new aws_logs.LogGroup(this, 'ReserveCarLogGroup', { + logGroupName: '/aws/lambda/saga-reserve-car', + retention: aws_logs.RetentionDays.ONE_WEEK, + removalPolicy: RemovalPolicy.DESTROY, + }); + + const reserveCar = new aws_lambda.Function(this, 'ReserveCarFunction', { + functionName: 'saga-reserve-car', + description: 'Creates a new car rental reservation and stores it in DynamoDB as part of the saga transaction', + runtime: aws_lambda.Runtime.PYTHON_3_14, + handler: 'reserve_car.lambda_handler', + code: aws_lambda.Code.fromAsset('lambda/car'), + tracing: aws_lambda.Tracing.ACTIVE, + loggingFormat: aws_lambda.LoggingFormat.JSON, + timeout: Duration.minutes(1), + logGroup: reserveCarLogGroup, + environment: { + TABLE_NAME: carTable.tableName + }, + }); + + const cancelCarLogGroup = new aws_logs.LogGroup(this, 'CancelCarLogGroup', { + logGroupName: '/aws/lambda/saga-cancel-car', + retention: aws_logs.RetentionDays.ONE_WEEK, + removalPolicy: RemovalPolicy.DESTROY, + }); + + const cancelCar = new aws_lambda.Function(this, 'CancelCarFunction', { + functionName: 'saga-cancel-car', + description: 'Compensating transaction that cancels a car rental reservation when the saga needs to rollback', + runtime: aws_lambda.Runtime.PYTHON_3_14, + handler: 'cancel_car.lambda_handler', + code: aws_lambda.Code.fromAsset('lambda/car'), + tracing: aws_lambda.Tracing.ACTIVE, + loggingFormat: aws_lambda.LoggingFormat.JSON, + timeout: Duration.minutes(1), + logGroup: cancelCarLogGroup, + environment: { + TABLE_NAME: carTable.tableName + }, + }); + + // Saga orchestrator durable function + const sagaDurableFunctionLogGroup = new aws_logs.LogGroup(this, 'SagaDurableFunctionLogGroup', { + logGroupName: '/aws/lambda/saga-durable-function', + retention: aws_logs.RetentionDays.ONE_WEEK, + removalPolicy: RemovalPolicy.DESTROY, + }); + + const sagaDependenciesLayer = new aws_lambda.LayerVersion(this, 'SagaDependenciesLayer', { + code: aws_lambda.Code.fromAsset('lambda/saga-workflow/saga-layer.zip'), + compatibleRuntimes: [aws_lambda.Runtime.PYTHON_3_14], + description: 'Saga pattern dependencies (durable SDK, boto3, etc.)', + }); + + const sagaDurableFunction = new aws_lambda.Function(this, 'SagaDurableFunction', { + runtime: aws_lambda.Runtime.PYTHON_3_14, + tracing: aws_lambda.Tracing.ACTIVE, + functionName: 'saga-durable-function', + description: 'Orchestrates the saga pattern workflow by coordinating flight, hotel, and car reservations with automatic rollback on failure', + loggingFormat: aws_lambda.LoggingFormat.JSON, + handler: 'index.lambda_handler', + logGroup: sagaDurableFunctionLogGroup, + layers: [sagaDependenciesLayer], + durableConfig: { + executionTimeout: Duration.hours(1), + retentionPeriod: Duration.days(30) + }, + code: aws_lambda.Code.fromAsset('lambda/saga-workflow'), + environment: { + HOTEL_TABLE_NAME: hotelTable.tableName, + FLIGHT_TABLE_NAME: flightTable.tableName, + CAR_TABLE_NAME: carTable.tableName, + RESERVE_FLIGHT_FUNCTION: reserveFlight.functionName, + CANCEL_FLIGHT_FUNCTION: cancelFlight.functionName, + RESERVE_HOTEL_FUNCTION: reserveHotel.functionName, + CANCEL_HOTEL_FUNCTION: cancelHotel.functionName, + RESERVE_CAR_FUNCTION: reserveCar.functionName, + CANCEL_CAR_FUNCTION: cancelCar.functionName, + }, + }); + + // Grant Lambda permissions to access DynamoDB tables + hotelTable.grantReadWriteData(sagaDurableFunction); + flightTable.grantReadWriteData(sagaDurableFunction); + carTable.grantReadWriteData(sagaDurableFunction); + + // Grant individual Lambda functions access to their respective tables + flightTable.grantReadWriteData(reserveFlight); + flightTable.grantReadWriteData(cancelFlight); + hotelTable.grantReadWriteData(reserveHotel); + hotelTable.grantReadWriteData(cancelHotel); + carTable.grantReadWriteData(reserveCar); + carTable.grantReadWriteData(cancelCar); + + // Grant saga orchestrator permission to invoke service functions + reserveFlight.grantInvoke(sagaDurableFunction); + cancelFlight.grantInvoke(sagaDurableFunction); + reserveHotel.grantInvoke(sagaDurableFunction); + cancelHotel.grantInvoke(sagaDurableFunction); + reserveCar.grantInvoke(sagaDurableFunction); + cancelCar.grantInvoke(sagaDurableFunction); + + // Outputs + new cdk.CfnOutput(this, 'SagaDurableFunctionArn', { + value: sagaDurableFunction.functionArn, + description: 'The ARN of the Saga durable function', + }); + + new cdk.CfnOutput(this, 'ReserveFlightFunctionArn', { + value: reserveFlight.functionArn, + description: 'Reserve flight function ARN', + }); + + new cdk.CfnOutput(this, 'CancelFlightFunctionArn', { + value: cancelFlight.functionArn, + description: 'Cancel flight function ARN', + }); + + new cdk.CfnOutput(this, 'ReserveHotelFunctionArn', { + value: reserveHotel.functionArn, + description: 'Reserve hotel function ARN', + }); + + new cdk.CfnOutput(this, 'CancelHotelFunctionArn', { + value: cancelHotel.functionArn, + description: 'Cancel hotel function ARN', + }); + + new cdk.CfnOutput(this, 'ReserveCarFunctionArn', { + value: reserveCar.functionArn, + description: 'Reserve car function ARN', + }); + + new cdk.CfnOutput(this, 'CancelCarFunctionArn', { + value: cancelCar.functionArn, + description: 'Cancel car function ARN', + }); + + new cdk.CfnOutput(this, 'HotelTableName', { + value: hotelTable.tableName, + description: 'Hotel reservations table name', + }); + + new cdk.CfnOutput(this, 'FlightTableName', { + value: flightTable.tableName, + description: 'Flight bookings table name', + }); + + new cdk.CfnOutput(this, 'CarTableName', { + value: carTable.tableName, + description: 'Car rentals table name', + }); + } +} diff --git a/saga-pattern-lambda-durable-functions/saga-pattern-cdk/package.json b/saga-pattern-lambda-durable-functions/saga-pattern-cdk/package.json new file mode 100644 index 000000000..912e9a17c --- /dev/null +++ b/saga-pattern-lambda-durable-functions/saga-pattern-cdk/package.json @@ -0,0 +1,26 @@ +{ + "name": "saga-pattern-cdk", + "version": "0.1.0", + "bin": { + "saga-pattern-cdk": "bin/saga-pattern-cdk.js" + }, + "scripts": { + "build": "tsc", + "watch": "tsc -w", + "test": "jest", + "cdk": "cdk" + }, + "devDependencies": { + "@types/jest": "^30", + "@types/node": "^24.10.1", + "jest": "^30", + "ts-jest": "^29", + "aws-cdk": "2.1100.3", + "ts-node": "^10.9.2", + "typescript": "~5.9.3" + }, + "dependencies": { + "aws-cdk-lib": "^2.232.2", + "constructs": "^10.0.0" + } +} diff --git a/saga-pattern-lambda-durable-functions/saga-pattern-cdk/test-fail-car-booking.json b/saga-pattern-lambda-durable-functions/saga-pattern-cdk/test-fail-car-booking.json new file mode 100644 index 000000000..a02934b94 --- /dev/null +++ b/saga-pattern-lambda-durable-functions/saga-pattern-cdk/test-fail-car-booking.json @@ -0,0 +1,9 @@ +{ + "passengerName": "Jane Smith", + "departure": "SFO", + "destination": "NYC", + "guestName": "Jane Smith", + "hotelName": "Marriott Times Square", + "driverName": "Jane Smith", + "failBookCar": true +} diff --git a/saga-pattern-lambda-durable-functions/saga-pattern-cdk/test-fail-flight-booking.json b/saga-pattern-lambda-durable-functions/saga-pattern-cdk/test-fail-flight-booking.json new file mode 100644 index 000000000..940e055f8 --- /dev/null +++ b/saga-pattern-lambda-durable-functions/saga-pattern-cdk/test-fail-flight-booking.json @@ -0,0 +1,6 @@ +{ + "passengerName": "Sarah Williams", + "departure": "ORD", + "destination": "SEA", + "failBookFlight": true +} diff --git a/saga-pattern-lambda-durable-functions/saga-pattern-cdk/test-fail-hotel-booking.json b/saga-pattern-lambda-durable-functions/saga-pattern-cdk/test-fail-hotel-booking.json new file mode 100644 index 000000000..f2435b933 --- /dev/null +++ b/saga-pattern-lambda-durable-functions/saga-pattern-cdk/test-fail-hotel-booking.json @@ -0,0 +1,9 @@ +{ + "passengerName": "David Martinez", + "departure": "DFW", + "destination": "BOS", + "flightPrice": 420.00, + "guestName": "David Martinez", + "hotelName": "Boston Harbor Hotel", + "failBookHotel": true +} diff --git a/saga-pattern-lambda-durable-functions/saga-pattern-cdk/test-success.json b/saga-pattern-lambda-durable-functions/saga-pattern-cdk/test-success.json new file mode 100644 index 000000000..a864468de --- /dev/null +++ b/saga-pattern-lambda-durable-functions/saga-pattern-cdk/test-success.json @@ -0,0 +1,20 @@ +{ + "passengerName": "Michael Johnson", + "flightNumber": "AA456", + "departure": "LAX", + "destination": "MIA", + "flightPrice": 380.00, + "guestName": "Michael Johnson", + "hotelName": "Hilton Downtown Miami", + "roomType": "Ocean View Suite", + "checkIn": "2026-04-10", + "checkOut": "2026-04-15", + "hotelPrice": 320.00, + "driverName": "Michael Johnson", + "carType": "Convertible", + "pickupLocation": "Miami Airport", + "dropoffLocation": "Miami Airport", + "pickupDate": "2026-04-10", + "dropoffDate": "2026-04-15", + "carPrice": 150.00 +} diff --git a/saga-pattern-lambda-durable-functions/saga-pattern-cdk/tsconfig.json b/saga-pattern-lambda-durable-functions/saga-pattern-cdk/tsconfig.json new file mode 100644 index 000000000..bfc61bf83 --- /dev/null +++ b/saga-pattern-lambda-durable-functions/saga-pattern-cdk/tsconfig.json @@ -0,0 +1,32 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": [ + "es2022" + ], + "declaration": true, + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "noImplicitThis": true, + "alwaysStrict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": false, + "inlineSourceMap": true, + "inlineSources": true, + "experimentalDecorators": true, + "strictPropertyInitialization": false, + "skipLibCheck": true, + "typeRoots": [ + "./node_modules/@types" + ] + }, + "exclude": [ + "node_modules", + "cdk.out" + ] +}