diff --git a/docs/cli-reference/invoke.md b/docs/cli-reference/invoke.md index a555f3ea58..c7974b2623 100644 --- a/docs/cli-reference/invoke.md +++ b/docs/cli-reference/invoke.md @@ -21,6 +21,7 @@ serverless invoke [local] --function functionName - `--context` String data to be passed as a context to your function. Same like with `--data`, context included in `--contextPath` will overwrite the context you passed with `--context` flag. - `--type` or `-t` The type of invocation. Either `RequestResponse`, `Event` or `DryRun`. Default is `RequestResponse`. - `--log` or `-l` If set to `true` and invocation type is `RequestResponse`, it will output logging data of the invocation. Default is `false`. +- `--durable-execution-name` Unique name for the durable function execution (enables idempotency). Execution names must be 1-64 characters: alphanumeric, hyphens, or underscores. ## Provided lifecycle events @@ -124,6 +125,16 @@ serverless invoke local --function functionName \ This example will pass the json context in the `lib/context.json` file (relative to the root of the service) while invoking the specified/deployed function. +### Function invocation with durable execution name + +```bash +serverless invoke --function functionName --contextPath lib/context.json --durable-execution-name order-12345 +``` + +This example invokes a durable function with a unique execution name. If you invoke the same function with the same execution name again, Lambda returns the cached result from the previous execution instead of re-executing the function. This enables idempotent invocations for reliable workflow orchestration. + +**Note:** Durable execution names must be 1-64 characters and contain only alphanumeric characters, hyphens, or underscores. See the [Functions guide](../guides/functions.md#aws-lambda-durable-functions) for more information on configuring durable functions. + ### Limitations Currently, `invoke local` only supports the Node.js, Python, Java and Ruby runtimes. diff --git a/docs/guides/functions.md b/docs/guides/functions.md index e02a3923f4..b3eda79770 100644 --- a/docs/guides/functions.md +++ b/docs/guides/functions.md @@ -448,6 +448,65 @@ functions: **Note:** Lambda SnapStart only supports the Java 11, Java 17 and Java 21 runtimes and does not support provisioned concurrency, the arm64 architecture, the Lambda Extensions API, Amazon Elastic File System (Amazon EFS), AWS X-Ray, or ephemeral storage greater than 512 MB. +## AWS Lambda Durable Functions + +[AWS Lambda Durable Functions](https://docs.aws.amazon.com/lambda/latest/dg/) enable long-running, fault-tolerant workflows without requiring custom state management. Durable functions use checkpoint-and-replay mechanisms to reliably execute workflows that can run for up to 1 year. + +Durable functions are ideal for: +- Multi-step order processing workflows +- Long-running data processing jobs +- Reliable task orchestration +- Workflows requiring idempotent execution guarantees + +### Configuration + +To enable durable function configuration for your Lambda function, add the `durableConfig` object property in the function configuration: + +```yaml +functions: + orderProcessor: + handler: handler.processOrder + runtime: nodejs22.x + durableConfig: + executionTimeout: 3600 # Required: 1-31536000 seconds (1 sec to 1 year) + retentionPeriodInDays: 30 # Optional: 1-90 days +``` + +### Configuration Properties + +- **`executionTimeout`** (required, integer): Maximum execution time for the durable function in seconds. Range: 1 to 31,536,000 (1 year). +- **`retentionPeriodInDays`** (optional, integer): Number of days to retain execution history and state. Range: 1 to 90 days. This determines how long AWS maintains execution metadata after completion. + +### Invoking Durable Functions + +When invoking a durable function, you can provide a unique execution name to enable idempotent invocations: + +```bash +serverless invoke --function orderProcessor --durable-execution-name order-12345 +``` + +If you invoke the same function with the same execution name again, Lambda returns the cached result instead of re-executing. Execution names must be 1-64 characters long and contain only alphanumeric characters, hyphens, or underscores. + +See the [invoke command documentation](../cli-reference/invoke.md) for more details on the `--durable-execution-name` option. + +### IAM Permissions + +When you configure a function with `durableConfig`, the Serverless Framework automatically adds the required IAM managed policy (`AWSLambdaBasicDurableExecutionRolePolicy`) to the Lambda execution role. + +**Note:** If you use a custom IAM role for your function (via `functions[].role` or `provider.iam.role`), you must manually add the `AWSLambdaBasicDurableExecutionRolePolicy` to your custom role. The framework cannot automatically modify custom roles. + +### Supported Runtimes and Limitations + +**Supported Runtimes:** +- Node.js: `nodejs22.x`, `nodejs24.x` +- Python: `python3.13`, `python3.14` +- Container images with compatible runtimes + +**Notes:** +- Durable functions require versioning, which is automatically enabled when `durableConfig` is present +- Functions must be invoked with a specific version or alias for durability guarantees +- Review the [AWS Lambda Durable Functions documentation](https://docs.aws.amazon.com/lambda/latest/dg/) for complete compatibility information and limitations + ## VPC Configuration You can add VPC configuration to a specific function in `serverless.yml` by adding a `vpc` object property in the function configuration. This object should contain the `securityGroupIds` and `subnetIds` array properties needed to construct VPC for this function. Here's an example configuration: diff --git a/docs/guides/serverless.yml.md b/docs/guides/serverless.yml.md index a4b9f01b3e..0c141e43db 100644 --- a/docs/guides/serverless.yml.md +++ b/docs/guides/serverless.yml.md @@ -658,6 +658,10 @@ functions: kmsKeyArn: arn:aws:kms:us-east-1:XXXXXX:key/some-hash # Defines if you want to make use of SnapStart, this feature can only be used in combination with a Java runtime. Configuring this property will result in either None or PublishedVersions for the Lambda function snapStart: true + # Configure AWS Lambda Durable Functions for long-running workflows (auto-enables versioning) + durableConfig: + executionTimeout: 3600 # Required: 1-31536000 seconds + retentionPeriodInDays: 30 # Optional: 1-90 days # Disable the creation of the CloudWatch log group disableLogs: false # Duration for CloudWatch log retention (default: forever). Overrides provider setting. diff --git a/lib/cli/commands-schema/aws-service.js b/lib/cli/commands-schema/aws-service.js index 1f24d6d7f7..f14215e5b4 100644 --- a/lib/cli/commands-schema/aws-service.js +++ b/lib/cli/commands-schema/aws-service.js @@ -123,6 +123,9 @@ commands.set('invoke', { contextPath: { usage: 'Path to JSON or YAML file holding context data', }, + 'durable-execution-name': { + usage: 'Unique name for durable function execution (enables idempotency)', + }, }, lifecycleEvents: ['invoke'], }); diff --git a/lib/plugins/aws/deploy-function.js b/lib/plugins/aws/deploy-function.js index 821ac8b8b2..4a78f11900 100644 --- a/lib/plugins/aws/deploy-function.js +++ b/lib/plugins/aws/deploy-function.js @@ -233,6 +233,15 @@ class AwsDeployFunction { }; } + if (functionObj.durableConfig) { + params.DurableConfig = { + ExecutionTimeout: functionObj.durableConfig.executionTimeout, + }; + if (functionObj.durableConfig.retentionPeriodInDays !== undefined) { + params.DurableConfig.RetentionPeriodInDays = functionObj.durableConfig.retentionPeriodInDays; + } + } + if ( functionObj.description && functionObj.description !== remoteFunctionConfiguration.Description diff --git a/lib/plugins/aws/invoke.js b/lib/plugins/aws/invoke.js index 9b5d14c9fd..1b2446f836 100644 --- a/lib/plugins/aws/invoke.js +++ b/lib/plugins/aws/invoke.js @@ -97,6 +97,10 @@ class AwsInvoke { params.Qualifier = this.options.qualifier; } + if (this.options['durable-execution-name']) { + params.DurableExecutionName = this.options['durable-execution-name']; + } + return this.provider.request('Lambda', 'invoke', params); } diff --git a/lib/plugins/aws/package/compile/functions.js b/lib/plugins/aws/package/compile/functions.js index 728b9ab72e..0075116abc 100644 --- a/lib/plugins/aws/package/compile/functions.js +++ b/lib/plugins/aws/package/compile/functions.js @@ -466,7 +466,9 @@ class AwsCompileFunctions { const shouldVersionFunction = functionObject.versionFunction != null ? functionObject.versionFunction - : this.serverless.service.provider.versionFunctions; + : this.serverless.service.provider.versionFunctions || + // Durable Functions require versioning (qualified ARNs), so we enable it automatically + !!functionObject.durableConfig; if ( shouldVersionFunction || @@ -632,6 +634,18 @@ class AwsCompileFunctions { } } + if (functionObject.durableConfig) { + const durableConfig = { + ExecutionTimeout: functionObject.durableConfig.executionTimeout, + }; + + if (functionObject.durableConfig.retentionPeriodInDays !== undefined) { + durableConfig.RetentionPeriodInDays = functionObject.durableConfig.retentionPeriodInDays; + } + + functionResource.Properties.DurableConfig = durableConfig; + } + const logs = functionObject.logs || (this.serverless.service.provider.logs && this.serverless.service.provider.logs.lambda); diff --git a/lib/plugins/aws/package/lib/merge-iam-templates.js b/lib/plugins/aws/package/lib/merge-iam-templates.js index 3b2fffcfe2..575317b67d 100644 --- a/lib/plugins/aws/package/lib/merge-iam-templates.js +++ b/lib/plugins/aws/package/lib/merge-iam-templates.js @@ -216,6 +216,28 @@ module.exports = { ]); } + // check if one of the functions contains durable configuration + const durableConfigProvided = this.serverless.service.getAllFunctions().some((functionName) => { + const functionObject = this.serverless.service.getFunction(functionName); + return 'durableConfig' in functionObject; + }); + + if (durableConfigProvided) { + // add managed iam policy for durable execution + this.mergeManagedPolicies([ + { + 'Fn::Join': [ + '', + [ + 'arn:', + { Ref: 'AWS::Partition' }, + ':iam::aws:policy/service-role/AWSLambdaBasicDurableExecutionRolePolicy', + ], + ], + }, + ]); + } + return; }, diff --git a/lib/plugins/aws/provider.js b/lib/plugins/aws/provider.js index 2bb29e7018..ce1f62a225 100644 --- a/lib/plugins/aws/provider.js +++ b/lib/plugins/aws/provider.js @@ -1489,6 +1489,15 @@ class AwsProvider { }, kmsKeyArn: { $ref: '#/definitions/awsKmsArn' }, snapStart: { type: 'boolean' }, + durableConfig: { + type: 'object', + properties: { + executionTimeout: { type: 'integer', minimum: 1, maximum: 31536000 }, + retentionPeriodInDays: { type: 'integer', minimum: 1, maximum: 90 }, + }, + required: ['executionTimeout'], + additionalProperties: false, + }, layers: { $ref: '#/definitions/awsLambdaLayers' }, logRetentionInDays: { $ref: '#/definitions/awsLogRetentionInDays', diff --git a/test/unit/lib/plugins/aws/invoke.test.js b/test/unit/lib/plugins/aws/invoke.test.js index e7d92a27ff..15df0e48d9 100644 --- a/test/unit/lib/plugins/aws/invoke.test.js +++ b/test/unit/lib/plugins/aws/invoke.test.js @@ -481,4 +481,57 @@ describe('test/unit/lib/plugins/aws/invoke.test.js', () => { }) ).to.be.eventually.fulfilled; }); + + it('should support --durable-execution-name option', async () => { + const lambdaInvokeStub = sinon.stub(); + const result = await runServerless({ + fixture: 'invocation', + command: 'invoke', + options: { + function: 'callback', + 'durable-execution-name': 'order-123', + }, + awsRequestStubMap: { + Lambda: { + invoke: (args) => { + lambdaInvokeStub.returns('payload'); + return lambdaInvokeStub(args); + }, + }, + }, + }); + expect(lambdaInvokeStub.args[0][0]).to.deep.equal({ + FunctionName: result.serverless.service.getFunction('callback').name, + InvocationType: 'RequestResponse', + LogType: 'None', + DurableExecutionName: 'order-123', + Payload: Buffer.from('{}'), + }); + }); + + it('should not include DurableExecutionName when option is not provided', async () => { + const lambdaInvokeStub = sinon.stub(); + const result = await runServerless({ + fixture: 'invocation', + command: 'invoke', + options: { + function: 'callback', + }, + awsRequestStubMap: { + Lambda: { + invoke: (args) => { + lambdaInvokeStub.returns('payload'); + return lambdaInvokeStub(args); + }, + }, + }, + }); + expect(lambdaInvokeStub.args[0][0]).to.not.have.property('DurableExecutionName'); + expect(lambdaInvokeStub.args[0][0]).to.deep.equal({ + FunctionName: result.serverless.service.getFunction('callback').name, + InvocationType: 'RequestResponse', + LogType: 'None', + Payload: Buffer.from('{}'), + }); + }); }); diff --git a/test/unit/lib/plugins/aws/package/compile/functions.test.js b/test/unit/lib/plugins/aws/package/compile/functions.test.js index fc99258f74..6345994a32 100644 --- a/test/unit/lib/plugins/aws/package/compile/functions.test.js +++ b/test/unit/lib/plugins/aws/package/compile/functions.test.js @@ -964,6 +964,59 @@ describe('AwsCompileFunctions', () => { expect(cfTemplate.Resources.BasicLambdaFunction.Properties).to.not.have.property('SnapStart'); }); + + it('should set function DurableConfig when enabled with all properties', async () => { + const { cfTemplate } = await runServerless({ + fixture: 'function', + configExt: { + functions: { + basic: { + durableConfig: { + executionTimeout: 3600, + retentionPeriodInDays: 30, + }, + }, + }, + }, + command: 'package', + }); + + expect(cfTemplate.Resources.BasicLambdaFunction.Properties.DurableConfig).to.deep.equal({ + ExecutionTimeout: 3600, + RetentionPeriodInDays: 30, + }); + }); + + it('should set function DurableConfig with only executionTimeout', async () => { + const { cfTemplate } = await runServerless({ + fixture: 'function', + configExt: { + functions: { + basic: { + durableConfig: { + executionTimeout: 7200, + }, + }, + }, + }, + command: 'package', + }); + + expect(cfTemplate.Resources.BasicLambdaFunction.Properties.DurableConfig).to.deep.equal({ + ExecutionTimeout: 7200, + }); + }); + + it('should not set function DurableConfig when not specified', async () => { + const { cfTemplate } = await runServerless({ + fixture: 'function', + command: 'package', + }); + + expect(cfTemplate.Resources.BasicLambdaFunction.Properties).to.not.have.property( + 'DurableConfig' + ); + }); }); describe('#compileRole()', () => { diff --git a/test/unit/lib/plugins/aws/package/lib/merge-iam-templates.test.js b/test/unit/lib/plugins/aws/package/lib/merge-iam-templates.test.js index a2675400eb..481812e337 100644 --- a/test/unit/lib/plugins/aws/package/lib/merge-iam-templates.test.js +++ b/test/unit/lib/plugins/aws/package/lib/merge-iam-templates.test.js @@ -601,6 +601,56 @@ describe('lib/plugins/aws/package/lib/mergeIamTemplates.test.js', () => { ], }); }); + + it('should ensure needed IAM configuration when `functions[].durableConfig` is configured', async () => { + const { cfTemplate, awsNaming } = await runServerless({ + fixture: 'function', + command: 'package', + configExt: { + functions: { + basic: { + durableConfig: { + executionTimeout: 3600, + }, + }, + }, + }, + }); + + const IamRoleLambdaExecution = awsNaming.getRoleLogicalId(); + const { Properties } = cfTemplate.Resources[IamRoleLambdaExecution]; + expect(Properties.ManagedPolicyArns).to.deep.includes({ + 'Fn::Join': [ + '', + [ + 'arn:', + { Ref: 'AWS::Partition' }, + ':iam::aws:policy/service-role/AWSLambdaBasicDurableExecutionRolePolicy', + ], + ], + }); + }); + + it('should not add durable IAM policy when `functions[].durableConfig` is not configured', async () => { + const { cfTemplate, awsNaming } = await runServerless({ + fixture: 'function', + command: 'package', + }); + + const IamRoleLambdaExecution = awsNaming.getRoleLogicalId(); + const { Properties } = cfTemplate.Resources[IamRoleLambdaExecution]; + const durablePolicyArn = { + 'Fn::Join': [ + '', + [ + 'arn:', + { Ref: 'AWS::Partition' }, + ':iam::aws:policy/service-role/AWSLambdaBasicDurableExecutionRolePolicy', + ], + ], + }; + expect(Properties.ManagedPolicyArns || []).to.not.deep.includes(durablePolicyArn); + }); }); }); });