diff --git a/lambda-durable-function-chaining-cdk/.gitignore b/lambda-durable-function-chaining-cdk/.gitignore new file mode 100644 index 000000000..f60797b6a --- /dev/null +++ b/lambda-durable-function-chaining-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-function-chaining-cdk/.npmignore b/lambda-durable-function-chaining-cdk/.npmignore new file mode 100644 index 000000000..c1d6d45dc --- /dev/null +++ b/lambda-durable-function-chaining-cdk/.npmignore @@ -0,0 +1,6 @@ +*.ts +!*.d.ts + +# CDK asset staging directory +.cdk.staging +cdk.out diff --git a/lambda-durable-function-chaining-cdk/README.md b/lambda-durable-function-chaining-cdk/README.md new file mode 100644 index 000000000..028c61500 --- /dev/null +++ b/lambda-durable-function-chaining-cdk/README.md @@ -0,0 +1,457 @@ +# Order Processing with AWS Lambda Durable Functions (CDK) + +This pattern demonstrates an e-commerce order processing workflow using AWS Lambda Durable Functions with function chaining. The workflow orchestrates order validation, payment authorization, inventory allocation, and order fulfillment with automatic checkpointing and state persistence. + +Learn more about this pattern at Serverless Land Patterns: https://serverlessland.com/patterns/lambda-durable-function-chaining-cdk + +## Architecture + +![Architecture Diagram](architecture.png) + +The solution implements a function chaining pattern where a durable orchestrator function coordinates multiple worker functions to process orders through a multi-step workflow. + +### Components + +- **ValidateOrder Function (Durable)**: Orchestrates the entire workflow with automatic checkpointing +- **AuthorizePayment Function**: Validates and authorizes payment methods +- **AllocateInventory Function**: Checks product availability and reserves inventory +- **FulfillOrder Function**: Creates shipments and generates tracking information +- **ProductCatalog Table**: DynamoDB table storing product information and stock levels + +### Workflow Steps + +The order processing workflow consists of 4 checkpointed steps: + +1. **validate-order** - Validates order data, items, addresses, payment method, and order total +2. **authorize-payment** - Authorizes payment with the payment gateway +3. **allocate-inventory** - Checks inventory availability and reserves items +4. **fulfill-order** - Creates shipments and generates tracking numbers + +Each step is automatically checkpointed, allowing the workflow to resume from the last successful step if interrupted. + +## Key Features + +- ✅ **Automatic Checkpointing** - Each step is checkpointed automatically +- ✅ **Failure Recovery** - Resumes from last checkpoint on failure +- ✅ **Function Chaining** - Orchestrator invokes worker functions sequentially +- ✅ **State Persistence** - Workflow state maintained across executions +- ✅ **Error Handling** - Graceful handling of validation, payment, and inventory failures +- ✅ **DynamoDB Integration** - Product catalog with stock level tracking + +## Prerequisites + +* [AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/install-cliv2.html) installed and configured +* [AWS CDK](https://docs.aws.amazon.com/cdk/v2/guide/getting_started.html) installed +* [Node.js 18+](https://nodejs.org/) installed +* [TypeScript](https://www.typescriptlang.org/) installed + +## Deployment + +1. Navigate to the pattern directory: + ```bash + cd lambda-durable-function-chaining-cdk + ``` + +2. Install dependencies: + ```bash + npm install + ``` + +3. Bootstrap CDK (if not already done): + ```bash + cdk bootstrap + ``` + +4. Deploy the stack: + ```bash + cdk deploy + ``` + +5. Note the `ProductCatalogTableName` from the outputs. + +## Testing + +### Step 1: Populate Product Catalog + +Add test products to the DynamoDB table: + +```bash +TABLE_NAME=$(aws cloudformation describe-stacks \ + --stack-name LambdaDurableFunctionChainingCdkStack \ + --query 'Stacks[0].Outputs[?OutputKey==`ProductCatalogTableName`].OutputValue' \ + --output text) + +# Add sample products with pricing +aws dynamodb put-item \ + --table-name ${TABLE_NAME} \ + --item '{ + "productId": {"S": "LAPTOP-001"}, + "name": {"S": "Gaming Laptop"}, + "price": {"N": "1299.99"}, + "stockLevel": {"N": "50"}, + "warehouseLocation": {"S": "WAREHOUSE-A"} + }' + +aws dynamodb put-item \ + --table-name ${TABLE_NAME} \ + --item '{ + "productId": {"S": "MOUSE-001"}, + "name": {"S": "Wireless Mouse"}, + "price": {"N": "29.99"}, + "stockLevel": {"N": "200"}, + "warehouseLocation": {"S": "WAREHOUSE-A"} + }' + +aws dynamodb put-item \ + --table-name ${TABLE_NAME} \ + --item '{ + "productId": {"S": "KEYBOARD-001"}, + "name": {"S": "Mechanical Keyboard"}, + "price": {"N": "149.99"}, + "stockLevel": {"N": "20"}, + "warehouseLocation": {"S": "WAREHOUSE-B"} + }' +``` + +### Step 2: Get Function Name + +```bash +FUNCTION_NAME=$(aws cloudformation describe-stacks \ + --stack-name LambdaDurableFunctionChainingCdkStack \ + --query 'Stacks[0].Outputs[?OutputKey==`ValidateOrderFunctionName`].OutputValue' \ + --output text) + +echo "Function Name: $FUNCTION_NAME" +``` + +### Step 3: Monitor Lambda Logs (Optional) + +In a separate terminal, monitor the logs to see real-time execution: + +```bash +FUNCTION_NAME=$(aws cloudformation describe-stacks \ + --stack-name LambdaDurableFunctionChainingCdkStack \ + --query 'Stacks[0].Outputs[?OutputKey==`ValidateOrderFunctionName`].OutputValue' \ + --output text) + +aws logs tail /aws/lambda/${FUNCTION_NAME} --follow +``` + +Look for checkpoint and step execution messages showing the workflow progression through each step. + +### Step 4: Test Order Processing + +**Note**: Product pricing is retrieved from the database. Orders only need to specify productId and quantity. + +**Test 1: Successful order** +```bash +aws lambda invoke \ + --function-name ${FUNCTION_NAME}:\$LATEST \ + --invocation-type Event \ + --cli-binary-format raw-in-base64-out \ + --payload '{ + "orderId": "ORDER-001", + "customerId": "CUST-001", + "items": [ + { + "productId": "LAPTOP-001", + "quantity": 1 + }, + { + "productId": "MOUSE-001", + "quantity": 2 + } + ], + "shippingAddress": "123 Main St, Seattle, WA 98101", + "billingAddress": "123 Main St, Seattle, WA 98101", + "paymentMethod": { + "type": "credit", + "cardNumber": 4532123456789012, + "cardBrand": "Visa" + } + }' \ + response.json + +echo "Order submitted asynchronously. Check logs for execution status." +``` + +**Test 2: Payment declined (10 laptops = $12,999.90, exceeds $10,000 limit)** +```bash +aws lambda invoke \ + --function-name ${FUNCTION_NAME}:\$LATEST \ + --invocation-type Event \ + --cli-binary-format raw-in-base64-out \ + --payload '{ + "orderId": "ORDER-002", + "customerId": "CUST-002", + "items": [ + { + "productId": "LAPTOP-001", + "quantity": 10 + } + ], + "shippingAddress": "456 Oak Ave, Portland, OR 97201", + "billingAddress": "456 Oak Ave, Portland, OR 97201", + "paymentMethod": { + "type": "credit", + "cardNumber": 5412345678901234, + "cardBrand": "Mastercard" + } + }' \ + response.json + +echo "Order submitted asynchronously. Check logs for execution status." +``` + +**Test 3: Insufficient inventory (60 keyboards, only 20 in stock)** +```bash +aws lambda invoke \ + --function-name ${FUNCTION_NAME}:\$LATEST \ + --invocation-type Event \ + --cli-binary-format raw-in-base64-out \ + --payload '{ + "orderId": "ORDER-003", + "customerId": "CUST-003", + "items": [ + { + "productId": "KEYBOARD-001", + "quantity": 60 + } + ], + "shippingAddress": "789 Pine Rd, Austin, TX 78701", + "billingAddress": "789 Pine Rd, Austin, TX 78701", + "paymentMethod": { + "type": "debit", + "cardNumber": 4111111111111111, + "cardBrand": "Visa" + } + }' \ + response.json + +echo "Order submitted asynchronously. Check logs for execution status." +``` + +**Test 4: Validation failure (empty items)** +```bash +aws lambda invoke \ + --function-name ${FUNCTION_NAME}:\$LATEST \ + --invocation-type Event \ + --cli-binary-format raw-in-base64-out \ + --payload '{ + "orderId": "ORDER-004", + "customerId": "CUST-004", + "items": [], + "shippingAddress": "321 Elm St, Boston, MA 02101", + "billingAddress": "321 Elm St, Boston, MA 02101", + "paymentMethod": { + "type": "credit", + "cardNumber": 378282246310005, + "cardBrand": "Amex" + } + }' \ + response.json + +echo "Order submitted asynchronously. Check logs for execution status." +``` + +### Expected Test Results + +- ✅ **Successful orders**: Complete all 4 steps and return full order details +- ✅ **Payment declined**: Fail at payment authorization step +- ✅ **Insufficient inventory**: Fail at inventory allocation step +- ✅ **Validation failures**: Reject immediately with error details +- ✅ **Checkpointing**: Function resumes from last checkpoint on retry + +## How It Works + +### Durable Execution with Function Chaining + +The ValidateOrder function uses the Durable Execution SDK to orchestrate worker functions: + +```typescript +import { DurableContext, withDurableExecution } from "@aws/durable-execution-sdk-js"; + +export const handler = withDurableExecution(async (event: Order, context: DurableContext) => { + // Step 1: Validate order (inline) + const validation = await context.step("validate-order", async () => { + // Validation logic + }); + + // Step 2: Authorize payment (invoke worker function) + const authorization = await context.step("authorize-payment", async () => { + const response = await lambdaClient.send( + new InvokeCommand({ + FunctionName: process.env.AUTHORIZE_PAYMENT_FUNCTION, + Payload: JSON.stringify({ orderId, paymentMethod, amount }) + }) + ); + return JSON.parse(new TextDecoder().decode(response.Payload)); + }); + + // Step 3: Allocate inventory (invoke worker function) + const allocation = await context.step("allocate-inventory", async () => { + // Invoke AllocateInventory function + }); + + // Step 4: Fulfill order (invoke worker function) + const fulfillment = await context.step("fulfill-order", async () => { + // Invoke FulfillOrder function + }); + + return { orderId, status: "completed", ... }; +}); +``` + +### Checkpoint Behavior + +When the durable function executes: +1. Each context.step creates a checkpoint before execution +2. If interrupted, Lambda saves the checkpoint state +3. On retry, the function replays from the beginning +4. Completed steps are skipped using stored checkpoint results +5. Execution continues from the last incomplete step + +### Worker Functions + +**AuthorizePayment**: Simulates payment gateway authorization with amount limits + +**AllocateInventory**: Queries DynamoDB for product availability and reserves inventory + +**FulfillOrder**: Generates shipment details and tracking numbers + +## Configuration + +### Durable Execution Settings + +The durable function is configured in the CDK stack: + +```typescript +const validateOrderFunction = new nodejs.NodejsFunction(this, "ValidateOrderFunction", { + runtime: lambda.Runtime.NODEJS_24_X, + entry: path.join(__dirname, "functions", "validateOrder", "index.ts"), + durableConfig: { + executionTimeout: cdk.Duration.hours(1), + retentionPeriod: cdk.Duration.days(7), + }, +}); +``` + +- **executionTimeout**: 1 hour maximum workflow duration +- **retentionPeriod**: 7 days checkpoint retention + +### IAM Permissions + +The orchestrator function requires permissions to: +- Invoke worker Lambda functions +- Create and retrieve durable execution checkpoints + +Worker functions require: +- DynamoDB read access (AllocateInventory) + +## Customization + +### Add More Workflow Steps + +Add additional steps to the orchestrator: + +```typescript +const notification = await context.step("send-notification", async () => { + // Send order confirmation email + return await sesClient.send(new SendEmailCommand({...})); +}); +``` + +### Modify Validation Rules + +Update validation logic in the validate-order step: + +```typescript +const validation = await context.step("validate-order", async () => { + const errors = []; + + // Add custom validation + if (orderTotal < 10) { + errors.push("Minimum order amount is $10"); + } + + return { isValid: errors.length === 0, errors }; +}); +``` + +### Integrate Real Payment Gateway + +Replace mock payment logic with actual payment service: + +```typescript +const authorization = await context.step("authorize-payment", async () => { + const stripe = new Stripe(process.env.STRIPE_SECRET_KEY); + return await stripe.paymentIntents.create({ + amount: orderTotal * 100, + currency: "usd", + customer: customerId + }); +}); +``` + +### Adjust Execution Timeout + +Modify the durable configuration in the CDK stack: + +```typescript +durableConfig: { + executionTimeout: cdk.Duration.hours(24), // 24 hours + retentionPeriod: cdk.Duration.days(14), // 14 days +} +``` + +## Monitoring + +### CloudWatch Metrics + +Monitor durable execution metrics: +- `DurableExecutionStarted` +- `DurableExecutionCompleted` +- `DurableExecutionFailed` +- `DurableExecutionCheckpointCreated` + +### CloudWatch Logs + +Look for log entries with `[DURABLE_EXECUTION]` prefix to track: +- Checkpoint creation +- Replay events +- Step execution +- Function invocations + +## Cleanup + +Delete the stack: + +```bash +cdk destroy +``` + +Or via AWS CLI: + +```bash +aws cloudformation delete-stack --stack-name LambdaDurableFunctionChainingCdkStack +``` + +## Cost Considerations + +- **Lambda**: Pay per invocation and execution time +- **DynamoDB**: On-demand pricing for product catalog reads +- **Durable Execution**: Checkpoint storage costs (minimal) +- **No charges during wait states**: Function suspended between steps + +## Learn More + +- [Lambda Durable Functions Documentation](https://docs.aws.amazon.com/lambda/latest/dg/durable-functions.html) +- [Durable Execution SDK (JavaScript)](https://github.com/aws/aws-durable-execution-sdk-js) +- [AWS CDK Documentation](https://docs.aws.amazon.com/cdk/) +- [Function Chaining Pattern](https://docs.aws.amazon.com/lambda/latest/dg/durable-functions-patterns.html) + +--- + +Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. + +SPDX-License-Identifier: MIT-0 diff --git a/lambda-durable-function-chaining-cdk/bin/lambda-durable-function-chaining-cdk.ts b/lambda-durable-function-chaining-cdk/bin/lambda-durable-function-chaining-cdk.ts new file mode 100644 index 000000000..20e103739 --- /dev/null +++ b/lambda-durable-function-chaining-cdk/bin/lambda-durable-function-chaining-cdk.ts @@ -0,0 +1,11 @@ +#!/usr/bin/env node +import * as cdk from "aws-cdk-lib/core"; +import { LambdaDurableFunctionChainingCdkStack } from "../lib/lambda-durable-function-chaining-cdk-stack"; + +const app = new cdk.App(); +new LambdaDurableFunctionChainingCdkStack(app, "LambdaDurableFunctionChainingCdkStack", { + env: { + account: process.env.CDK_DEFAULT_ACCOUNT, + region: process.env.CDK_DEFAULT_REGION, + }, +}); diff --git a/lambda-durable-function-chaining-cdk/cdk.json b/lambda-durable-function-chaining-cdk/cdk.json new file mode 100644 index 000000000..0307df5a2 --- /dev/null +++ b/lambda-durable-function-chaining-cdk/cdk.json @@ -0,0 +1,102 @@ +{ + "app": "npx ts-node --prefer-ts-exts bin/lambda-durable-function-chaining-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: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, + "@aws-cdk/aws-route53-patterns:useDistribution": true + } +} diff --git a/lambda-durable-function-chaining-cdk/lib/functions/AllocateInventory/index.ts b/lambda-durable-function-chaining-cdk/lib/functions/AllocateInventory/index.ts new file mode 100644 index 000000000..3a378f683 --- /dev/null +++ b/lambda-durable-function-chaining-cdk/lib/functions/AllocateInventory/index.ts @@ -0,0 +1,115 @@ +import { DynamoDBClient, GetItemCommand, UpdateItemCommand } from "@aws-sdk/client-dynamodb"; + +interface InventoryEvent { + orderId: string; + items: { + productId: string; + quantity: number; + unitPrice: number; + itemTotal: number; + }[]; + restore?: boolean; +} + +const dynamoClient = new DynamoDBClient(); + +export async function handler(event: InventoryEvent) { + console.log("Allocating inventory for order:", event.orderId); + + const { orderId, items, restore } = event; + + // If restore flag is set, add inventory back + if (restore) { + console.log("Restoring inventory for order:", orderId); + + for (const item of items) { + const { productId, quantity } = item; + + await dynamoClient.send( + new UpdateItemCommand({ + TableName: process.env.PRODUCT_CATALOG_TABLE, + Key: { productId: { S: productId } }, + UpdateExpression: "SET stockLevel = stockLevel + :quantity", + ExpressionAttributeValues: { + ":quantity": { N: String(quantity) }, + }, + }), + ); + } + + return { + orderId, + status: "restored", + timestamp: new Date().toISOString(), + }; + } + + const allocations = []; + + for (const item of items) { + const { productId, quantity } = item; + + // Get product from catalog + const result = await dynamoClient.send( + new GetItemCommand({ + TableName: process.env.PRODUCT_CATALOG_TABLE, + Key: { productId: { S: productId } }, + }), + ); + + if (!result.Item) { + return { + orderId, + status: "failed", + reason: "product_not_found", + productId, + timestamp: new Date().toISOString(), + }; + } + + const stockLevel = parseInt(result.Item.stockLevel.N!); + const warehouseLocation = result.Item.warehouseLocation.S!; + + if (stockLevel < quantity) { + return { + orderId, + status: "failed", + reason: "insufficient_inventory", + productId, + requested: quantity, + available: stockLevel, + timestamp: new Date().toISOString(), + }; + } + + const allocationId = `ALLOC-${productId}-${crypto.randomUUID()}`; + + // Update stock level in DynamoDB + await dynamoClient.send( + new UpdateItemCommand({ + TableName: process.env.PRODUCT_CATALOG_TABLE, + Key: { productId: { S: productId } }, + UpdateExpression: "SET stockLevel = :newStock", + ExpressionAttributeValues: { + ":newStock": { N: String(stockLevel - quantity) }, + }, + }), + ); + + allocations.push({ + allocationId, + productId, + quantity, + warehouseLocation, + reservedUntil: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), + }); + } + + return { + orderId, + status: "allocated", + allocations, + totalItems: items.reduce((sum, item) => sum + item.quantity, 0), + timestamp: new Date().toISOString(), + }; +} diff --git a/lambda-durable-function-chaining-cdk/lib/functions/FulfillOrder/index.ts b/lambda-durable-function-chaining-cdk/lib/functions/FulfillOrder/index.ts new file mode 100644 index 000000000..55feef8de --- /dev/null +++ b/lambda-durable-function-chaining-cdk/lib/functions/FulfillOrder/index.ts @@ -0,0 +1,64 @@ +interface FulfillmentEvent { + orderId: string; + customerId: string; + items: { + productId: string; + quantity: number; + unitPrice: number; + itemTotal: number; + }[]; + shippingAddress: string; + allocations: { + allocationId: string; + productId: string; + quantity: number; + warehouseLocation: string; + }[]; +} + +export async function handler(event: FulfillmentEvent) { + console.log("Fulfilling order:", event.orderId); + + const { orderId, customerId, items, shippingAddress, allocations } = event; + + // Mock shipping carrier selection + const carriers = ["Amazon Prime", "My Carrier", "Mock Carrier"]; + const selectedCarrier = carriers[Math.floor(crypto.getRandomValues(new Uint32Array(1))[0] / 0x100000000 * carriers.length)]; + + const trackingNumber = `${selectedCarrier}-${Date.now()}-${crypto.randomUUID()}`; + + const estimatedDeliveryDays = Math.floor(crypto.getRandomValues(new Uint32Array(1))[0] / 0x100000000 * 5) + 3; + const estimatedDeliveryDate = new Date(Date.now() + estimatedDeliveryDays * 24 * 60 * 60 * 1000); + + // Create shipments with pricing information + const shipments = allocations.map((allocation) => { + const item = items.find(i => i.productId === allocation.productId); + return { + shipmentId: `SHIP-${allocation.warehouseLocation}-${crypto.randomUUID()}`, + warehouseLocation: allocation.warehouseLocation, + productId: allocation.productId, + quantity: allocation.quantity, + unitPrice: item?.unitPrice || 0, + itemTotal: item?.itemTotal || 0, + status: "preparing", + }; + }); + + // Mock order fulfillment details + return { + orderId, + customerId, + status: "fulfilled", + trackingNumber, + carrier: selectedCarrier, + shippingAddress, + shipments, + estimatedDeliveryDate: estimatedDeliveryDate.toISOString(), + totalItems: items.reduce((sum, item) => sum + item.quantity, 0), + fulfillmentTimestamp: new Date().toISOString(), + notifications: { + emailSent: true, + smsSent: true, + }, + }; +} diff --git a/lambda-durable-function-chaining-cdk/lib/functions/authorizePayment/index.ts b/lambda-durable-function-chaining-cdk/lib/functions/authorizePayment/index.ts new file mode 100644 index 000000000..3db71fef2 --- /dev/null +++ b/lambda-durable-function-chaining-cdk/lib/functions/authorizePayment/index.ts @@ -0,0 +1,87 @@ +import { DynamoDBClient, GetItemCommand } from "@aws-sdk/client-dynamodb"; + +interface PaymentEvent { + orderId: string; + customerId: string; + items: { + productId: string; + quantity: number; + }[]; + paymentMethod: { + type: "credit" | "debit"; + cardNumber: string; + cardBrand: "Visa" | "Mastercard" | "Amex"; + }; +} + +const dynamoClient = new DynamoDBClient(); + +export async function handler(event: PaymentEvent) { + console.log("Authorizing payment for order:", event.orderId); + + const { paymentMethod, orderId, customerId, items } = event; + + // Fetch pricing from DynamoDB and calculate total + let amount = 0; + const itemsWithPricing = []; + + for (const item of items) { + const result = await dynamoClient.send( + new GetItemCommand({ + TableName: process.env.PRODUCT_CATALOG_TABLE, + Key: { productId: { S: item.productId } }, + }), + ); + + if (!result.Item) { + return { + orderId, + status: "declined", + reason: "product_not_found", + productId: item.productId, + timestamp: new Date().toISOString(), + }; + } + + const price = parseFloat(result.Item.price.N!); + const itemTotal = price * item.quantity; + amount += itemTotal; + + itemsWithPricing.push({ + productId: item.productId, + quantity: item.quantity, + unitPrice: price, + itemTotal, + }); + } + + // Simulate payment gateway call + const authorizationId = `AUTH-${Date.now()}-${crypto.randomUUID()}`; + + // Mock validation - reject if amount is too high (for demo purposes) + if (amount > 10000) { + return { + orderId, + status: "declined", + reason: "amount_exceeds_limit", + amount, + timestamp: new Date().toISOString(), + }; + } + + // Mock card validation + const lastFourDigits = String(paymentMethod.cardNumber).slice(-4); + + return { + orderId, + customerId, + status: "authorized", + authorizationId, + amount, + items: itemsWithPricing, + cardBrand: paymentMethod.cardBrand, + lastFourDigits, + timestamp: new Date().toISOString(), + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), // 7 days + }; +} diff --git a/lambda-durable-function-chaining-cdk/lib/functions/validateOrder/index.ts b/lambda-durable-function-chaining-cdk/lib/functions/validateOrder/index.ts new file mode 100644 index 000000000..f0c5205c6 --- /dev/null +++ b/lambda-durable-function-chaining-cdk/lib/functions/validateOrder/index.ts @@ -0,0 +1,186 @@ +import { InvokeCommand, LambdaClient } from "@aws-sdk/client-lambda"; +import { DurableContext, withDurableExecution } from "@aws/durable-execution-sdk-js"; + +interface Order { + orderId: string; + customerId: string; + items: { + productId: string; + quantity: number; + }[]; + shippingAddress: string; + billingAddress: string; + paymentMethod: { + type: "credit" | "debit"; + cardNumber: number; + cardBrand: "Visa" | "Mastercard" | "Amex"; + }; +} + +const lambdaClient = new LambdaClient(); + +export const handler = withDurableExecution(async (event: Order, context: DurableContext) => { + const { orderId, items, shippingAddress, billingAddress, paymentMethod } = event; + + const validation = await context.step("validate-order", async () => { + // Mock order validation logic + const errors = []; + + // Validate items + if (!items || items.length === 0) { + errors.push("No items in order"); + } + + // Validate addresses + if (!shippingAddress || shippingAddress.trim() === "") { + errors.push("Invalid shipping address"); + } + + if (!billingAddress || billingAddress.trim() === "") { + errors.push("Invalid billing address"); + } + + // Validate payment method + if (!paymentMethod || !paymentMethod.cardNumber) { + errors.push("Invalid payment method"); + } + + return { + isValid: errors.length === 0, + errors, + validatedAt: new Date().toISOString(), + }; + }); + + if (!validation.isValid) { + return { + orderId, + status: "rejected", + reason: "validation_failed", + errors: validation.errors, + timestamp: new Date().toISOString(), + }; + } + + const authorization = await context.step("authorize-payment", async () => { + const response = await lambdaClient.send( + new InvokeCommand({ + FunctionName: process.env.AUTHORIZE_PAYMENT_FUNCTION, + Payload: JSON.stringify({ + orderId, + customerId: event.customerId, + items, + paymentMethod, + }), + }), + ); + + const payload = JSON.parse(new TextDecoder().decode(response.Payload)); + return payload; + }); + + if (authorization.status === "declined") { + return { + orderId, + status: "payment_declined", + reason: authorization.reason, + amount: authorization.amount, + timestamp: new Date().toISOString(), + }; + } + + const allocation = await context.step("allocate-inventory", async () => { + const response = await lambdaClient.send( + new InvokeCommand({ + FunctionName: process.env.ALLOCATE_INVENTORY_FUNCTION, + Payload: JSON.stringify({ + orderId, + items: authorization.items, + }), + }), + ); + + const payload = JSON.parse(new TextDecoder().decode(response.Payload)); + return payload; + }); + + if (allocation.status === "failed") { + return { + orderId, + status: "inventory_unavailable", + reason: allocation.reason, + productId: allocation.productId, + orderTotal: authorization.amount, + timestamp: new Date().toISOString(), + }; + } + + const fulfillment = await context.step("fulfill-order", async () => { + const response = await lambdaClient.send( + new InvokeCommand({ + FunctionName: process.env.FULFILL_ORDER_FUNCTION, + Payload: JSON.stringify({ + orderId, + customerId: event.customerId, + items: authorization.items, + shippingAddress, + allocations: allocation.allocations, + }), + }), + ); + + const payload = JSON.parse(new TextDecoder().decode(response.Payload)); + + // If fulfillment fails, restore inventory + if (payload.status === "failed") { + await context.step("restore-inventory", async () => { + await lambdaClient.send( + new InvokeCommand({ + FunctionName: process.env.ALLOCATE_INVENTORY_FUNCTION, + Payload: JSON.stringify({ + orderId, + items: authorization.items, + restore: true, + }), + }), + ); + }); + + return { + orderId, + status: "fulfillment_failed", + reason: payload.reason, + orderTotal: authorization.amount, + timestamp: new Date().toISOString(), + }; + } + + return payload; + }); + + return { + orderId, + customerId: event.customerId, + status: "completed", + orderTotal: authorization.amount, + items: authorization.items, + validation, + payment: { + authorizationId: authorization.authorizationId, + amount: authorization.amount, + cardBrand: authorization.cardBrand, + lastFourDigits: authorization.lastFourDigits, + }, + inventory: { + totalItems: allocation.totalItems, + allocations: allocation.allocations, + }, + fulfillment: { + trackingNumber: fulfillment.trackingNumber, + carrier: fulfillment.carrier, + estimatedDeliveryDate: fulfillment.estimatedDeliveryDate, + shipments: fulfillment.shipments, + }, + completedAt: new Date().toISOString(), + }; +}); diff --git a/lambda-durable-function-chaining-cdk/lib/lambda-durable-function-chaining-cdk-stack.ts b/lambda-durable-function-chaining-cdk/lib/lambda-durable-function-chaining-cdk-stack.ts new file mode 100644 index 000000000..4902d6df5 --- /dev/null +++ b/lambda-durable-function-chaining-cdk/lib/lambda-durable-function-chaining-cdk-stack.ts @@ -0,0 +1,73 @@ +import * as dynamodb from "aws-cdk-lib/aws-dynamodb"; +import * as lambda from "aws-cdk-lib/aws-lambda"; +import * as nodejs from "aws-cdk-lib/aws-lambda-nodejs"; +import * as cdk from "aws-cdk-lib/core"; +import { Construct } from "constructs"; +import path from "path"; + +export class LambdaDurableFunctionChainingCdkStack extends cdk.Stack { + constructor(scope: Construct, id: string, props?: cdk.StackProps) { + super(scope, id, props); + + const productCatalogTable = new dynamodb.Table(this, "ProductCatalogTable", { + partitionKey: { + name: "productId", + type: dynamodb.AttributeType.STRING, + }, + }); + + const validateOrderFunction = new nodejs.NodejsFunction(this, "ValidateOrderFunction", { + runtime: lambda.Runtime.NODEJS_24_X, + entry: path.join(__dirname, "functions", "validateOrder", "index.ts"), + durableConfig: { + executionTimeout: cdk.Duration.hours(1), + retentionPeriod: cdk.Duration.days(7), + }, + }); + + const authorizePaymentFunction = new nodejs.NodejsFunction(this, "AuthorizePaymentFunction", { + runtime: lambda.Runtime.NODEJS_24_X, + entry: path.join(__dirname, "functions", "authorizePayment", "index.ts"), + environment: { + PRODUCT_CATALOG_TABLE: productCatalogTable.tableName, + }, + timeout: cdk.Duration.seconds(30), + }); + + validateOrderFunction.addEnvironment("AUTHORIZE_PAYMENT_FUNCTION", authorizePaymentFunction.functionName); + authorizePaymentFunction.grantInvoke(validateOrderFunction); + productCatalogTable.grantReadData(authorizePaymentFunction); + + const allocateInventoryFunction = new nodejs.NodejsFunction(this, "AllocateInventoryFunction", { + runtime: lambda.Runtime.NODEJS_24_X, + entry: path.join(__dirname, "functions", "allocateInventory", "index.ts"), + environment: { + PRODUCT_CATALOG_TABLE: productCatalogTable.tableName, + }, + timeout: cdk.Duration.seconds(30), + }); + + validateOrderFunction.addEnvironment("ALLOCATE_INVENTORY_FUNCTION", allocateInventoryFunction.functionName); + allocateInventoryFunction.grantInvoke(validateOrderFunction); + + const fulfillOrderFunction = new nodejs.NodejsFunction(this, "FulfillOrderFunction", { + runtime: lambda.Runtime.NODEJS_24_X, + entry: path.join(__dirname, "functions", "fulfillOrder", "index.ts"), + timeout: cdk.Duration.seconds(30), + }); + + validateOrderFunction.addEnvironment("FULFILL_ORDER_FUNCTION", fulfillOrderFunction.functionName); + fulfillOrderFunction.grantInvoke(validateOrderFunction); + + productCatalogTable.grantReadWriteData(allocateInventoryFunction); + + // Outputs + new cdk.CfnOutput(this, "ProductCatalogTableName", { + value: productCatalogTable.tableName, + }); + + new cdk.CfnOutput(this, "ValidateOrderFunctionName", { + value: validateOrderFunction.functionName, + }) + } +} diff --git a/lambda-durable-function-chaining-cdk/package.json b/lambda-durable-function-chaining-cdk/package.json new file mode 100644 index 000000000..d9ba2d1d3 --- /dev/null +++ b/lambda-durable-function-chaining-cdk/package.json @@ -0,0 +1,27 @@ +{ + "name": "lambda-durable-function-chaining-cdk", + "version": "0.1.0", + "bin": { + "lambda-durable-function-chaining-cdk": "bin/lambda-durable-function-chaining-cdk.js" + }, + "scripts": { + "build": "tsc", + "watch": "tsc -w", + "cdk": "cdk" + }, + "devDependencies": { + "@types/aws-lambda": "^8.10.160", + "@types/jest": "^30", + "@types/node": "^24.10.1", + "aws-cdk": "2.1104.0", + "ts-node": "^10.9.2", + "typescript": "~5.9.3" + }, + "dependencies": { + "@aws-sdk/client-dynamodb": "^3.978.0", + "@aws-sdk/client-lambda": "^3.978.0", + "@aws/durable-execution-sdk-js": "^1.0.2", + "aws-cdk-lib": "^2.236.0", + "constructs": "^10.0.0" + } +} diff --git a/lambda-durable-function-chaining-cdk/tsconfig.json b/lambda-durable-function-chaining-cdk/tsconfig.json new file mode 100644 index 000000000..bfc61bf83 --- /dev/null +++ b/lambda-durable-function-chaining-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" + ] +}