From ece198d9575cf6e77209e1e5e6b9241ef93dc96a Mon Sep 17 00:00:00 2001 From: Connor Kirkpatrick Date: Wed, 21 Jan 2026 16:52:03 +0000 Subject: [PATCH] feat(idempotency): add E2E tests for durable functions Add E2E tests to verify idempotency behavior with AWS Lambda durable functions. Changes: - Add TestNodejsDurableFunction to testing-utils for creating Lambda functions with durableConfig - Add IdempotencyTestDurableFunctionAndDynamoTable construct - Add E2E tests verifying: - Durable execution with wait step executes successfully - Duplicate payload returns same result (idempotent) - Concurrent executions handled correctly - Add @aws/durable-execution-sdk-js as dev dependency Closes #4839 --- package-lock.json | 36 +++-- packages/idempotency/package.json | 1 + .../e2e/durableFunctions.test.FunctionCode.ts | 33 ++++ .../tests/e2e/durableFunctions.test.ts | 153 ++++++++++++++++++ .../idempotency/tests/helpers/resources.ts | 49 +++++- .../src/resources/TestNodejsFunction.ts | 86 +++++++++- 6 files changed, 343 insertions(+), 15 deletions(-) create mode 100644 packages/idempotency/tests/e2e/durableFunctions.test.FunctionCode.ts create mode 100644 packages/idempotency/tests/e2e/durableFunctions.test.ts diff --git a/package-lock.json b/package-lock.json index 200d95252e..ae9a61c9ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2906,6 +2906,19 @@ "node": ">=18.0.0" } }, + "node_modules/@aws/durable-execution-sdk-js": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@aws/durable-execution-sdk-js/-/durable-execution-sdk-js-1.0.1.tgz", + "integrity": "sha512-kySi/m8dLm+LP00cqob85GSnCbSpNXURIw70y00IDc3me/zgX67MaAPz4DF+XW80AItudJEl1wVjd4kZ0gv8Yw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/client-lambda": "^3.943.0" + }, + "engines": { + "node": ">=22" + } + }, "node_modules/@aws/lambda-invoke-store": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.3.tgz", @@ -3704,31 +3717,31 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", - "devOptional": true + "dev": true }, "node_modules/@protobufjs/base64": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", - "devOptional": true + "dev": true }, "node_modules/@protobufjs/codegen": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", - "devOptional": true + "dev": true }, "node_modules/@protobufjs/eventemitter": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", - "devOptional": true + "dev": true }, "node_modules/@protobufjs/fetch": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", - "devOptional": true, + "dev": true, "dependencies": { "@protobufjs/aspromise": "^1.1.1", "@protobufjs/inquire": "^1.1.0" @@ -3738,31 +3751,31 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", - "devOptional": true + "dev": true }, "node_modules/@protobufjs/inquire": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", - "devOptional": true + "dev": true }, "node_modules/@protobufjs/path": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", - "devOptional": true + "dev": true }, "node_modules/@protobufjs/pool": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", - "devOptional": true + "dev": true }, "node_modules/@protobufjs/utf8": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", - "devOptional": true + "dev": true }, "node_modules/@redis/client": { "version": "5.10.0", @@ -7603,7 +7616,7 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", - "devOptional": true + "dev": true }, "node_modules/lru-cache": { "version": "11.2.4", @@ -10160,6 +10173,7 @@ "@aws-lambda-powertools/testing-utils": "file:../testing", "@aws-sdk/client-dynamodb": "^3.966.0", "@aws-sdk/lib-dynamodb": "^3.966.0", + "@aws/durable-execution-sdk-js": "^1.0.0", "aws-sdk-client-mock": "^4.1.0" }, "peerDependencies": { diff --git a/packages/idempotency/package.json b/packages/idempotency/package.json index 51719a1d6c..ce355630d0 100644 --- a/packages/idempotency/package.json +++ b/packages/idempotency/package.json @@ -152,6 +152,7 @@ ], "devDependencies": { "@aws-lambda-powertools/testing-utils": "file:../testing", + "@aws/durable-execution-sdk-js": "^1.0.0", "@aws-sdk/client-dynamodb": "^3.966.0", "@aws-sdk/lib-dynamodb": "^3.966.0", "aws-sdk-client-mock": "^4.1.0" diff --git a/packages/idempotency/tests/e2e/durableFunctions.test.FunctionCode.ts b/packages/idempotency/tests/e2e/durableFunctions.test.FunctionCode.ts new file mode 100644 index 0000000000..917dc95776 --- /dev/null +++ b/packages/idempotency/tests/e2e/durableFunctions.test.FunctionCode.ts @@ -0,0 +1,33 @@ +import { + type DurableContext, + withDurableExecution, +} from '@aws/durable-execution-sdk-js'; +import { Logger } from '@aws-lambda-powertools/logger'; +import { makeIdempotent } from '../../src/makeIdempotent.js'; +import { DynamoDBPersistenceLayer } from '../../src/persistence/DynamoDBPersistenceLayer.js'; + +const dynamoDBPersistenceLayer = new DynamoDBPersistenceLayer({ + tableName: process.env.IDEMPOTENCY_TABLE_NAME || 'table_name', +}); + +const logger = new Logger(); + +/** + * Durable function with wait step for testing idempotency. + */ +export const handler = withDurableExecution( + makeIdempotent( + async (event: { foo: string }, context: DurableContext) => { + logger.info('Processing event', { foo: event.foo }); + + await context.wait({ seconds: 1 }); + + logger.info('After wait'); + + return `processed: ${event.foo}`; + }, + { + persistenceStore: dynamoDBPersistenceLayer, + } + ) +); diff --git a/packages/idempotency/tests/e2e/durableFunctions.test.ts b/packages/idempotency/tests/e2e/durableFunctions.test.ts new file mode 100644 index 0000000000..d192bda86e --- /dev/null +++ b/packages/idempotency/tests/e2e/durableFunctions.test.ts @@ -0,0 +1,153 @@ +import { createHash } from 'node:crypto'; +import { join } from 'node:path'; +import { setTimeout } from 'node:timers/promises'; +import { TestStack } from '@aws-lambda-powertools/testing-utils'; +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { InvokeCommand, LambdaClient } from '@aws-sdk/client-lambda'; +import { ScanCommand } from '@aws-sdk/lib-dynamodb'; +import { Duration } from 'aws-cdk-lib'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { IdempotencyTestDurableFunctionAndDynamoTable } from '../helpers/resources.js'; +import { RESOURCE_NAME_PREFIX } from './constants.js'; + +const invokeDurableFunction = async ( + lambda: LambdaClient, + functionName: string, + payload: object +): Promise<{ result: string | undefined }> => { + const response = await lambda.send( + new InvokeCommand({ + FunctionName: functionName, + Payload: JSON.stringify(payload), + }) + ); + + const result = response.Payload + ? JSON.parse(new TextDecoder().decode(response.Payload)) + : undefined; + + return { result }; +}; + +describe('Idempotency E2E tests, durable functions', () => { + const testStack = new TestStack({ + stackNameProps: { + stackNamePrefix: RESOURCE_NAME_PREFIX, + testName: 'durableFn', + }, + }); + + const lambdaFunctionCodeFilePath = join( + __dirname, + 'durableFunctions.test.FunctionCode.ts' + ); + + let functionName: string; + let tableName: string; + + new IdempotencyTestDurableFunctionAndDynamoTable( + testStack, + { + function: { + entry: lambdaFunctionCodeFilePath, + handler: 'handler', + timeout: Duration.seconds(30), + }, + }, + { nameSuffix: 'fn' } + ); + + const ddb = new DynamoDBClient({}); + const lambda = new LambdaClient({}); + + beforeAll(async () => { + await testStack.deploy(); + functionName = testStack.findAndGetStackOutputValue('fnFn'); + tableName = testStack.findAndGetStackOutputValue('fnTable'); + }); + + it('executes a durable function with wait step successfully', async () => { + const payload = { foo: 'bar' }; + const payloadHash = createHash('md5') + .update(JSON.stringify(payload)) + .digest('base64'); + + const { result } = await invokeDurableFunction( + lambda, + functionName, + payload + ); + + const idempotencyRecords = await ddb.send( + new ScanCommand({ TableName: tableName }) + ); + + const baseFunctionName = functionName.split(':')[0]; + + expect(result).toEqual('processed: bar'); + expect(idempotencyRecords.Items?.length).toEqual(1); + expect(idempotencyRecords.Items?.[0].id).toEqual( + `${baseFunctionName}#${payloadHash}` + ); + expect(idempotencyRecords.Items?.[0].status).toEqual('COMPLETED'); + }); + + it('returns the same result for duplicate payload', async () => { + const payload = { foo: 'baz' }; + const payloadHash = createHash('md5') + .update(JSON.stringify(payload)) + .digest('base64'); + + const result1 = await invokeDurableFunction(lambda, functionName, payload); + const result2 = await invokeDurableFunction(lambda, functionName, payload); + + const idempotencyRecords = await ddb.send( + new ScanCommand({ TableName: tableName }) + ); + + const baseFunctionName = functionName.split(':')[0]; + + expect(result1.result).toEqual('processed: baz'); + expect(result2.result).toEqual('processed: baz'); + + const record = idempotencyRecords.Items?.find((item) => + item.id.includes(payloadHash) + ); + expect(record?.id).toEqual(`${baseFunctionName}#${payloadHash}`); + expect(record?.status).toEqual('COMPLETED'); + }); + + it('throws IdempotencyAlreadyInProgressError for concurrent executions', async () => { + const payload = { foo: 'concurrent' }; + const payloadHash = createHash('md5') + .update(JSON.stringify(payload)) + .digest('base64'); + + const results = await Promise.allSettled([ + invokeDurableFunction(lambda, functionName, payload), + invokeDurableFunction(lambda, functionName, payload), + ]); + + await setTimeout(2000); + + const idempotencyRecords = await ddb.send( + new ScanCommand({ TableName: tableName }) + ); + + const baseFunctionName = functionName.split(':')[0]; + const fulfilled = results.filter((r) => r.status === 'fulfilled'); + + expect(fulfilled.length).toBeGreaterThanOrEqual(1); + + const record = idempotencyRecords.Items?.find((item) => + item.id.includes(payloadHash) + ); + expect(record?.id).toEqual(`${baseFunctionName}#${payloadHash}`); + }); + + afterAll(async () => { + if (!process.env.DISABLE_TEARDOWN) { + await testStack.destroy(); + } + }); +}); diff --git a/packages/idempotency/tests/helpers/resources.ts b/packages/idempotency/tests/helpers/resources.ts index fe7507f31c..a6617e6b55 100644 --- a/packages/idempotency/tests/helpers/resources.ts +++ b/packages/idempotency/tests/helpers/resources.ts @@ -4,7 +4,10 @@ import { type TestStack, } from '@aws-lambda-powertools/testing-utils'; import { TestDynamodbTable } from '@aws-lambda-powertools/testing-utils/resources/dynamodb'; -import { TestNodejsFunction } from '@aws-lambda-powertools/testing-utils/resources/lambda'; +import { + TestNodejsDurableFunction, + TestNodejsFunction, +} from '@aws-lambda-powertools/testing-utils/resources/lambda'; import type { ExtraTestProps, TestDynamodbTableProps, @@ -51,4 +54,46 @@ class IdempotencyTestNodejsFunctionAndDynamoTable extends Construct { } } -export { IdempotencyTestNodejsFunctionAndDynamoTable }; +class IdempotencyTestDurableFunctionAndDynamoTable extends Construct { + public constructor( + testStack: TestStack, + props: { + function: TestNodejsFunctionProps; + table?: TestDynamodbTableProps; + }, + extraProps: ExtraTestProps + ) { + super( + testStack.stack, + concatenateResourceName({ + testName: testStack.testName, + resourceName: randomUUID(), + }) + ); + + const table = new TestDynamodbTable(testStack, props.table || {}, { + nameSuffix: `${extraProps.nameSuffix}Table`, + }); + + const fn = new TestNodejsDurableFunction( + testStack, + { + ...props.function, + environment: { + IDEMPOTENCY_TABLE_NAME: table.tableName, + POWERTOOLS_LOGGER_LOG_EVENT: 'true', + }, + }, + { + nameSuffix: `${extraProps.nameSuffix}Fn`, + } + ); + + table.grantReadWriteData(fn); + } +} + +export { + IdempotencyTestNodejsFunctionAndDynamoTable, + IdempotencyTestDurableFunctionAndDynamoTable, +}; diff --git a/packages/testing/src/resources/TestNodejsFunction.ts b/packages/testing/src/resources/TestNodejsFunction.ts index aae1629e45..7234306e92 100644 --- a/packages/testing/src/resources/TestNodejsFunction.ts +++ b/packages/testing/src/resources/TestNodejsFunction.ts @@ -1,5 +1,6 @@ import { randomUUID } from 'node:crypto'; -import { CfnOutput, Duration } from 'aws-cdk-lib'; +import { Arn, ArnFormat, CfnOutput, Duration, Stack } from 'aws-cdk-lib'; +import { PolicyStatement } from 'aws-cdk-lib/aws-iam'; import { Alias, Tracing } from 'aws-cdk-lib/aws-lambda'; import { NodejsFunction, OutputFormat } from 'aws-cdk-lib/aws-lambda-nodejs'; import { LogGroup, RetentionDays } from 'aws-cdk-lib/aws-logs'; @@ -73,4 +74,85 @@ class TestNodejsFunction extends NodejsFunction { } } -export { TestNodejsFunction }; +/** + * A NodejsFunction with durable execution enabled for testing durable functions. + * + * Durable functions require: + * - durableConfig with executionTimeout + * - IAM permissions for checkpointing + * - A published version or alias (qualified ARN) + */ +class TestNodejsDurableFunction extends NodejsFunction { + public constructor( + stack: TestStack, + props: TestNodejsFunctionProps, + extraProps: ExtraTestProps + ) { + const { bundling, ...restProps } = props; + const functionName = concatenateResourceName({ + testName: stack.testName, + resourceName: extraProps.nameSuffix, + }); + const resourceId = randomUUID().substring(0, 5); + + const logGroup = new LogGroup(stack.stack, `log-${resourceId}`, { + logGroupName: `/aws/lambda/${functionName}`, + retention: RetentionDays.ONE_DAY, + }); + + super(stack.stack, `fn-${resourceId}`, { + timeout: Duration.seconds(30), + memorySize: 512, + tracing: Tracing.ACTIVE, + bundling: { + ...bundling, + minify: true, + mainFields: ['module', 'main'], + sourceMap: false, + format: OutputFormat.ESM, + banner: `import { createRequire } from 'module';const require = createRequire(import.meta.url);`, + }, + ...restProps, + functionName, + runtime: TEST_RUNTIMES[getRuntimeKey()], + architecture: TEST_ARCHITECTURES[getArchitectureKey()], + logGroup, + durableConfig: { + executionTimeout: Duration.minutes(5), + retentionPeriod: Duration.days(1), + }, + }); + + // Durable functions need checkpoint permissions - use constructed ARN to avoid circular dependency + const functionArn = Arn.format( + { + service: 'lambda', + resource: 'function', + resourceName: functionName, + arnFormat: ArnFormat.COLON_RESOURCE_NAME, + }, + Stack.of(stack.stack) + ); + this.addToRolePolicy( + new PolicyStatement({ + actions: [ + 'lambda:CheckpointDurableExecutions', + 'lambda:GetDurableExecutionState', + ], + resources: [functionArn, `${functionArn}:*`], + }) + ); + + // Durable functions require a qualified ARN (version or alias) + const alias = new Alias(this, 'live', { + aliasName: 'live', + version: this.currentVersion, + }); + + new CfnOutput(this, extraProps.nameSuffix, { + value: alias.functionName, + }); + } +} + +export { TestNodejsFunction, TestNodejsDurableFunction };