From b3a920cc97f8793cdaa2131c9817a897152cb617 Mon Sep 17 00:00:00 2001 From: Avnish Kumar Date: Tue, 27 May 2025 18:34:52 -0700 Subject: [PATCH 1/6] DDB Stepfunction trigger constructs commit --- ddbstream-lambda-sfn-cdk-ts/.gitignore | 8 + ddbstream-lambda-sfn-cdk-ts/.npmignore | 6 + ddbstream-lambda-sfn-cdk-ts/README.md | 209 +++++++++++++ ddbstream-lambda-sfn-cdk-ts/app.ts | 21 ++ ddbstream-lambda-sfn-cdk-ts/cdk.json | 50 +++ ddbstream-lambda-sfn-cdk-ts/jest.config.js | 8 + ddbstream-lambda-sfn-cdk-ts/package.json | 26 ++ .../src/lambda/package.json | 18 ++ .../lib/ddbstream-lambda-sfn-example-stack.ts | 57 ++++ .../src/lib/ddbstream-lambda-sfn.ts | 292 ++++++++++++++++++ ddbstream-lambda-sfn-cdk-ts/tsconfig.json | 32 ++ 11 files changed, 727 insertions(+) create mode 100644 ddbstream-lambda-sfn-cdk-ts/.gitignore create mode 100644 ddbstream-lambda-sfn-cdk-ts/.npmignore create mode 100644 ddbstream-lambda-sfn-cdk-ts/README.md create mode 100644 ddbstream-lambda-sfn-cdk-ts/app.ts create mode 100644 ddbstream-lambda-sfn-cdk-ts/cdk.json create mode 100644 ddbstream-lambda-sfn-cdk-ts/jest.config.js create mode 100644 ddbstream-lambda-sfn-cdk-ts/package.json create mode 100644 ddbstream-lambda-sfn-cdk-ts/src/lambda/package.json create mode 100644 ddbstream-lambda-sfn-cdk-ts/src/lib/ddbstream-lambda-sfn-example-stack.ts create mode 100644 ddbstream-lambda-sfn-cdk-ts/src/lib/ddbstream-lambda-sfn.ts create mode 100644 ddbstream-lambda-sfn-cdk-ts/tsconfig.json diff --git a/ddbstream-lambda-sfn-cdk-ts/.gitignore b/ddbstream-lambda-sfn-cdk-ts/.gitignore new file mode 100644 index 000000000..f60797b6a --- /dev/null +++ b/ddbstream-lambda-sfn-cdk-ts/.gitignore @@ -0,0 +1,8 @@ +*.js +!jest.config.js +*.d.ts +node_modules + +# CDK asset staging directory +.cdk.staging +cdk.out diff --git a/ddbstream-lambda-sfn-cdk-ts/.npmignore b/ddbstream-lambda-sfn-cdk-ts/.npmignore new file mode 100644 index 000000000..c1d6d45dc --- /dev/null +++ b/ddbstream-lambda-sfn-cdk-ts/.npmignore @@ -0,0 +1,6 @@ +*.ts +!*.d.ts + +# CDK asset staging directory +.cdk.staging +cdk.out diff --git a/ddbstream-lambda-sfn-cdk-ts/README.md b/ddbstream-lambda-sfn-cdk-ts/README.md new file mode 100644 index 000000000..818f8b66a --- /dev/null +++ b/ddbstream-lambda-sfn-cdk-ts/README.md @@ -0,0 +1,209 @@ + +# DynamoDB Stream to Step Functions Trigger + +A CDK construct to automatically trigger AWS Step Functions workflows in response to changes in DynamoDB tables. The `DynamoWorkflowTrigger` construct bridges DynamoDB streams and Step Functions by allowing you to define event handlers that monitor specific changes in your DynamoDB tables and trigger workflows in response. It leverages Lambda functions to evaluate conditions and start Step Functions state machines with inputs derived from the DynamoDB events. + +Learn more about this pattern at Serverless Land Patterns: https://serverlessland.com/patterns/{} + +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](https://docs.aws.amazon.com/cli/latest/userguide/install-cliv2.html) installed and configured +* [Node and NPM](https://nodejs.org/en/download/) installed +* [Git Installed](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) +* [AWS Cloud Development Kit](https://docs.aws.amazon.com/cdk/latest/guide/cli.html) (AWS CDK) installed + +## Deployment Instructions + +1. Create a new directory, navigate to that directory in a terminal and clone the GitHub repository: + ``` + git clone https://github.com/aws-samples/serverless-patterns + ``` +2. Change directory to the pattern directory: + ``` + cd cdk-vpc-lambda-sfn + ``` +3. To deploy from the command line use the following: + ```bash + npm install + npx cdk bootstrap aws://accountnumber/region + npm run lambda + npx cdk synth + npx cdk deploy --all + ``` + + +## Cleanup + +1. From the command line, use the following in the source folder + ```bash + npx cdk destroy + ``` +2. Confirm the removal and wait for the resource deletion to complete. +---- +Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. + +SPDX-License-Identifier: MIT-0 + + + +## Example Stack Explanation + +The `DdbstreamLambdaSfnExampleStack` demonstrates how to use the `DynamoWorkflowTrigger` construct: + +1. It creates a DynamoDB table (`TestTable`) with streaming enabled +2. It creates a simple Step Functions state machine (`TestStateMachine`) +3. It sets up a trigger with the following behavior: + - It applies a filter to ignore events where a `SkipMe` attribute exists in the new image + - It only processes `MODIFY` events (updates to existing items) + - It checks two conditions: + - The new value of `testKey` must be "test8" + - The old value of `testKey` must have been "test9" + - When all conditions are met, it triggers the state machine with input parameters extracted from the DynamoDB event: + - `Index` taken from the item's partition key + - `MapAttribute` taken from the first element in a list attribute + +This workflow allows you to respond to specific data changes in DynamoDB by executing custom workflows with Step Functions. + +## Creating Your Own Stacks + +### Basic Setup + +1. Import necessary modules: + +```typescript +import { DynamoWorkflowTrigger, EventName } from "ddbstream-lambda-sfn"; +import { AttributeType, StreamViewType, Table } from "aws-cdk-lib/aws-dynamodb"; +import { StateMachine } from "aws-cdk-lib/aws-stepfunctions"; +``` + +2. Create a DynamoDB table with streaming enabled: + +```typescript +const myTable = new Table(this, "MyTable", { + partitionKey: { + name: "Id", + type: AttributeType.STRING + }, + stream: StreamViewType.NEW_AND_OLD_IMAGES // Required for the trigger to work +}); +``` + +3. Create a Step Functions state machine: + +```typescript +const myStateMachine = new StateMachine(this, "MyWorkflow", { + definition: /* your state machine definition */ +}); +``` + +4. Create the workflow trigger: + +```typescript +new DynamoWorkflowTrigger(this, "MyTrigger", { + eventHandlers: [ + { + table: myTable, + eventNames: [EventName.Insert], // Only trigger on inserts + conditions: [ + { jsonPath: "$.NewImage.status.S", value: "PENDING" } + ], + stateMachineConfig: { + stateMachine: myStateMachine, + input: { + id: "$.NewImage.Id.S", + timestamp: "$.NewImage.createdAt.S" + } + } + } + ] +}); +``` + +#### Monitoring multiple event types: + +```typescript +new DynamoWorkflowTrigger(this, "MultipleTriggers", { + eventHandlers: [ + { + table: orderTable, + eventNames: [EventName.Insert], + conditions: [{ jsonPath: "$.NewImage.status.S", value: "NEW" }], + stateMachineConfig: { + stateMachine: newOrderWorkflow, + input: { orderId: "$.NewImage.orderId.S" } + } + }, + { + table: orderTable, + eventNames: [EventName.Modify], + conditions: [ + { jsonPath: "$.NewImage.status.S", value: "CANCELED" }, + { jsonPath: "$.OldImage.status.S", value: "IN_PROGRESS" } + ], + stateMachineConfig: { + stateMachine: cancelOrderWorkflow, + input: { orderId: "$.NewImage.orderId.S" } + } + } + ] +}); +``` + +#### Using event source filters: + +```typescript +new DynamoWorkflowTrigger(this, "FilteredTrigger", { + eventSourceFilters: [ + FilterCriteria.filter({ + dynamodb: { + NewImage: { + status: { + S: FilterRule.isEqual("ACTIVE"), + }, + }, + }, + }), + ], + eventHandlers: [ + /* event handlers */ + ] +}); +``` + +#### Using VPC configuration: + +```typescript +new DynamoWorkflowTrigger(this, "VpcTrigger", { + vpc: myVpc, + subnetType: SubnetType.PRIVATE_WITH_EGRESS, + additionalSecurityGroups: [mySecurityGroup], + eventHandlers: [ + /* event handlers */ + ] +}); +``` + +## Features + +- Dead letter queue for failed invocations +- VPC support +- Custom security groups +- Fine-grained event filtering +- Multiple event handlers per construct +- JSONPath-based condition evaluation +- Input mapping for state machines + +## Limitations + +- Tables must have streams enabled with `NEW_AND_OLD_IMAGES` +- Conditions currently only support exact matches via the `value` property +- For complex filtering, use Lambda event source filters + +## Troubleshooting + +- Check CloudWatch Logs for the Lambda function +- Monitor the dead letter queue for failed events +- Ensure IAM permissions are correct for DynamoDB stream access and Step Functions execution \ No newline at end of file diff --git a/ddbstream-lambda-sfn-cdk-ts/app.ts b/ddbstream-lambda-sfn-cdk-ts/app.ts new file mode 100644 index 000000000..97f8d4b79 --- /dev/null +++ b/ddbstream-lambda-sfn-cdk-ts/app.ts @@ -0,0 +1,21 @@ +#!/usr/bin/env node +import 'source-map-support/register'; +import * as cdk from 'aws-cdk-lib'; +import { DdbstreamLambdaSfnExampleStack } from './src/lib/ddbstream-lambda-sfn-example-stack'; + +const app = new cdk.App(); +new DdbstreamLambdaSfnExampleStack(app, 'DDBEventTrigger', { + /* 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: 'AWS_ACCOUNT', region: 'REGION' }, + + /* For more information, see https://docs.aws.amazon.com/cdk/latest/guide/environments.html */ +}); \ No newline at end of file diff --git a/ddbstream-lambda-sfn-cdk-ts/cdk.json b/ddbstream-lambda-sfn-cdk-ts/cdk.json new file mode 100644 index 000000000..b614b8555 --- /dev/null +++ b/ddbstream-lambda-sfn-cdk-ts/cdk.json @@ -0,0 +1,50 @@ +{ + "app": "npx ts-node --prefer-ts-exts app.ts", + "watch": { + "include": [ + "**" + ], + "exclude": [ + "README.md", + "cdk*.json", + "**/*.d.ts", + "**/*.js", + "tsconfig.json", + "package*.json", + "yarn.lock", + "node_modules", + "test" + ] + }, + "context": { + "@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-iam:standardizedServicePrincipals": 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 + } + } \ No newline at end of file diff --git a/ddbstream-lambda-sfn-cdk-ts/jest.config.js b/ddbstream-lambda-sfn-cdk-ts/jest.config.js new file mode 100644 index 000000000..08263b895 --- /dev/null +++ b/ddbstream-lambda-sfn-cdk-ts/jest.config.js @@ -0,0 +1,8 @@ +module.exports = { + testEnvironment: 'node', + roots: ['/test'], + testMatch: ['**/*.test.ts'], + transform: { + '^.+\\.tsx?$': 'ts-jest' + } +}; diff --git a/ddbstream-lambda-sfn-cdk-ts/package.json b/ddbstream-lambda-sfn-cdk-ts/package.json new file mode 100644 index 000000000..a5c420529 --- /dev/null +++ b/ddbstream-lambda-sfn-cdk-ts/package.json @@ -0,0 +1,26 @@ +{ + "name": "ddbstream-lambda-sfn-cdk-ts", + "version": "0.1.0", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "scripts": { + "build": "tsc", + "watch": "tsc -w", + "test": "jest", + "lambda": "cd ./src/lambda && npm i" + }, + "devDependencies": { + "@types/jest": "^29.5.14", + "@types/node": "22.7.9", + "aws-cdk-lib": "2.195.0", + "@types/aws-lambda": "^8.10.102", + "constructs": "^10.0.0", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "typescript": "~5.6.3" + }, + "peerDependencies": { + "aws-cdk-lib": "2.195.0", + "constructs": "^10.0.0" + } +} \ No newline at end of file diff --git a/ddbstream-lambda-sfn-cdk-ts/src/lambda/package.json b/ddbstream-lambda-sfn-cdk-ts/src/lambda/package.json new file mode 100644 index 000000000..1825b3f3d --- /dev/null +++ b/ddbstream-lambda-sfn-cdk-ts/src/lambda/package.json @@ -0,0 +1,18 @@ +{ + "name": "lambda", + "version": "1.0.0", + "description": "Lambdas to consume via ALB", + "private": true, + "license": "MIT", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "dependencies": { + "@aws-sdk/client-sfn": "^3.817.0", + "aws-embedded-metrics": "^4.2.0", + "jsonpath": "^1.1.1" + } +} diff --git a/ddbstream-lambda-sfn-cdk-ts/src/lib/ddbstream-lambda-sfn-example-stack.ts b/ddbstream-lambda-sfn-cdk-ts/src/lib/ddbstream-lambda-sfn-example-stack.ts new file mode 100644 index 000000000..e2b63e279 --- /dev/null +++ b/ddbstream-lambda-sfn-cdk-ts/src/lib/ddbstream-lambda-sfn-example-stack.ts @@ -0,0 +1,57 @@ +import * as cdk from 'aws-cdk-lib'; +import { Construct } from 'constructs'; +import { AttributeType, StreamViewType, Table } from "aws-cdk-lib/aws-dynamodb" +import { Pass, StateMachine } from "aws-cdk-lib/aws-stepfunctions" +import { DynamoWorkflowTrigger, EventName } from "./ddbstream-lambda-sfn" +import { FilterCriteria, FilterRule } from "aws-cdk-lib/aws-lambda"; + + +export class DdbstreamLambdaSfnExampleStack extends cdk.Stack { + constructor(scope: Construct, id: string, props?: cdk.StackProps) { + super(scope, id, props); + + // Create a test table + const testTable = new Table(this, "TestTable", { + partitionKey: { + name: "Index", + type: AttributeType.STRING + }, + stream: StreamViewType.NEW_AND_OLD_IMAGES + }) + + // Create a test state machine + const testStateMachine = new StateMachine(this, "TestStateMachine", { + definition: new Pass(this, "TestPassState") + }) + + // Create a trigger on insert + const exampleTrigger = new DynamoWorkflowTrigger(this, "TestTrigger", { + eventSourceFilters: [ + FilterCriteria.filter({ + dynamodb: { + NewImage: { + SkipMe: { + // Only trigger when attribute "SkipMe" does not exist + S: FilterRule.notExists(), + }, + }, + }, + }), + ], + eventHandlers: [ + { + table: testTable, + eventNames: [EventName.Modify], + conditions: [{ jsonPath: "$.NewImage.testKey.S", value: "test8"}, { jsonPath: "$.OldImage.testKey.S", value: "test9"}], // Ensure this is always an array + stateMachineConfig: { + stateMachine: testStateMachine, + input: { + Index: "$.NewImage.Index.S", + MapAttribute: "$.newImage.ListAttribute.l[0]" + } + } + } + ] + }) + } +} \ No newline at end of file diff --git a/ddbstream-lambda-sfn-cdk-ts/src/lib/ddbstream-lambda-sfn.ts b/ddbstream-lambda-sfn-cdk-ts/src/lib/ddbstream-lambda-sfn.ts new file mode 100644 index 000000000..7c65dcae2 --- /dev/null +++ b/ddbstream-lambda-sfn-cdk-ts/src/lib/ddbstream-lambda-sfn.ts @@ -0,0 +1,292 @@ +import { Duration, RemovalPolicy } from "aws-cdk-lib" +import { Dashboard } from "aws-cdk-lib/aws-cloudwatch" +import { ITable } from "aws-cdk-lib/aws-dynamodb" +import { ISecurityGroup, IVpc, Peer, Port, SecurityGroup, SubnetType } from "aws-cdk-lib/aws-ec2" +import { Key } from "aws-cdk-lib/aws-kms" +import { Function, Runtime, Code, StartingPosition } from "aws-cdk-lib/aws-lambda" +import { DynamoEventSource, SqsDlq } from "aws-cdk-lib/aws-lambda-event-sources" +import { RetentionDays } from "aws-cdk-lib/aws-logs" +import { Queue, QueueEncryption } from "aws-cdk-lib/aws-sqs" +import { IStateMachine } from "aws-cdk-lib/aws-stepfunctions" +import { Construct } from "constructs" +import * as path from 'path'; + +/** + * DynamoDB event stream event names. Use this to filter events based on whether they are inserts, updates, + * or deletes. + */ +export enum EventName { + /** + * A new item was inserted into the DDB table. + */ + Insert = "INSERT", + /** + * An existing item was modified in the DDB table. + */ + Modify = "MODIFY", + /** + * An item was removed from the DDB table. + */ + Remove = "REMOVE" +} + +/** + * The state machine config describes which state machine to invoke, and what properties to input into it. + */ +export interface StateMachineConfig { + /** + * State machine to invoke. + */ + stateMachine: IStateMachine + /** + * Input map. + */ + input?: { + [name: string]: string + } +} + +export interface Condition { + jsonPath: string, + value: string +} + +/** + * Each event handler describes a kind of change in DynamoDB to react to, and the state machine to trigger + * when that event happens. + */ +export interface EventHandler { + /** + * Table to consume events from. The table must have streaming enabled and set to NEW_AND_OLD_IMAGES. + */ + table: ITable + /** + * The types of events (INSERT, MODIFY, REMOVE) to trigger on. + */ + eventNames?: EventName[] + /** + * Conditions that must be met for this event handler to trigger. These are JSONPath expressions that are + * evaluated over the `dynamoDb` property of the event record. + */ + conditions?: Condition[] + /** + * The state machine to execute if conditions are met. + */ + stateMachineConfig: StateMachineConfig +} + +/** + * DynamoWorkflowTrigger construct properties. + */ +export interface DynamoWorkflowTriggerProps { + /** + * List of event handlers to trigger on. Each event handler describes a set of conditions that must be + * met, and a state machine to trigger when those conditions are met. + */ + eventHandlers: EventHandler[] + /** + * Number of times to re-try a failed Lambda invocation before sending it to the dead-letter queue. + * + * @default 3 + */ + retries?: number + /** + * VPC to run the trigger Lambda inside of. + */ + vpc?: IVpc + /** + * SubnetType to use for VPC Subnet. Requires setting vpc. + * + * Defaults to SubnetType.ISOLATED if vpc is configured. + */ + subnetType?: SubnetType + /** + * Additional security groups to apply to the event trigger lambda. Requires setting vpc. + * + * The event trigger lambda requires communication to the StepFunctions service endpoint. If a vpc is configured + * but this prop is not specified, a default security group enabling all egress HTTPS traffic is used. + * It is recommend that consumers of this construct provide a vpc and explicit security groups that limit traffic + * to only the StepFunctions service endpoint over HTTPS (port 443) using a VPC interface endpoint. + */ + additionalSecurityGroups?: ISecurityGroup[] + /** + * Add filter criteria option for event source. + * + * @default - None + */ + readonly eventSourceFilters?: Array<{ + [key: string]: any + }> +} + +/** + * State machine config that resolves the state machine to its ARN. + */ +export interface LambdaStateMachineConfig { + /** + * State machine ARN. + */ + stateMachineArn: string + /** + * Input map. + */ + input?: { + [name: string]: string + } +} + +/** + * Event handler representation that resolves each table to its event source ARN. + */ +interface LambdaEventHandler { + /** + * Table to consume events from. The table must have streaming enabled and set to NEW_AND_OLD_IMAGES. + */ + eventSourceArn: string + /** + * The types of events (INSERT, MODIFY, REMOVE) to trigger on. + */ + eventNames?: EventName[] + /** + * Conditions that must be met for this event handler to trigger. These are JSONPath expressions that are + * evaluated over the `dynamoDb` property of the event record. + */ + conditions?: Condition[] + /** + * The state machine to execute if conditions are met. + */ + stateMachineConfig: LambdaStateMachineConfig +} + +/** + * A CDK construct that to trigger StepFunctions workflows in response to changes in a DynamoDB table. + * + * The construct contains a Lambda function that evaluates JSONPath expressions against DynamoDB event + * records to determine whether a workflow must be executed. An arbitrary number of event handlers can + * be defined for a single table to handle different kinds of state transitions. + * + * The construct includes a dead-letter queue for failed invocations, as well as a dashboard and alarms. + */ +export class DynamoWorkflowTrigger extends Construct { + /** + * The Lambda function to be invoked for each DynamoDB event record. + */ + public readonly lambda: Function + + /** + * Generated CloudWatch dashboard. + */ + public readonly deadLetterQueue: Queue + + /** + * Generated CloudWatch dashboard. + */ + public readonly dashboard?: Dashboard + + + /** + * Creates a new instance. + * + * @param parent parent construct. + * @param id construct id. + * @param props properties. + */ + constructor(parent: Construct, id: string, props: DynamoWorkflowTriggerProps) { + super(parent, id) + + const dlqKmsKey = new Key(parent, "DlqKey", { + description: "SSE for encrypting the workflow trigger SQS DLQ.", + enableKeyRotation: true, + removalPolicy: RemovalPolicy.RETAIN + }) + + // Create a dead-letter queue for failed invocations. + this.deadLetterQueue = new Queue(this, "Dlq", { + retentionPeriod: Duration.days(14), + encryption: QueueEncryption.KMS, + encryptionMasterKey: dlqKmsKey, + }) + + // Construct event handler configuration for the Lambda function. This resolves CDK Table + // constructs to their event stream ARNs. + const lambdaEventHandlers: LambdaEventHandler[] = props.eventHandlers.map((handler) => { + return { + eventSourceArn: handler.table.tableStreamArn!, + eventNames: handler.eventNames, + conditions: handler.conditions, + stateMachineConfig: { + stateMachineArn: handler.stateMachineConfig.stateMachine.stateMachineArn, + input: handler.stateMachineConfig.input, + } + } + }) + + if (!props.vpc && props.additionalSecurityGroups) { + throw new Error("Cannot specify security groups without configuring a vpc.") + } + if (!props.vpc && props.subnetType) { + throw new Error("Cannot specify subnetType without configuring a vpc.") + } + + // If VPC is set build sane defaults into subnet type and security groups. + const networkConfiguration = props.vpc + ? { + vpc: props.vpc, + subnetType: props.subnetType ?? SubnetType.PRIVATE_ISOLATED, + securityGroups: props.additionalSecurityGroups || [this.buildDefaultSecurityGroup(props.vpc)] + } + : {} + + // Create the Lambda function. + this.lambda = new Function(this, "Lambda", { + code: Code.fromAsset(path.join(__dirname, '../lambda')), + handler: "index.handler", + runtime: Runtime.NODEJS_20_X, + memorySize: 2048, + timeout: Duration.seconds(20), + environment: { + EVENT_HANDLER_CONFIG: JSON.stringify({ + eventHandlers: lambdaEventHandlers + }) + }, + ...networkConfiguration, + logRetention: RetentionDays.TEN_YEARS + }) + + // Give the Lambda function read access to the required tables and allow it to + // start executions for the relevant state machines. + props.eventHandlers.forEach((handler) => { + handler.table.grantStreamRead(this.lambda) + // grantStreamRead is supposed to grant decrypt to the KMS key but it doesn't + handler.table.encryptionKey?.grantDecrypt(this.lambda) + handler.stateMachineConfig.stateMachine.grantStartExecution(this.lambda) + }) + + // For each table, create an event source and wire it up to the Lambda function. + const tables = new Set(props.eventHandlers.map((handler) => handler.table)) + tables.forEach((table) => { + // Create event source for the Lambda function. + const eventSource = new DynamoEventSource(table, { + startingPosition: StartingPosition.TRIM_HORIZON, + onFailure: new SqsDlq(this.deadLetterQueue), + retryAttempts: props.retries || 10, + bisectBatchOnError: true, + filters: props.eventSourceFilters, + }) + + // Connect the DDB event source to the Lambda. + this.lambda.addEventSource(eventSource) + }) + } + + buildDefaultSecurityGroup(vpc: IVpc): SecurityGroup { + const defaultSecurityGroup = new SecurityGroup(this, "DefaultSecurityGroup", { + vpc: vpc, + description: "DynamoWorkflowTrigger default security group.", + allowAllOutbound: false + }) + defaultSecurityGroup.addEgressRule(Peer.anyIpv4(), Port.tcp(443), "Enable HTTPS egress.") + + return defaultSecurityGroup + } +} diff --git a/ddbstream-lambda-sfn-cdk-ts/tsconfig.json b/ddbstream-lambda-sfn-cdk-ts/tsconfig.json new file mode 100644 index 000000000..1b620444f --- /dev/null +++ b/ddbstream-lambda-sfn-cdk-ts/tsconfig.json @@ -0,0 +1,32 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": [ + "es2022" + ], + "declaration": true, + "strict": true, + "noImplicitAny": true, + "noEmit": true, + "strictNullChecks": true, + "noImplicitThis": true, + "alwaysStrict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": false, + "inlineSourceMap": true, + "inlineSources": true, + "experimentalDecorators": true, + "strictPropertyInitialization": false, + "typeRoots": [ + "./node_modules/@types" + ] + }, + "exclude": [ + "node_modules", + "cdk.out" + ] +} From 74819fd55cb51aaf940ddf803ec3a409a64d0349 Mon Sep 17 00:00:00 2001 From: avnishamzn <155021888+avnishamzn@users.noreply.github.com> Date: Tue, 27 May 2025 18:57:21 -0700 Subject: [PATCH 2/6] Add files via upload DDB stream event trigger constructs --- .../src/lambda/index.js | 214 ++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 ddbstream-lambda-sfn-cdk-ts/src/lambda/index.js diff --git a/ddbstream-lambda-sfn-cdk-ts/src/lambda/index.js b/ddbstream-lambda-sfn-cdk-ts/src/lambda/index.js new file mode 100644 index 000000000..ef1cd3e1c --- /dev/null +++ b/ddbstream-lambda-sfn-cdk-ts/src/lambda/index.js @@ -0,0 +1,214 @@ +const { SFNClient, StartExecutionCommand } = require('@aws-sdk/client-sfn'); +const { createMetricsLogger } = require('aws-embedded-metrics'); +const JsonPath = require('jsonpath'); + +/** + * @typedef {Object} StateMachineConfig + * @property {string} stateMachineArn + * @property {Object.} input + * @property {string[]} executionNamePrefixKeys + * @property {Object.} traceContext + */ + +/** + * @typedef {Object} EventHandler + * @property {string} eventSourceArn + * @property {string[]} eventNames + * @property {string[]} conditions + * @property {StateMachineConfig} stateMachineConfig + */ + +/** + * @typedef {Object} EventStreamHandlerConfig + * @property {EventHandler[]} eventHandlers + */ + +class StreamEventStateMachineHandler { + static get METRICS_OPERATION() { return 'HandleRecord'; } + static get METRICS_EXECUTION_EXISTS() { return 'ExecutionAlreadyExists'; } + static get METRICS_EXECUTION_STARTED() { return 'ExecutionStarted'; } + static get METRICS_JSON_PATH_ERROR() { return 'JsonPathError'; } + static get EVENT_HANDLER_CONFIG() { return 'EVENT_HANDLER_CONFIG'; } + + constructor() { + this.metricsLogger = createMetricsLogger(); + this.metricsLogger.setNamespace(process.env.AWS_LAMBDA_FUNCTION_NAME || ''); + this.metricsLogger.setDimensions({ Operation: StreamEventStateMachineHandler.METRICS_OPERATION }); + + this.stepFunctionsClient = new SFNClient({ + region: process.env.AWS_REGION + }); + + this.eventStreamHandlerConfig = this.parseConfigFromEnvironment(); + } + + parseConfigFromEnvironment() { + console.log('Parsing configuration from environment'); + try { + return JSON.parse(process.env.EVENT_HANDLER_CONFIG || ''); + } catch (ex) { + console.error('Unable to parse configuration, cannot start!', ex, process.env.EVENT_HANDLER_CONFIG); + throw ex; + } + } + + async handleRequest(event, context) { + console.log('Received DynamoDB event:', JSON.stringify(event)); + + for (const record of event.Records) { + const startTime = Date.now(); + let success = false; + + try { + await this.handleRecord(record); + success = true; + } finally { + const endTime = Date.now(); + this.metricsLogger.putMetric('Time', endTime - startTime, 'Milliseconds'); + + if (success) { + this.metricsLogger.putMetric('SuccessLatency', endTime - startTime, 'Milliseconds'); + } + + this.metricsLogger.setProperty('StartTime', new Date(startTime).toISOString()); + this.metricsLogger.setProperty('EndTime', new Date(endTime).toISOString()); + this.metricsLogger.putMetric('Exception', success ? 0 : 1, 'Count'); + await this.metricsLogger.flush(); + } + } + } + + async handleRecord(record) { + console.log('Processing DynamoDB record:', record.eventID); + + this.metricsLogger.putMetric(StreamEventStateMachineHandler.METRICS_JSON_PATH_ERROR, 0, 'Count'); + this.metricsLogger.putMetric(StreamEventStateMachineHandler.METRICS_EXECUTION_EXISTS, 0, 'Count'); + + for (const eventHandler of this.eventStreamHandlerConfig.eventHandlers) { + if (this.recordMatchesHandler(record, eventHandler)) { + await this.startExecution(eventHandler.stateMachineConfig, record); + } + else { + console.log('Record does not match handler', eventHandler); + } + } + } + + recordMatchesHandler(record, eventHandler) { + if (!record || !eventHandler) { + console.error('Invalid parameters: record and eventHandler are required'); + return false; + } + + if (eventHandler.eventNames.length > 0 && !eventHandler.eventNames.includes(record.eventName || '')) { + console.log('Event name does not match', record.eventName, eventHandler.eventNames); + return false; + } + + const jsonRecord = record.dynamodb; + if (!jsonRecord) { + console.error('Invalid parameters: record.dynamodb is required'); + return false; + } + + if (eventHandler.conditions.length > 0) { + return eventHandler.conditions.every(element => { + if (!element || !element.jsonPath) { + return false; + } + + const query = element.jsonPath; + try { + const matches = JsonPath.query(jsonRecord, query); + if (matches.length === 0) { + console.log('Unable find any match for the condition', query, 'value', element.value); + return false; + } + console.log(`JsonPath query ${query} looking for value ${element.value} running on the following record: `,jsonRecord, " found the following potential match value", matches[0]); + return matches[0] === element.value; + } catch (ex) { + console.error('Unable to run the json query', ex, query, jsonRecord); + this.metricsLogger.putMetric(StreamEventStateMachineHandler.METRICS_JSON_PATH_ERROR, 1, 'Count'); + return false; + } + }); + } + + return true; + } + + async startExecution(stateMachineConfig, record) { + try { + const executionName = this.buildExecutionName(record); + const input = this.buildExecutionInput(stateMachineConfig, record); + + console.log(`Starting execution ${executionName} for state machine ${stateMachineConfig.stateMachineArn}`); + + const command = new StartExecutionCommand({ + stateMachineArn: stateMachineConfig.stateMachineArn, + name: executionName, + input: JSON.stringify(input) + }); + + const response = await this.stepFunctionsClient.send(command); + + console.log('Started execution:', response.executionArn); + this.metricsLogger.putMetric(StreamEventStateMachineHandler.METRICS_EXECUTION_STARTED, 1, 'Count'); + } catch (error) { + if (error.name === 'ExecutionAlreadyExists') { + console.log('Execution already exists'); + this.metricsLogger.putMetric(StreamEventStateMachineHandler.METRICS_EXECUTION_EXISTS, 1, 'Count'); + } else { + throw error; + } + } + } + + buildExecutionName(record) { + return `${record.eventID || Date.now()}`; + } + + buildExecutionInput(stateMachineConfig, record) { + if (!stateMachineConfig || !record) { + throw new Error('Invalid parameters: stateMachineConfig and record are required'); + } + + try { + const result = {}; + // If input mapping is defined in stateMachineConfig + if (stateMachineConfig.input && typeof stateMachineConfig.input === 'object') { + // For each key-value pair in the input configuration + Object.entries(stateMachineConfig.input).forEach(([key, jsonPath]) => { + try { + // Evaluate the JSONPath expression against the dynamodb property + const matches = JsonPath.query(record.dynamodb, jsonPath); + + if (matches && matches.length > 0) { + result[key] = matches[0]; + } else { + console.warn(`No matches found for JSONPath: ${jsonPath} for key: ${key}`); + result[key] = null; + } + } catch (error) { + console.error(`Error evaluating JSONPath for key ${key}:`, error); + result[key] = null; + } + }); + } + console.log('Built execution input:', result); + return result; + + } catch (error) { + console.error('Error building execution input:', error); + throw new Error(`Failed to build execution input: ${error.message}`); + } + } +} + +/** + * Lambda handler function + */ +exports.handler = async (event, context) => { + const handler = new StreamEventStateMachineHandler(); + return handler.handleRequest(event, context); +}; \ No newline at end of file From 093deb0e3d33c4baef243723b075c4465d85c44e Mon Sep 17 00:00:00 2001 From: Saptarshi Banerjee Date: Tue, 27 May 2025 19:06:45 -0700 Subject: [PATCH 3/6] files updated and added --- ddbstream-lambda-sfn-cdk-ts/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ddbstream-lambda-sfn-cdk-ts/README.md b/ddbstream-lambda-sfn-cdk-ts/README.md index 818f8b66a..2b7697e6f 100644 --- a/ddbstream-lambda-sfn-cdk-ts/README.md +++ b/ddbstream-lambda-sfn-cdk-ts/README.md @@ -1,7 +1,7 @@ -# DynamoDB Stream to Step Functions Trigger +# Amazon DynamoDB Stream to AWS Step Functions Trigger -A CDK construct to automatically trigger AWS Step Functions workflows in response to changes in DynamoDB tables. The `DynamoWorkflowTrigger` construct bridges DynamoDB streams and Step Functions by allowing you to define event handlers that monitor specific changes in your DynamoDB tables and trigger workflows in response. It leverages Lambda functions to evaluate conditions and start Step Functions state machines with inputs derived from the DynamoDB events. +This Pattern is a CDK construct to automatically trigger AWS Step Functions workflows in response to changes in DynamoDB tables. The `DynamoWorkflowTrigger` construct bridges DynamoDB streams and Step Functions by allowing you to define event handlers that monitor specific changes in your DynamoDB tables and trigger workflows in response. It leverages Lambda functions to evaluate conditions and start Step Functions state machines with inputs derived from the DynamoDB events. Learn more about this pattern at Serverless Land Patterns: https://serverlessland.com/patterns/{} @@ -206,4 +206,4 @@ new DynamoWorkflowTrigger(this, "VpcTrigger", { - Check CloudWatch Logs for the Lambda function - Monitor the dead letter queue for failed events -- Ensure IAM permissions are correct for DynamoDB stream access and Step Functions execution \ No newline at end of file +- Ensure IAM permissions are correct for DynamoDB stream access and Step Functions execution From 43047cf9ae80dea93d44bc1632524d493936c038 Mon Sep 17 00:00:00 2001 From: Saptarshi Banerjee Date: Tue, 27 May 2025 19:13:19 -0700 Subject: [PATCH 4/6] files updated --- ddbstream-lambda-sfn-cdk-ts/README.md | 48 ++++----------------------- 1 file changed, 6 insertions(+), 42 deletions(-) diff --git a/ddbstream-lambda-sfn-cdk-ts/README.md b/ddbstream-lambda-sfn-cdk-ts/README.md index 2b7697e6f..484bdae5c 100644 --- a/ddbstream-lambda-sfn-cdk-ts/README.md +++ b/ddbstream-lambda-sfn-cdk-ts/README.md @@ -23,7 +23,7 @@ Important: this application uses various AWS services and there are costs associ ``` 2. Change directory to the pattern directory: ``` - cd cdk-vpc-lambda-sfn + cd ddbstream-lambda-sfn-cdk-ts ``` 3. To deploy from the command line use the following: ```bash @@ -43,9 +43,7 @@ Important: this application uses various AWS services and there are costs associ ``` 2. Confirm the removal and wait for the resource deletion to complete. ---- -Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. -SPDX-License-Identifier: MIT-0 @@ -122,35 +120,7 @@ new DynamoWorkflowTrigger(this, "MyTrigger", { }); ``` -#### Monitoring multiple event types: -```typescript -new DynamoWorkflowTrigger(this, "MultipleTriggers", { - eventHandlers: [ - { - table: orderTable, - eventNames: [EventName.Insert], - conditions: [{ jsonPath: "$.NewImage.status.S", value: "NEW" }], - stateMachineConfig: { - stateMachine: newOrderWorkflow, - input: { orderId: "$.NewImage.orderId.S" } - } - }, - { - table: orderTable, - eventNames: [EventName.Modify], - conditions: [ - { jsonPath: "$.NewImage.status.S", value: "CANCELED" }, - { jsonPath: "$.OldImage.status.S", value: "IN_PROGRESS" } - ], - stateMachineConfig: { - stateMachine: cancelOrderWorkflow, - input: { orderId: "$.NewImage.orderId.S" } - } - } - ] -}); -``` #### Using event source filters: @@ -173,18 +143,7 @@ new DynamoWorkflowTrigger(this, "FilteredTrigger", { }); ``` -#### Using VPC configuration: -```typescript -new DynamoWorkflowTrigger(this, "VpcTrigger", { - vpc: myVpc, - subnetType: SubnetType.PRIVATE_WITH_EGRESS, - additionalSecurityGroups: [mySecurityGroup], - eventHandlers: [ - /* event handlers */ - ] -}); -``` ## Features @@ -207,3 +166,8 @@ new DynamoWorkflowTrigger(this, "VpcTrigger", { - Check CloudWatch Logs for the Lambda function - Monitor the dead letter queue for failed events - Ensure IAM permissions are correct for DynamoDB stream access and Step Functions execution + + +Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. + +SPDX-License-Identifier: MIT-0 From 0e9808237f1db79cd0c2d8cb2656db1bb58039bd Mon Sep 17 00:00:00 2001 From: Avnish Kumar Date: Tue, 3 Jun 2025 18:39:07 -0700 Subject: [PATCH 5/6] adressed comments to update the readme + added pattern diagram --- ddbstream-lambda-sfn-cdk-ts/README.md | 178 ++++++++---------- .../ddbstream-lambda-sfn-cdk-ts.png | Bin 0 -> 43566 bytes .../lib/ddbstream-lambda-sfn-example-stack.ts | 17 +- 3 files changed, 95 insertions(+), 100 deletions(-) create mode 100644 ddbstream-lambda-sfn-cdk-ts/ddbstream-lambda-sfn-cdk-ts.png diff --git a/ddbstream-lambda-sfn-cdk-ts/README.md b/ddbstream-lambda-sfn-cdk-ts/README.md index 484bdae5c..487653523 100644 --- a/ddbstream-lambda-sfn-cdk-ts/README.md +++ b/ddbstream-lambda-sfn-cdk-ts/README.md @@ -1,7 +1,7 @@ # Amazon DynamoDB Stream to AWS Step Functions Trigger -This Pattern is a CDK construct to automatically trigger AWS Step Functions workflows in response to changes in DynamoDB tables. The `DynamoWorkflowTrigger` construct bridges DynamoDB streams and Step Functions by allowing you to define event handlers that monitor specific changes in your DynamoDB tables and trigger workflows in response. It leverages Lambda functions to evaluate conditions and start Step Functions state machines with inputs derived from the DynamoDB events. +This Pattern demonstrates how to automatically trigger AWS Step Functions workflows in response to changes in DynamoDB tables. `DynamoWorkflowTrigger` lets you connect DynamoDB and Step Functions by allowing you to define event handlers that monitor specific changes in your DynamoDB tables and trigger workflows in response. It leverages Lambda functions to evaluate conditions and start Step Functions state machines with inputs derived from the DynamoDB events. Learn more about this pattern at Serverless Land Patterns: https://serverlessland.com/patterns/{} @@ -25,7 +25,14 @@ Important: this application uses various AWS services and there are costs associ ``` cd ddbstream-lambda-sfn-cdk-ts ``` -3. To deploy from the command line use the following: +3. (Optional) Update the environment settings in `app.ts` if you know exactly what Account and Region you want to deploy the stack to. + ```typescript + env: { + account: 'YOUR_ACCOUNT_NUMBER', // Replace with your AWS account number + region: 'YOUR_REGION' // Replace with your desired region + } + ``` +4. To deploy from the command line use the following: ```bash npm install npx cdk bootstrap aws://accountnumber/region @@ -34,22 +41,11 @@ Important: this application uses various AWS services and there are costs associ npx cdk deploy --all ``` +## How It Works -## Cleanup - -1. From the command line, use the following in the source folder - ```bash - npx cdk destroy - ``` -2. Confirm the removal and wait for the resource deletion to complete. ----- - - - - -## Example Stack Explanation +![Architecture Diagram](ddbstream-lambda-sfn-cdk-ts.png) -The `DdbstreamLambdaSfnExampleStack` demonstrates how to use the `DynamoWorkflowTrigger` construct: +The `DdbstreamLambdaSfnExampleStack` demonstrates how to use the the pattern: 1. It creates a DynamoDB table (`TestTable`) with streaming enabled 2. It creates a simple Step Functions state machine (`TestStateMachine`) @@ -65,109 +61,101 @@ The `DdbstreamLambdaSfnExampleStack` demonstrates how to use the `DynamoWorkflow This workflow allows you to respond to specific data changes in DynamoDB by executing custom workflows with Step Functions. -## Creating Your Own Stacks -### Basic Setup +#### Features -1. Import necessary modules: +- Dead letter queue for failed invocations +- VPC support +- Custom security groups +- Fine-grained event filtering +- Multiple event handlers per construct +- JSONPath-based condition evaluation +- Input mapping for state machines -```typescript -import { DynamoWorkflowTrigger, EventName } from "ddbstream-lambda-sfn"; -import { AttributeType, StreamViewType, Table } from "aws-cdk-lib/aws-dynamodb"; -import { StateMachine } from "aws-cdk-lib/aws-stepfunctions"; -``` +#### Limitations -2. Create a DynamoDB table with streaming enabled: +- Tables must have streams enabled with `NEW_AND_OLD_IMAGES` +- Conditions currently only support exact matches via the `value` property +- For complex filtering, use Lambda event source filters -```typescript -const myTable = new Table(this, "MyTable", { - partitionKey: { - name: "Id", - type: AttributeType.STRING - }, - stream: StreamViewType.NEW_AND_OLD_IMAGES // Required for the trigger to work -}); -``` -3. Create a Step Functions state machine: +Here's a suggested testing section for the README: -```typescript -const myStateMachine = new StateMachine(this, "MyWorkflow", { - definition: /* your state machine definition */ -}); -``` +## Testing -4. Create the workflow trigger: - -```typescript -new DynamoWorkflowTrigger(this, "MyTrigger", { - eventHandlers: [ - { - table: myTable, - eventNames: [EventName.Insert], // Only trigger on inserts - conditions: [ - { jsonPath: "$.NewImage.status.S", value: "PENDING" } - ], - stateMachineConfig: { - stateMachine: myStateMachine, - input: { - id: "$.NewImage.Id.S", - timestamp: "$.NewImage.createdAt.S" - } - } - } - ] -}); -``` +You can test the workflow using the AWS CLI to create and modify items in the DynamoDB table. Here are some example commands to test different scenarios: +1. First, create an item that shouldn't trigger the workflow (initial state): +```bash +aws dynamodb put-item \ + --table-name TestTable \ + --item '{ + "Index": {"S": "test-item-1"}, + "testKey": {"S": "test9"}, + "ListAttribute": {"L": [{"S": "first-element"}]} + }' +``` +2. Update the item to trigger the workflow (meets all conditions): +```bash +aws dynamodb update-item \ + --table-name TestTable \ + --key '{"Index": {"S": "test-item-1"}}' \ + --update-expression "SET testKey = :newval" \ + --expression-attribute-values '{":newval": {"S": "test8"}}' +``` -#### Using event source filters: - -```typescript -new DynamoWorkflowTrigger(this, "FilteredTrigger", { - eventSourceFilters: [ - FilterCriteria.filter({ - dynamodb: { - NewImage: { - status: { - S: FilterRule.isEqual("ACTIVE"), - }, - }, - }, - }), - ], - eventHandlers: [ - /* event handlers */ - ] -}); +3. Test the SkipMe filter by creating an item that should be ignored: +```bash +aws dynamodb put-item \ + --table-name TestTable \ + --item '{ + "Index": {"S": "test-item-2"}, + "testKey": {"S": "test9"}, + "SkipMe": {"S": "true"} + }' ``` +This should not trigger DDBEventTrigger lambda at all. +To verify the results: +1. Check if the Step Function was triggered: +```bash +aws stepfunctions list-executions \ + --state-machine-arn +``` -## Features +2. View the execution details: +```bash +aws stepfunctions get-execution-history \ + --execution-arn +``` -- Dead letter queue for failed invocations -- VPC support -- Custom security groups -- Fine-grained event filtering -- Multiple event handlers per construct -- JSONPath-based condition evaluation -- Input mapping for state machines +3. Monitor Lambda function logs: +```bash +aws logs tail /aws/lambda/ --follow +``` -## Limitations +Note: Replace `TestTable` with your actual table name if different. You may need to adjust the region using `--region` if not using your default region. -- Tables must have streams enabled with `NEW_AND_OLD_IMAGES` -- Conditions currently only support exact matches via the `value` property -- For complex filtering, use Lambda event source filters -## Troubleshooting +#### Troubleshooting - Check CloudWatch Logs for the Lambda function - Monitor the dead letter queue for failed events - Ensure IAM permissions are correct for DynamoDB stream access and Step Functions execution +## Cleanup + +1. From the command line, use the following in the source folder + ```bash + npx cdk destroy + ``` +2. Confirm the removal and wait for the resource deletion to complete. +---- + + -Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. SPDX-License-Identifier: MIT-0 diff --git a/ddbstream-lambda-sfn-cdk-ts/ddbstream-lambda-sfn-cdk-ts.png b/ddbstream-lambda-sfn-cdk-ts/ddbstream-lambda-sfn-cdk-ts.png new file mode 100644 index 0000000000000000000000000000000000000000..a584ee1ea3dbb31220692808087f73c4c9113ed7 GIT binary patch literal 43566 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 literal 0 HcmV?d00001 diff --git a/ddbstream-lambda-sfn-cdk-ts/src/lib/ddbstream-lambda-sfn-example-stack.ts b/ddbstream-lambda-sfn-cdk-ts/src/lib/ddbstream-lambda-sfn-example-stack.ts index e2b63e279..64a04730b 100644 --- a/ddbstream-lambda-sfn-cdk-ts/src/lib/ddbstream-lambda-sfn-example-stack.ts +++ b/ddbstream-lambda-sfn-cdk-ts/src/lib/ddbstream-lambda-sfn-example-stack.ts @@ -10,21 +10,23 @@ export class DdbstreamLambdaSfnExampleStack extends cdk.Stack { constructor(scope: Construct, id: string, props?: cdk.StackProps) { super(scope, id, props); - // Create a test table + // Create a DynamoDB table with streaming enabled and 'Index' as partition key. + // You can also import your own table. But it should have DDB streaming enabled. const testTable = new Table(this, "TestTable", { partitionKey: { name: "Index", type: AttributeType.STRING }, - stream: StreamViewType.NEW_AND_OLD_IMAGES + stream: StreamViewType.NEW_AND_OLD_IMAGES // Required for the trigger to work }) - // Create a test state machine + // Create a simple Step Functions state machine with a single Pass state. + // You can import any other step function here as well. const testStateMachine = new StateMachine(this, "TestStateMachine", { definition: new Pass(this, "TestPassState") }) - // Create a trigger on insert + // Create a DynamoDB stream trigger with event filtering and conditional execution const exampleTrigger = new DynamoWorkflowTrigger(this, "TestTrigger", { eventSourceFilters: [ FilterCriteria.filter({ @@ -41,13 +43,18 @@ export class DdbstreamLambdaSfnExampleStack extends cdk.Stack { eventHandlers: [ { table: testTable, + // Only trigger on MODIFY events eventNames: [EventName.Modify], + // Only execute when: + // 1. NewImage.testKey = "test8" + // 2. OldImage.testKey = "test9" conditions: [{ jsonPath: "$.NewImage.testKey.S", value: "test8"}, { jsonPath: "$.OldImage.testKey.S", value: "test9"}], // Ensure this is always an array + // Configure Step Functions execution with dynamic input mapping stateMachineConfig: { stateMachine: testStateMachine, input: { Index: "$.NewImage.Index.S", - MapAttribute: "$.newImage.ListAttribute.l[0]" + MapAttribute: "$.NewImage.ListAttribute.L[0]" } } } From 3deaa1083def957079523176e475437f1d450f9e Mon Sep 17 00:00:00 2001 From: Avnish Kumar Date: Sat, 7 Jun 2025 15:46:50 -0700 Subject: [PATCH 6/6] add cdk outputs + address README comments --- ddbstream-lambda-sfn-cdk-ts/.gitignore | 8 --- ddbstream-lambda-sfn-cdk-ts/.npmignore | 6 -- ddbstream-lambda-sfn-cdk-ts/README.md | 43 +++++------ .../example-pattern.json | 72 +++++++++++++++++++ ddbstream-lambda-sfn-cdk-ts/jest.config.js | 8 --- .../lib/ddbstream-lambda-sfn-example-stack.ts | 12 ++++ .../src/lib/ddbstream-lambda-sfn.ts | 1 - 7 files changed, 101 insertions(+), 49 deletions(-) delete mode 100644 ddbstream-lambda-sfn-cdk-ts/.gitignore delete mode 100644 ddbstream-lambda-sfn-cdk-ts/.npmignore create mode 100644 ddbstream-lambda-sfn-cdk-ts/example-pattern.json delete mode 100644 ddbstream-lambda-sfn-cdk-ts/jest.config.js diff --git a/ddbstream-lambda-sfn-cdk-ts/.gitignore b/ddbstream-lambda-sfn-cdk-ts/.gitignore deleted file mode 100644 index f60797b6a..000000000 --- a/ddbstream-lambda-sfn-cdk-ts/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -*.js -!jest.config.js -*.d.ts -node_modules - -# CDK asset staging directory -.cdk.staging -cdk.out diff --git a/ddbstream-lambda-sfn-cdk-ts/.npmignore b/ddbstream-lambda-sfn-cdk-ts/.npmignore deleted file mode 100644 index c1d6d45dc..000000000 --- a/ddbstream-lambda-sfn-cdk-ts/.npmignore +++ /dev/null @@ -1,6 +0,0 @@ -*.ts -!*.d.ts - -# CDK asset staging directory -.cdk.staging -cdk.out diff --git a/ddbstream-lambda-sfn-cdk-ts/README.md b/ddbstream-lambda-sfn-cdk-ts/README.md index 487653523..73c9ef58e 100644 --- a/ddbstream-lambda-sfn-cdk-ts/README.md +++ b/ddbstream-lambda-sfn-cdk-ts/README.md @@ -1,9 +1,9 @@ # Amazon DynamoDB Stream to AWS Step Functions Trigger -This Pattern demonstrates how to automatically trigger AWS Step Functions workflows in response to changes in DynamoDB tables. `DynamoWorkflowTrigger` lets you connect DynamoDB and Step Functions by allowing you to define event handlers that monitor specific changes in your DynamoDB tables and trigger workflows in response. It leverages Lambda functions to evaluate conditions and start Step Functions state machines with inputs derived from the DynamoDB events. +This Pattern demonstrates how to automatically trigger AWS Step Functions workflows in response to changes in DynamoDB tables. The CDK construct `DynamoWorkflowTrigger` lets you connect DynamoDB and Step Functions by allowing you to define event handlers that monitor specific changes in your DynamoDB tables and trigger workflows in response. It leverages Lambda functions to evaluate conditions and start Step Functions state machines with inputs derived from the DynamoDB events. -Learn more about this pattern at Serverless Land Patterns: https://serverlessland.com/patterns/{} +Learn more about this pattern at Serverless Land Patterns: [https://serverlessland.com/patterns/ddbstream-lambda-sfn-cdk-ts](https://serverlessland.com/patterns/ddbstream-lambda-sfn-cdk-ts) 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. @@ -25,21 +25,17 @@ Important: this application uses various AWS services and there are costs associ ``` cd ddbstream-lambda-sfn-cdk-ts ``` -3. (Optional) Update the environment settings in `app.ts` if you know exactly what Account and Region you want to deploy the stack to. - ```typescript - env: { - account: 'YOUR_ACCOUNT_NUMBER', // Replace with your AWS account number - region: 'YOUR_REGION' // Replace with your desired region - } - ``` -4. To deploy from the command line use the following: +3. To deploy from the command line use the following: ```bash npm install - npx cdk bootstrap aws://accountnumber/region npm run lambda - npx cdk synth - npx cdk deploy --all + cdk deploy ``` + **The deployment will take a couple of minutes** + +4. Note the outputs from the CDK deployment process. These contain the ``, `` and `` which are used for testing. + + ## How It Works @@ -78,9 +74,6 @@ This workflow allows you to respond to specific data changes in DynamoDB by exec - Conditions currently only support exact matches via the `value` property - For complex filtering, use Lambda event source filters - -Here's a suggested testing section for the README: - ## Testing You can test the workflow using the AWS CLI to create and modify items in the DynamoDB table. Here are some example commands to test different scenarios: @@ -88,7 +81,7 @@ You can test the workflow using the AWS CLI to create and modify items in the Dy 1. First, create an item that shouldn't trigger the workflow (initial state): ```bash aws dynamodb put-item \ - --table-name TestTable \ + --table-name \ --item '{ "Index": {"S": "test-item-1"}, "testKey": {"S": "test9"}, @@ -99,7 +92,7 @@ aws dynamodb put-item \ 2. Update the item to trigger the workflow (meets all conditions): ```bash aws dynamodb update-item \ - --table-name TestTable \ + --table-name \ --key '{"Index": {"S": "test-item-1"}}' \ --update-expression "SET testKey = :newval" \ --expression-attribute-values '{":newval": {"S": "test8"}}' @@ -108,7 +101,7 @@ aws dynamodb update-item \ 3. Test the SkipMe filter by creating an item that should be ignored: ```bash aws dynamodb put-item \ - --table-name TestTable \ + --table-name \ --item '{ "Index": {"S": "test-item-2"}, "testKey": {"S": "test9"}, @@ -122,7 +115,7 @@ To verify the results: 1. Check if the Step Function was triggered: ```bash aws stepfunctions list-executions \ - --state-machine-arn + --state-machine-arn ``` 2. View the execution details: @@ -133,23 +126,21 @@ aws stepfunctions get-execution-history \ 3. Monitor Lambda function logs: ```bash -aws logs tail /aws/lambda/ --follow +aws logs tail /aws/lambda/ --follow ``` -Note: Replace `TestTable` with your actual table name if different. You may need to adjust the region using `--region` if not using your default region. - #### Troubleshooting -- Check CloudWatch Logs for the Lambda function +- Check Amazon CloudWatch Logs for the Lambda function - Monitor the dead letter queue for failed events -- Ensure IAM permissions are correct for DynamoDB stream access and Step Functions execution +- Ensure Amazon IAM permissions are correct for DynamoDB stream access and Step Functions execution ## Cleanup 1. From the command line, use the following in the source folder ```bash - npx cdk destroy + cdk destroy ``` 2. Confirm the removal and wait for the resource deletion to complete. ---- diff --git a/ddbstream-lambda-sfn-cdk-ts/example-pattern.json b/ddbstream-lambda-sfn-cdk-ts/example-pattern.json new file mode 100644 index 000000000..ef9f2068d --- /dev/null +++ b/ddbstream-lambda-sfn-cdk-ts/example-pattern.json @@ -0,0 +1,72 @@ +{ + "title": "Amazon DynamoDB Stream to AWS Step Functions Trigger", + "description": "Automatically trigger AWS Step Functions workflows in response to changes in DynamoDB tables using a CDK construct that connects DynamoDB and Step Functions.", + "language": "TypeScript", + "level": "300", + "framework": "AWS CDK", + "introBox": { + "headline": "How it works", + "text": [ + "This pattern demonstrates how to automatically trigger AWS Step Functions workflows in response to changes in DynamoDB tables.", + "The CDK construct 'DynamoWorkflowTrigger' connects DynamoDB and Step Functions by allowing you to define event handlers that monitor specific changes in your DynamoDB tables.", + "It leverages Lambda functions to evaluate conditions and start Step Functions state machines with inputs derived from the DynamoDB events.", + "The pattern includes features like dead letter queues, VPC support, custom security groups, and fine-grained event filtering." + ] + }, + "gitHub": { + "template": { + "repoURL": "https://github.com/aws-samples/serverless-patterns/tree/main/ddbstream-lambda-sfn-cdk-ts", + "templateURL": "serverless-patterns/ddbstream-lambda-sfn-cdk-ts", + "projectFolder": "ddbstream-lambda-sfn-cdk-ts", + "templateFile": "lib/ddbstream-lambda-sfn-example-stack.ts" + } + }, + "resources": { + "bullets": [ + { + "text": "AWS DynamoDB Streams Documentation", + "link": "https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Streams.html" + }, + { + "text": "AWS Step Functions Documentation", + "link": "https://docs.aws.amazon.com/step-functions/latest/dg/welcome.html" + }, + { + "text": "AWS CDK Documentation", + "link": "https://docs.aws.amazon.com/cdk/latest/guide/home.html" + } + ] + }, + "deploy": { + "text": [ + "Clone the repository: git clone https://github.com/aws-samples/serverless-patterns", + "Change directory: cd ddbstream-lambda-sfn-cdk-ts", + "Install dependencies: npm install && npm run lambda", + "Deploy the CDK stack: cdk deploy" + ] + }, + "testing": { + "text": [ + "1. Create an initial item in DynamoDB: aws dynamodb put-item --table-name --item '{ \"Index\": {\"S\": \"test-item-1\"}, \"testKey\": {\"S\": \"test9\"}, \"ListAttribute\": {\"L\": [{\"S\": \"first-element\"}]} }'", + "2. Update the item to trigger workflow: aws dynamodb update-item --table-name --key '{\"Index\": {\"S\": \"test-item-1\"}}' --update-expression \"SET testKey = :newval\" --expression-attribute-values '{\":newval\": {\"S\": \"test8\"}}'", + "3. Check Step Functions execution: aws stepfunctions list-executions --state-machine-arn ", + "4. Monitor Lambda logs: aws logs tail /aws/lambda/ --follow" + ] + }, + "cleanup": { + "text": ["Delete the stack: cdk destroy"] + }, + "authors": [ + { + "name": "Avnish Kumar", + "image": "https://i.postimg.cc/W1SVxLxR/avnish-profile.jpg", + "bio": "Senior Software Engineer, Amazon Web Services", + "linkedin": "avnish-kumar-40a54328" + }, + { + "name": "Saptarshi Banerjee", + "bio": "Senior Solutions Architect, Amazon Web Services", + "linkedin": "saptarshi-banerjee-83472679" + } + ] +} \ No newline at end of file diff --git a/ddbstream-lambda-sfn-cdk-ts/jest.config.js b/ddbstream-lambda-sfn-cdk-ts/jest.config.js deleted file mode 100644 index 08263b895..000000000 --- a/ddbstream-lambda-sfn-cdk-ts/jest.config.js +++ /dev/null @@ -1,8 +0,0 @@ -module.exports = { - testEnvironment: 'node', - roots: ['/test'], - testMatch: ['**/*.test.ts'], - transform: { - '^.+\\.tsx?$': 'ts-jest' - } -}; diff --git a/ddbstream-lambda-sfn-cdk-ts/src/lib/ddbstream-lambda-sfn-example-stack.ts b/ddbstream-lambda-sfn-cdk-ts/src/lib/ddbstream-lambda-sfn-example-stack.ts index 64a04730b..e76cc65ad 100644 --- a/ddbstream-lambda-sfn-cdk-ts/src/lib/ddbstream-lambda-sfn-example-stack.ts +++ b/ddbstream-lambda-sfn-cdk-ts/src/lib/ddbstream-lambda-sfn-example-stack.ts @@ -60,5 +60,17 @@ export class DdbstreamLambdaSfnExampleStack extends cdk.Stack { } ] }) + + new cdk.CfnOutput(this, "DynamoDBTable", { + value: testTable.tableName, + }); + + new cdk.CfnOutput(this, "StateMachineArn", { + value: testStateMachine.stateMachineArn, + }); + + new cdk.CfnOutput(this, "LambdaName", { + value: exampleTrigger.lambda.functionName, + }); } } \ No newline at end of file diff --git a/ddbstream-lambda-sfn-cdk-ts/src/lib/ddbstream-lambda-sfn.ts b/ddbstream-lambda-sfn-cdk-ts/src/lib/ddbstream-lambda-sfn.ts index 7c65dcae2..cc84bbcdc 100644 --- a/ddbstream-lambda-sfn-cdk-ts/src/lib/ddbstream-lambda-sfn.ts +++ b/ddbstream-lambda-sfn-cdk-ts/src/lib/ddbstream-lambda-sfn.ts @@ -242,7 +242,6 @@ export class DynamoWorkflowTrigger extends Construct { code: Code.fromAsset(path.join(__dirname, '../lambda')), handler: "index.handler", runtime: Runtime.NODEJS_20_X, - memorySize: 2048, timeout: Duration.seconds(20), environment: { EVENT_HANDLER_CONFIG: JSON.stringify({