From d3998177b227e635d97e84b5979cc8c824e34233 Mon Sep 17 00:00:00 2001
From: Ben Freiberg <9841563+bfreiberg@users.noreply.github.com>
Date: Fri, 6 Mar 2026 13:09:57 +0100
Subject: [PATCH] feat(lambda-durable-bedrock-async-invoke-cdk) new pattern
---
.../.gitignore | 8 +
.../.npmignore | 6 +
.../README.md | 140 ++++++++++
.../bin/cdk-bedrock-async-invoke.ts | 20 ++
.../cdk.json | 103 ++++++++
.../example-pattern.json | 71 +++++
.../jest.config.js | 8 +
.../lib/cdk-bedrock-async-invoke-stack.ts | 102 ++++++++
.../lib/lambda/retry-strategies.ts | 30 +++
.../lib/lambda/types.ts | 30 +++
.../lib/lambda/video-generator.test.ts | 100 +++++++
.../lib/lambda/video-generator.ts | 245 ++++++++++++++++++
.../package.json | 29 +++
.../test/cdk-bedrock-async-invoke.test.ts | 47 ++++
.../tsconfig.json | 32 +++
15 files changed, 971 insertions(+)
create mode 100644 lambda-durable-bedrock-async-invoke-cdk/.gitignore
create mode 100644 lambda-durable-bedrock-async-invoke-cdk/.npmignore
create mode 100644 lambda-durable-bedrock-async-invoke-cdk/README.md
create mode 100644 lambda-durable-bedrock-async-invoke-cdk/bin/cdk-bedrock-async-invoke.ts
create mode 100644 lambda-durable-bedrock-async-invoke-cdk/cdk.json
create mode 100644 lambda-durable-bedrock-async-invoke-cdk/example-pattern.json
create mode 100644 lambda-durable-bedrock-async-invoke-cdk/jest.config.js
create mode 100644 lambda-durable-bedrock-async-invoke-cdk/lib/cdk-bedrock-async-invoke-stack.ts
create mode 100644 lambda-durable-bedrock-async-invoke-cdk/lib/lambda/retry-strategies.ts
create mode 100644 lambda-durable-bedrock-async-invoke-cdk/lib/lambda/types.ts
create mode 100644 lambda-durable-bedrock-async-invoke-cdk/lib/lambda/video-generator.test.ts
create mode 100644 lambda-durable-bedrock-async-invoke-cdk/lib/lambda/video-generator.ts
create mode 100644 lambda-durable-bedrock-async-invoke-cdk/package.json
create mode 100644 lambda-durable-bedrock-async-invoke-cdk/test/cdk-bedrock-async-invoke.test.ts
create mode 100644 lambda-durable-bedrock-async-invoke-cdk/tsconfig.json
diff --git a/lambda-durable-bedrock-async-invoke-cdk/.gitignore b/lambda-durable-bedrock-async-invoke-cdk/.gitignore
new file mode 100644
index 0000000000..f60797b6a9
--- /dev/null
+++ b/lambda-durable-bedrock-async-invoke-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-bedrock-async-invoke-cdk/.npmignore b/lambda-durable-bedrock-async-invoke-cdk/.npmignore
new file mode 100644
index 0000000000..c1d6d45dcf
--- /dev/null
+++ b/lambda-durable-bedrock-async-invoke-cdk/.npmignore
@@ -0,0 +1,6 @@
+*.ts
+!*.d.ts
+
+# CDK asset staging directory
+.cdk.staging
+cdk.out
diff --git a/lambda-durable-bedrock-async-invoke-cdk/README.md b/lambda-durable-bedrock-async-invoke-cdk/README.md
new file mode 100644
index 0000000000..c1e43e1d06
--- /dev/null
+++ b/lambda-durable-bedrock-async-invoke-cdk/README.md
@@ -0,0 +1,140 @@
+# Amazon Bedrock Async Invoke with AWS Lambda durable functions
+
+This pattern shows how to use AWS Lambda durable functions to orchestrate [Amazon Bedrock Async Invoke](https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_StartAsyncInvoke.html) for AI video generation. The durable function starts an Amazon Nova Reel video generation job, then polls for completion using `waitForCondition` with exponential backoff. During each polling interval the function suspends execution entirely, incurring zero compute charges while Bedrock processes the video.
+
+Without durable functions this pattern would require a separate polling mechanism such as Step Functions, EventBridge rules, or a cron-based poller. Here the entire workflow is a single, linear function that reads top-to-bottom.
+
+Learn more about this pattern at Serverless Land Patterns: [https://serverlessland.com/patterns/lambda-durable-bedrock-async-invoke](https://serverlessland.com/patterns/lambda-durable-bedrock-async-invoke)
+
+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](https://portal.aws.amazon.com/gp/aws/developer/registration/index.html) 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 v2](https://docs.aws.amazon.com/cli/latest/userguide/install-cliv2.html) (latest available version) installed and configured
+* [Git Installed](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git)
+* [AWS CDK](https://docs.aws.amazon.com/cdk/latest/guide/getting_started.html) (version 2.232.1 or later) installed and configured
+* [Node.js 22.x](https://nodejs.org/) installed
+* Amazon Bedrock model access enabled for **Amazon Nova Reel** (`amazon.nova-reel-v1:1`) in your target region
+
+## 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
+ ```
+
+1. Change directory to the pattern directory:
+
+ ```bash
+ cd lambda-durable-bedrock-async-invoke
+ ```
+
+1. Install the project dependencies:
+
+ ```bash
+ npm install
+ ```
+
+1. Deploy the CDK stack:
+
+ ```bash
+ npx cdk deploy
+ ```
+
+ The stack creates:
+ - An S3 bucket for video output (auto-deleted on stack destroy, 7-day lifecycle)
+ - A durable Lambda function with 30-minute execution timeout
+ - IAM permissions for Bedrock and S3 access
+ - A CloudWatch log group with 1-week retention
+
+1. Note the outputs from the CDK deployment process. These contain the resource names and ARNs used for testing.
+
+## How it works
+
+The durable function performs three logical phases:
+
+1. **`start-video-generation` step** — calls `StartAsyncInvoke` with a `clientRequestToken` for Bedrock-level idempotency. Because this runs inside a durable step, the Bedrock invocation ARN is checkpointed and will not be re-executed on replay.
+
+2. **`wait-for-video-ready` waitForCondition** — polls `GetAsyncInvoke` with exponential backoff (30 s → 60 s cap). The function suspends during each wait interval, consuming no compute time while the video is being generated.
+
+3. **`build-result` step** — assembles the final response with the S3 output location and metadata, or throws an error if the generation failed.
+
+```
+Client ──► Lambda (durable) ──► Bedrock StartAsyncInvoke ──► S3 (video output)
+ │ │
+ │ ◄── waitForCondition ──► │
+ │ (poll with │
+ │ exponential │
+ │ backoff) │
+ │ │
+ └── GetAsyncInvoke ─────────┘
+```
+
+Key concepts:
+
+| Concept | How it is used |
+|---|---|
+| `step` | Wraps the `StartAsyncInvoke` call so it is checkpointed and never re-executed on replay |
+| `waitForCondition` | Polls `GetAsyncInvoke` with exponential backoff; the function suspends between polls |
+| `clientRequestToken` | Bedrock idempotency token generated inside a step, ensuring replays cannot create duplicate invocations |
+| `context.logger` | Replay-aware structured logging throughout the workflow |
+| S3 output | Bedrock writes the generated video directly to an S3 bucket provisioned by CDK |
+
+## Testing
+
+After deployment, invoke the durable function using the AWS CLI.
+
+Because the durable execution timeout is 30 minutes (exceeding Lambda's 15-minute synchronous limit), you must invoke the function **asynchronously**. Use `--durable-execution-name` for idempotency at the Lambda level.
+
+### Invoke the durable function
+
+```bash
+aws lambda invoke \
+ --function-name 'video-generator-durable:$LATEST' \
+ --invocation-type Event \
+ --durable-execution-name "my-beach-video-001" \
+ --payload '{"prompt":"A golden retriever playing fetch on a sunny beach","durationSeconds":6}' \
+ --cli-binary-format raw-in-base64-out \
+ response.json
+```
+
+Repeat the same command with the same `--durable-execution-name` to safely retry without creating a duplicate execution.
+
+### Check execution status
+
+```bash
+aws lambda get-durable-execution \
+ --function-name 'video-generator-durable:$LATEST' \
+ --durable-execution-name "my-beach-video-001"
+```
+
+Once the status shows `SUCCEEDED`, the result will contain the S3 URI where the video was written.
+
+### Run unit tests
+
+```bash
+npm test
+```
+
+This runs both CDK infrastructure tests and durable handler tests (with mocked Bedrock calls).
+
+## Cleanup
+
+1. Delete the stack:
+
+ ```bash
+ npx cdk destroy
+ ```
+
+1. Confirm the stack has been deleted by checking the AWS CloudFormation console or running:
+
+ ```bash
+ aws cloudformation list-stacks --stack-status-filter DELETE_COMPLETE
+ ```
+
+----
+Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+
+SPDX-License-Identifier: MIT-0
diff --git a/lambda-durable-bedrock-async-invoke-cdk/bin/cdk-bedrock-async-invoke.ts b/lambda-durable-bedrock-async-invoke-cdk/bin/cdk-bedrock-async-invoke.ts
new file mode 100644
index 0000000000..65f02a355d
--- /dev/null
+++ b/lambda-durable-bedrock-async-invoke-cdk/bin/cdk-bedrock-async-invoke.ts
@@ -0,0 +1,20 @@
+#!/opt/homebrew/opt/node/bin/node
+import * as cdk from 'aws-cdk-lib/core';
+import { CdkBedrockAsyncInvokeStack } from '../lib/cdk-bedrock-async-invoke-stack';
+
+const app = new cdk.App();
+new CdkBedrockAsyncInvokeStack(app, 'CdkBedrockAsyncInvokeStack', {
+ /* 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-bedrock-async-invoke-cdk/cdk.json b/lambda-durable-bedrock-async-invoke-cdk/cdk.json
new file mode 100644
index 0000000000..70ed193ff9
--- /dev/null
+++ b/lambda-durable-bedrock-async-invoke-cdk/cdk.json
@@ -0,0 +1,103 @@
+{
+ "app": "npx ts-node --prefer-ts-exts bin/cdk-bedrock-async-invoke.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-bedrock-async-invoke-cdk/example-pattern.json b/lambda-durable-bedrock-async-invoke-cdk/example-pattern.json
new file mode 100644
index 0000000000..30f79b0565
--- /dev/null
+++ b/lambda-durable-bedrock-async-invoke-cdk/example-pattern.json
@@ -0,0 +1,71 @@
+{
+ "title": "Amazon Bedrock Async Invoke with AWS Lambda durable functions",
+ "description": "Orchestrate long-running Amazon Bedrock video generation jobs using AWS Lambda durable functions with waitForCondition polling and zero-cost waits",
+ "language": "TypeScript",
+ "level": "300",
+ "framework": "AWS CDK",
+ "introBox": {
+ "headline": "How it works",
+ "text": [
+ "This pattern deploys an AWS Lambda durable function that orchestrates Amazon Bedrock Async Invoke to generate AI videos using Amazon Nova Reel.",
+ "The durable function starts a video generation job with StartAsyncInvoke, then polls for completion using waitForCondition with exponential backoff. During each polling interval the function suspends entirely, incurring zero compute charges while Bedrock processes the video.",
+ "An S3 bucket is provisioned for video output. The durable execution SDK checkpoints progress at each step, so the workflow is fault-tolerant and idempotent without requiring external orchestration."
+ ]
+ },
+ "gitHub": {
+ "template": {
+ "repoURL": "https://github.com/aws-samples/serverless-patterns/tree/main/lambda-durable-bedrock-async-invoke",
+ "templateURL": "serverless-patterns/lambda-durable-bedrock-async-invoke",
+ "projectFolder": "lambda-durable-bedrock-async-invoke",
+ "templateFile": "lib/cdk-bedrock-async-invoke-stack.ts"
+ }
+ },
+ "resources": {
+ "bullets": [
+ {
+ "text": "AWS Lambda durable functions documentation",
+ "link": "https://docs.aws.amazon.com/lambda/latest/dg/durable-basic-concepts.html"
+ },
+ {
+ "text": "Durable Execution SDK",
+ "link": "https://docs.aws.amazon.com/lambda/latest/dg/durable-execution-sdk.html"
+ },
+ {
+ "text": "Amazon Bedrock Async Invoke API reference",
+ "link": "https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_StartAsyncInvoke.html"
+ },
+ {
+ "text": "AWS CDK Developer Guide",
+ "link": "https://docs.aws.amazon.com/cdk/latest/guide/"
+ }
+ ]
+ },
+ "deploy": {
+ "text": [
+ "npm install",
+ "npx cdk deploy"
+ ]
+ },
+ "testing": {
+ "text": [
+ "See the GitHub repo for detailed testing instructions."
+ ]
+ },
+ "cleanup": {
+ "text": [
+ "Delete the stack: npx cdk destroy."
+ ]
+ },
+ "authors": [
+ {
+ "name": "Ben Freiberg",
+ "image": "https://serverlessland.com/assets/images/resources/contributors/ben-freiberg.jpg",
+ "bio": "Ben is a Senior Solutions Architect at Amazon Web Services (AWS) based in Frankfurt, Germany.",
+ "linkedin": "benfreiberg"
+ },{
+ "name": "Michael Gasch",
+ "bio": "Michael is a Senior Product Manager at Amazon Web Services (AWS) based in Germany.",
+ "linkedin": "michael-gasch"
+ }
+ ]
+}
diff --git a/lambda-durable-bedrock-async-invoke-cdk/jest.config.js b/lambda-durable-bedrock-async-invoke-cdk/jest.config.js
new file mode 100644
index 0000000000..c0e833bce2
--- /dev/null
+++ b/lambda-durable-bedrock-async-invoke-cdk/jest.config.js
@@ -0,0 +1,8 @@
+module.exports = {
+ testEnvironment: 'node',
+ roots: ['/test', '/lib'],
+ testMatch: ['**/*.test.ts'],
+ transform: {
+ '^.+\\.tsx?$': 'ts-jest'
+ },
+};
\ No newline at end of file
diff --git a/lambda-durable-bedrock-async-invoke-cdk/lib/cdk-bedrock-async-invoke-stack.ts b/lambda-durable-bedrock-async-invoke-cdk/lib/cdk-bedrock-async-invoke-stack.ts
new file mode 100644
index 0000000000..0a6ab0affa
--- /dev/null
+++ b/lambda-durable-bedrock-async-invoke-cdk/lib/cdk-bedrock-async-invoke-stack.ts
@@ -0,0 +1,102 @@
+import * as iam from 'aws-cdk-lib/aws-iam';
+import * as lambda from 'aws-cdk-lib/aws-lambda';
+import * as nodejs from 'aws-cdk-lib/aws-lambda-nodejs';
+import * as logs from 'aws-cdk-lib/aws-logs';
+import * as s3 from 'aws-cdk-lib/aws-s3';
+import * as cdk from 'aws-cdk-lib/core';
+import { Construct } from 'constructs';
+import * as path from 'path';
+
+export class CdkBedrockAsyncInvokeStack extends cdk.Stack {
+ constructor(scope: Construct, id: string, props?: cdk.StackProps) {
+ super(scope, id, props);
+
+ // S3 bucket where Bedrock writes the generated video output
+ const outputBucket = new s3.Bucket(this, 'VideoOutputBucket', {
+ removalPolicy: cdk.RemovalPolicy.DESTROY,
+ autoDeleteObjects: true,
+ lifecycleRules: [
+ {
+ expiration: cdk.Duration.days(7),
+ id: 'ExpireVideosAfter7Days',
+ },
+ ],
+ });
+
+ // Explicit log group with cleanup on stack destroy
+ const logGroup = new logs.LogGroup(this, 'VideoGeneratorLogGroup', {
+ logGroupName: '/aws/lambda/video-generator-durable',
+ retention: logs.RetentionDays.ONE_WEEK,
+ removalPolicy: cdk.RemovalPolicy.DESTROY,
+ });
+
+ // Durable Lambda function for the video generation workflow
+ const videoGeneratorFunction = new nodejs.NodejsFunction(this, 'VideoGeneratorFunction', {
+ functionName: 'video-generator-durable',
+ description:
+ 'Durable function demonstrating Bedrock async invoke for AI video generation',
+ runtime: lambda.Runtime.NODEJS_22_X,
+ handler: 'handler',
+ entry: path.join(__dirname, 'lambda', 'video-generator.ts'),
+ timeout: cdk.Duration.minutes(1),
+ memorySize: 256,
+ durableConfig: {
+ executionTimeout: cdk.Duration.minutes(30),
+ retentionPeriod: cdk.Duration.days(1),
+ },
+ bundling: {
+ minify: true,
+ sourceMap: true,
+ externalModules: [],
+ },
+ environment: {
+ NODE_OPTIONS: '--enable-source-maps',
+ OUTPUT_BUCKET_NAME: outputBucket.bucketName,
+ BEDROCK_MODEL_ID: 'amazon.nova-reel-v1:1',
+ BEDROCK_REGION: 'us-east-1',
+ },
+ logGroup: logGroup,
+ });
+
+ // Grant the function permission to write to the output bucket.
+ // Bedrock writes the video output directly, but the function also
+ // needs s3:PutObject so that Bedrock can use the function's role
+ // when writing to the bucket via the async invocation.
+ outputBucket.grantReadWrite(videoGeneratorFunction);
+
+ // Grant Bedrock invocation permissions
+ videoGeneratorFunction.addToRolePolicy(
+ new iam.PolicyStatement({
+ actions: [
+ 'bedrock:InvokeModel',
+ 'bedrock:GetAsyncInvoke',
+ 'bedrock:StartAsyncInvoke',
+ ],
+ resources: ['*'],
+ }),
+ );
+
+ // Add durable execution managed policy (required when using explicit log groups)
+ videoGeneratorFunction.role?.addManagedPolicy(
+ iam.ManagedPolicy.fromAwsManagedPolicyName(
+ 'service-role/AWSLambdaBasicDurableExecutionRolePolicy',
+ ),
+ );
+
+ // Stack outputs
+ new cdk.CfnOutput(this, 'VideoGeneratorFunctionArn', {
+ value: videoGeneratorFunction.functionArn,
+ description: 'ARN of the Video Generator Durable Function',
+ });
+
+ new cdk.CfnOutput(this, 'VideoGeneratorFunctionName', {
+ value: videoGeneratorFunction.functionName,
+ description: 'Name of the Video Generator Durable Function',
+ });
+
+ new cdk.CfnOutput(this, 'VideoOutputBucketName', {
+ value: outputBucket.bucketName,
+ description: 'S3 bucket where generated videos are stored',
+ });
+ }
+}
\ No newline at end of file
diff --git a/lambda-durable-bedrock-async-invoke-cdk/lib/lambda/retry-strategies.ts b/lambda-durable-bedrock-async-invoke-cdk/lib/lambda/retry-strategies.ts
new file mode 100644
index 0000000000..823581ffcc
--- /dev/null
+++ b/lambda-durable-bedrock-async-invoke-cdk/lib/lambda/retry-strategies.ts
@@ -0,0 +1,30 @@
+/**
+ * Retry strategy for Bedrock API calls.
+ * Skips retries on validation errors (wrong model ID, bad input, etc.)
+ * and uses exponential backoff for transient failures.
+ */
+export function bedrockRetryStrategy(error: any, attemptCount: number) {
+ if (error?.name === 'ValidationException' || error?.message?.includes('ValidationException')) {
+ return { shouldRetry: false };
+ }
+ if (attemptCount >= 3) {
+ return { shouldRetry: false };
+ }
+ return {
+ shouldRetry: true,
+ delay: { seconds: Math.pow(2, attemptCount) },
+ };
+}
+
+/**
+ * Default retry strategy with exponential backoff, max 3 attempts.
+ */
+export function defaultRetryStrategy(_error: any, attemptCount: number) {
+ if (attemptCount >= 3) {
+ return { shouldRetry: false };
+ }
+ return {
+ shouldRetry: true,
+ delay: { seconds: Math.pow(2, attemptCount) },
+ };
+}
\ No newline at end of file
diff --git a/lambda-durable-bedrock-async-invoke-cdk/lib/lambda/types.ts b/lambda-durable-bedrock-async-invoke-cdk/lib/lambda/types.ts
new file mode 100644
index 0000000000..c61ad1e328
--- /dev/null
+++ b/lambda-durable-bedrock-async-invoke-cdk/lib/lambda/types.ts
@@ -0,0 +1,30 @@
+/**
+ * Input for the video generation workflow
+ */
+export interface VideoGenerationInput {
+ prompt: string;
+ durationSeconds?: number;
+ seed?: number;
+}
+
+/**
+ * State tracked across polling iterations
+ */
+export interface AsyncInvokeState {
+ invocationArn: string;
+ status: 'InProgress' | 'Completed' | 'Failed';
+ checkCount: number;
+ failureMessage?: string;
+}
+
+/**
+ * Final result returned by the handler
+ */
+export interface VideoGenerationResult {
+ invocationArn: string;
+ status: string;
+ outputS3Uri: string;
+ totalChecks: number;
+ prompt: string;
+ completedAt: string;
+}
\ No newline at end of file
diff --git a/lambda-durable-bedrock-async-invoke-cdk/lib/lambda/video-generator.test.ts b/lambda-durable-bedrock-async-invoke-cdk/lib/lambda/video-generator.test.ts
new file mode 100644
index 0000000000..c0973180ba
--- /dev/null
+++ b/lambda-durable-bedrock-async-invoke-cdk/lib/lambda/video-generator.test.ts
@@ -0,0 +1,100 @@
+import { LocalDurableTestRunner, OperationType, OperationStatus } from '@aws/durable-execution-sdk-js-testing';
+import { handler } from './video-generator';
+
+// Mock the Bedrock Runtime client
+jest.mock('@aws-sdk/client-bedrock-runtime', () => {
+ const MOCK_INVOCATION_ARN =
+ 'arn:aws:bedrock:us-east-1:123456789012:async-invoke/abc123def456';
+
+ let callCount = 0;
+
+ return {
+ BedrockRuntimeClient: jest.fn().mockImplementation(() => ({
+ send: jest.fn().mockImplementation((command: any) => {
+ if (command.constructor.name === 'StartAsyncInvokeCommand') {
+ return Promise.resolve({
+ invocationArn: MOCK_INVOCATION_ARN,
+ });
+ }
+ if (command.constructor.name === 'GetAsyncInvokeCommand') {
+ callCount++;
+ // Simulate: first two checks return InProgress, third returns Completed
+ if (callCount >= 3) {
+ return Promise.resolve({
+ invocationArn: MOCK_INVOCATION_ARN,
+ status: 'Completed',
+ outputDataConfig: {
+ s3OutputDataConfig: {
+ s3Uri: 's3://test-bucket/videos/output/',
+ },
+ },
+ });
+ }
+ return Promise.resolve({
+ invocationArn: MOCK_INVOCATION_ARN,
+ status: 'InProgress',
+ });
+ }
+ return Promise.reject(new Error('Unknown command'));
+ }),
+ })),
+ StartAsyncInvokeCommand: jest.fn().mockImplementation((input: any) => ({
+ constructor: { name: 'StartAsyncInvokeCommand' },
+ input,
+ })),
+ GetAsyncInvokeCommand: jest.fn().mockImplementation((input: any) => ({
+ constructor: { name: 'GetAsyncInvokeCommand' },
+ input,
+ })),
+ };
+});
+
+describe('Video Generator - Bedrock Async Invoke', () => {
+ beforeAll(() => LocalDurableTestRunner.setupTestEnvironment({ skipTime: true }));
+ afterAll(() => LocalDurableTestRunner.teardownTestEnvironment());
+
+ it('should start async invocation and poll until completion', async () => {
+ const runner = new LocalDurableTestRunner({
+ handlerFunction: handler,
+ });
+
+ const execution = await runner.run({
+ payload: {
+ prompt: 'A golden retriever playing fetch on a sunny beach',
+ durationSeconds: 6,
+ },
+ });
+
+ const result = execution.getResult() as any;
+
+ // Verify the workflow completed successfully
+ expect(result).toBeDefined();
+ expect(result.status).toBe('Completed');
+ expect(result.prompt).toBe('A golden retriever playing fetch on a sunny beach');
+ expect(result.invocationArn).toContain('async-invoke');
+ expect(result.totalChecks).toBeGreaterThanOrEqual(1);
+ expect(result.completedAt).toBeDefined();
+
+ // Verify the idempotency token step ran
+ const tokenStep = runner.getOperation('generate-idempotency-token');
+ expect(tokenStep).toBeDefined();
+ expect(tokenStep.getType()).toBe(OperationType.STEP);
+ expect(tokenStep.getStatus()).toBe(OperationStatus.SUCCEEDED);
+
+ // Verify the start-video-generation step ran
+ const startStep = runner.getOperation('start-video-generation');
+ expect(startStep).toBeDefined();
+ expect(startStep.getType()).toBe(OperationType.STEP);
+ expect(startStep.getStatus()).toBe(OperationStatus.SUCCEEDED);
+
+ // Verify the waitForCondition polling ran
+ const waitOp = runner.getOperation('wait-for-video-ready');
+ expect(waitOp).toBeDefined();
+
+ // Verify the build-result step ran
+ const resultStep = runner.getOperation('build-result');
+ expect(resultStep).toBeDefined();
+ expect(resultStep.getType()).toBe(OperationType.STEP);
+ expect(resultStep.getStatus()).toBe(OperationStatus.SUCCEEDED);
+ });
+});
\ No newline at end of file
diff --git a/lambda-durable-bedrock-async-invoke-cdk/lib/lambda/video-generator.ts b/lambda-durable-bedrock-async-invoke-cdk/lib/lambda/video-generator.ts
new file mode 100644
index 0000000000..b4b1869557
--- /dev/null
+++ b/lambda-durable-bedrock-async-invoke-cdk/lib/lambda/video-generator.ts
@@ -0,0 +1,245 @@
+import { DurableContext, withDurableExecution } from '@aws/durable-execution-sdk-js';
+import {
+ BedrockRuntimeClient,
+ StartAsyncInvokeCommand,
+ GetAsyncInvokeCommand,
+} from '@aws-sdk/client-bedrock-runtime';
+
+import { VideoGenerationInput, AsyncInvokeState, VideoGenerationResult } from './types';
+import { bedrockRetryStrategy, defaultRetryStrategy } from './retry-strategies';
+
+const BEDROCK_MODEL_ID = process.env.BEDROCK_MODEL_ID ?? 'amazon.nova-reel-v1:1';
+const BEDROCK_REGION = process.env.BEDROCK_REGION ?? 'us-east-1';
+const OUTPUT_BUCKET = process.env.OUTPUT_BUCKET_NAME ?? '';
+
+// Maximum number of polling iterations before treating the invocation as timed out
+const MAX_POLL_CHECKS = 10;
+
+const bedrockClient = new BedrockRuntimeClient({ region: BEDROCK_REGION });
+
+/**
+ * Start a Bedrock async invocation for video generation.
+ */
+async function startVideoGeneration(
+ client: BedrockRuntimeClient,
+ event: VideoGenerationInput,
+ idempotencyToken: string,
+ stepCtx: { logger: { info: (msg: string, data?: any) => void } },
+): Promise {
+ const s3OutputUri = `s3://${OUTPUT_BUCKET}/videos/${idempotencyToken}/`;
+
+ stepCtx.logger.info('Starting Bedrock async invocation', {
+ model: BEDROCK_MODEL_ID,
+ outputUri: s3OutputUri,
+ });
+
+ const response = await client.send(
+ new StartAsyncInvokeCommand({
+ modelId: BEDROCK_MODEL_ID,
+ clientRequestToken: idempotencyToken,
+ modelInput: {
+ taskType: 'TEXT_VIDEO',
+ textToVideoParams: {
+ text: event.prompt,
+ },
+ videoGenerationConfig: {
+ durationSeconds: event.durationSeconds ?? 6,
+ fps: 24,
+ dimension: '1280x720',
+ seed: event.seed ?? 0,
+ },
+ },
+ outputDataConfig: {
+ s3OutputDataConfig: {
+ s3Uri: s3OutputUri,
+ },
+ },
+ }),
+ );
+
+ const arn = response.invocationArn!;
+ stepCtx.logger.info('Bedrock async invocation started', { invocationArn: arn });
+ return arn;
+}
+
+/**
+ * Check the current status of a Bedrock async invocation.
+ */
+async function checkInvocationStatus(
+ client: BedrockRuntimeClient,
+ currentState: AsyncInvokeState,
+ ctx: { logger: { info: (msg: string, data?: any) => void } },
+): Promise {
+ ctx.logger.info('Checking Bedrock async invocation status', {
+ invocationArn: currentState.invocationArn,
+ checkNumber: currentState.checkCount + 1,
+ });
+
+ const response = await client.send(
+ new GetAsyncInvokeCommand({
+ invocationArn: currentState.invocationArn,
+ }),
+ );
+
+ const status = (response.status as AsyncInvokeState['status']) ?? 'InProgress';
+
+ ctx.logger.info('Bedrock invocation status retrieved', {
+ invocationArn: currentState.invocationArn,
+ status,
+ failureMessage: response.failureMessage,
+ });
+
+ return {
+ invocationArn: currentState.invocationArn,
+ status,
+ checkCount: currentState.checkCount + 1,
+ failureMessage: response.failureMessage,
+ };
+}
+
+/**
+ * Build the final result from the completed polling state.
+ * Throws if the generation failed or timed out.
+ */
+function buildResult(
+ finalState: AsyncInvokeState,
+ event: VideoGenerationInput,
+ idempotencyToken: string,
+ stepCtx: { logger: { info: (msg: string, data?: any) => void; error: (msg: string, data?: any) => void } },
+): VideoGenerationResult {
+ if (finalState.status === 'Failed') {
+ stepCtx.logger.error('Video generation failed', {
+ invocationArn: finalState.invocationArn,
+ failureMessage: finalState.failureMessage,
+ });
+ throw new Error(
+ `Video generation failed: ${finalState.failureMessage ?? 'Unknown error'}`,
+ );
+ }
+
+ if (finalState.status !== 'Completed') {
+ stepCtx.logger.error('Video generation timed out', {
+ invocationArn: finalState.invocationArn,
+ checkCount: finalState.checkCount,
+ });
+ throw new Error(
+ `Video generation timed out after ${finalState.checkCount} polling attempts (status: ${finalState.status})`,
+ );
+ }
+
+ const outputUri = `s3://${OUTPUT_BUCKET}/videos/${idempotencyToken}/`;
+
+ stepCtx.logger.info('Video generation completed', {
+ invocationArn: finalState.invocationArn,
+ totalChecks: finalState.checkCount,
+ outputUri,
+ });
+
+ return {
+ invocationArn: finalState.invocationArn,
+ status: finalState.status,
+ outputS3Uri: outputUri,
+ totalChecks: finalState.checkCount,
+ prompt: event.prompt,
+ completedAt: new Date().toISOString(),
+ };
+}
+
+/**
+ * AI Video Generation Pipeline — Demonstrates Bedrock Async Invoke with durable functions
+ *
+ * This durable function orchestrates Amazon Nova Reel video generation:
+ * 1. Generates a deterministic idempotency token (checkpointed for replay safety)
+ * 2. Starts a Bedrock async invocation (video output written to S3)
+ * 3. Polls GetAsyncInvoke with exponential backoff using waitForCondition
+ * 4. Returns the S3 location of the generated video
+ *
+ * Without durable functions you would need a separate polling mechanism
+ * (Step Functions, EventBridge, or a cron-based poller). Here the entire
+ * flow is a single, linear function with automatic state persistence and
+ * zero compute cost during waits.
+ */
+export const handler = withDurableExecution(
+ async (event: VideoGenerationInput, context: DurableContext): Promise => {
+ context.logger.info('Starting video generation workflow', {
+ prompt: event.prompt,
+ durationSeconds: event.durationSeconds ?? 6,
+ });
+
+ try {
+ // Step 1: Generate idempotency token
+ // This is in its own step so the token is checkpointed. If the
+ // subsequent Bedrock call fails and retries, the same token is
+ // reused and the request stays idempotent.
+ const idempotencyToken = await context.step(
+ 'generate-idempotency-token',
+ async (): Promise => {
+ return `video-${Date.now()}-${Math.random().toString(36).substring(2, 10)}`;
+ },
+ );
+
+ // Step 2: Start the async Bedrock invocation
+ const invocationArn = await context.step(
+ 'start-video-generation',
+ async (stepCtx) => startVideoGeneration(bedrockClient, event, idempotencyToken, stepCtx),
+ { retryStrategy: bedrockRetryStrategy },
+ );
+
+ // Step 3: Poll for completion using waitForCondition with exponential backoff
+ // The durable function suspends during each wait interval, incurring zero
+ // compute charges while the video is being generated.
+ const finalState = await context.waitForCondition(
+ 'wait-for-video-ready',
+ async (currentState: AsyncInvokeState, ctx) =>
+ checkInvocationStatus(bedrockClient, currentState, ctx),
+ {
+ initialState: {
+ invocationArn,
+ status: 'InProgress' as const,
+ checkCount: 0,
+ },
+ waitStrategy: (state: AsyncInvokeState, attempt: number) => {
+ if (state.status === 'Completed' || state.status === 'Failed') {
+ return { shouldContinue: false };
+ }
+
+ // Guard against infinite polling
+ if (state.checkCount >= MAX_POLL_CHECKS) {
+ return { shouldContinue: false };
+ }
+
+ // Exponential backoff starting at 30s: 30s, 60s, 60s, ... capped at 60s.
+ // Nova Reel generation takes minutes so a higher initial delay avoids
+ // unnecessary API calls.
+ const delaySeconds = Math.min(30 * Math.pow(2, attempt - 1), 60);
+
+ return {
+ shouldContinue: true,
+ delay: { seconds: delaySeconds },
+ };
+ },
+ },
+ );
+
+ // Step 4: Build the final result
+ const result = await context.step(
+ 'build-result',
+ async (stepCtx) => buildResult(finalState, event, idempotencyToken, stepCtx),
+ { retryStrategy: defaultRetryStrategy },
+ );
+
+ context.logger.info('Video generation workflow completed', {
+ invocationArn: result.invocationArn,
+ status: result.status,
+ });
+
+ return result;
+ } catch (error: any) {
+ context.logger.error('Video generation workflow failed', {
+ error: error.message,
+ prompt: event.prompt,
+ });
+ throw error;
+ }
+ },
+);
diff --git a/lambda-durable-bedrock-async-invoke-cdk/package.json b/lambda-durable-bedrock-async-invoke-cdk/package.json
new file mode 100644
index 0000000000..6b2077fde2
--- /dev/null
+++ b/lambda-durable-bedrock-async-invoke-cdk/package.json
@@ -0,0 +1,29 @@
+{
+ "name": "cdk-bedrock-async-invoke",
+ "version": "0.1.0",
+ "bin": {
+ "cdk-bedrock-async-invoke": "bin/cdk-bedrock-async-invoke.js"
+ },
+ "scripts": {
+ "build": "tsc",
+ "watch": "tsc -w",
+ "test": "jest",
+ "cdk": "cdk"
+ },
+ "devDependencies": {
+ "@aws/durable-execution-sdk-js-testing": "^1.0.0",
+ "@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/durable-execution-sdk-js": "^1.0.0",
+ "@aws-sdk/client-bedrock-runtime": "^3.0.0",
+ "aws-cdk-lib": "^2.232.2",
+ "constructs": "^10.0.0"
+ }
+}
diff --git a/lambda-durable-bedrock-async-invoke-cdk/test/cdk-bedrock-async-invoke.test.ts b/lambda-durable-bedrock-async-invoke-cdk/test/cdk-bedrock-async-invoke.test.ts
new file mode 100644
index 0000000000..181c147923
--- /dev/null
+++ b/lambda-durable-bedrock-async-invoke-cdk/test/cdk-bedrock-async-invoke.test.ts
@@ -0,0 +1,47 @@
+import * as cdk from 'aws-cdk-lib/core';
+import { Template, Match } from 'aws-cdk-lib/assertions';
+import { CdkBedrockAsyncInvokeStack } from '../lib/cdk-bedrock-async-invoke-stack';
+
+describe('CdkBedrockAsyncInvokeStack', () => {
+ const app = new cdk.App();
+ const stack = new CdkBedrockAsyncInvokeStack(app, 'TestStack');
+ const template = Template.fromStack(stack);
+
+ test('creates an S3 bucket for video output', () => {
+ template.hasResource('AWS::S3::Bucket', {
+ DeletionPolicy: 'Delete',
+ });
+ });
+
+ test('creates a durable Lambda function', () => {
+ template.hasResourceProperties('AWS::Lambda::Function', {
+ FunctionName: 'video-generator-durable',
+ Runtime: 'nodejs22.x',
+ });
+ });
+
+ test('grants Bedrock permissions', () => {
+ template.hasResourceProperties('AWS::IAM::Policy', {
+ PolicyDocument: {
+ Statement: Match.arrayWith([
+ Match.objectLike({
+ Action: Match.arrayWith([
+ 'bedrock:InvokeModel',
+ 'bedrock:GetAsyncInvoke',
+ 'bedrock:StartAsyncInvoke',
+ ]),
+ }),
+ ]),
+ },
+ });
+ });
+
+ test('creates CloudWatch log group with DESTROY removal', () => {
+ template.hasResource('AWS::Logs::LogGroup', {
+ DeletionPolicy: 'Delete',
+ Properties: {
+ LogGroupName: '/aws/lambda/video-generator-durable',
+ },
+ });
+ });
+});
\ No newline at end of file
diff --git a/lambda-durable-bedrock-async-invoke-cdk/tsconfig.json b/lambda-durable-bedrock-async-invoke-cdk/tsconfig.json
new file mode 100644
index 0000000000..bfc61bf833
--- /dev/null
+++ b/lambda-durable-bedrock-async-invoke-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"
+ ]
+}