Skip to content

Commit ebe49c4

Browse files
Add lambda.DurableFunction blueprint (vs2026) (#2445)
1 parent 483cdca commit ebe49c4

20 files changed

Lines changed: 766 additions & 4 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"Projects": [
3+
{
4+
"Name": "Amazon.Lambda.Templates",
5+
"Type": "Minor",
6+
"ChangelogMessages": [
7+
"Add lambda.DurableFunction and serverless.DurableFunction blueprints (vs2026) for Lambda durable execution workflows. lambda.DurableFunction uses the class-library static-wrapper model (DurableFunction.WrapAsync) and deploys via dotnet lambda deploy-function; serverless.DurableFunction uses the annotations model ([LambdaFunction] + [DurableExecution]) and deploys via CloudFormation (serverless.template). Both target the managed dotnet10 runtime and ship a sample ProcessOrder workflow plus a local test project driven by Amazon.Lambda.DurableExecution.Testing. Preview."
8+
]
9+
}
10+
]
11+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"display-name": "Durable Function",
3+
"system-name": "DurableFunction",
4+
"description": "A durable execution workflow that checkpoints every step, so it can be suspended during waits and resumed after a crash without re-running completed work. Deploys straight to Lambda with the 'dotnet lambda deploy-function' command.",
5+
"sort-order": 124,
6+
"hidden-tags": [
7+
"C#",
8+
"LambdaProject"
9+
],
10+
"tags": [
11+
"Durable"
12+
]
13+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
{
2+
"author": "AWS",
3+
"classifications": [
4+
"AWS",
5+
"Lambda",
6+
"Serverless"
7+
],
8+
"name": "Lambda Durable Function",
9+
"identity": "AWS.Lambda.Function.Durable.CSharp",
10+
"groupIdentity": "AWS.Lambda.Function.Durable",
11+
"shortName": "lambda.DurableFunction",
12+
"tags": {
13+
"language": "C#",
14+
"type": "project"
15+
},
16+
"sourceName": "BlueprintBaseName.1",
17+
"preferNameDirectory": true,
18+
"symbols": {
19+
"profile": {
20+
"type": "parameter",
21+
"description": "The AWS credentials profile set in aws-lambda-tools-defaults.json and used as the default profile when interacting with AWS.",
22+
"datatype": "string",
23+
"replaces": "DefaultProfile",
24+
"defaultValue": ""
25+
},
26+
"region": {
27+
"type": "parameter",
28+
"description": "The AWS region set in aws-lambda-tools-defaults.json and used as the default region when interacting with AWS.",
29+
"datatype": "string",
30+
"replaces": "DefaultRegion",
31+
"defaultValue": ""
32+
}
33+
},
34+
"primaryOutputs": [
35+
{
36+
"path": "./BlueprintBaseName.1.csproj"
37+
}
38+
]
39+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
<PropertyGroup>
3+
<!--
4+
Class-library programming model: there is NO OutputType=Exe and NO hand-written Main.
5+
The handler delegates to DurableFunction.WrapAsync (the static wrapper model), and the managed
6+
dotnet10 runtime hosts its own bootstrap and invokes the handler via an
7+
Assembly::Type::Method handler string. Durable execution requires the managed dotnet10 runtime.
8+
-->
9+
<OutputType>Library</OutputType>
10+
<TargetFramework>net10.0</TargetFramework>
11+
<ImplicitUsings>enable</ImplicitUsings>
12+
<Nullable>enable</Nullable>
13+
<GenerateRuntimeConfigurationFiles>true</GenerateRuntimeConfigurationFiles>
14+
<AWSProjectType>Lambda</AWSProjectType>
15+
<!-- This property makes the build directory similar to a publish directory and helps the AWS .NET Lambda Mock Test Tool find project dependencies. -->
16+
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
17+
<!-- Generate ready to run images during publishing to improvement cold starts. -->
18+
<PublishReadyToRun>true</PublishReadyToRun>
19+
</PropertyGroup>
20+
<ItemGroup>
21+
<PackageReference Include="Amazon.Lambda.Core" Version="3.1.1" />
22+
<PackageReference Include="Amazon.Lambda.Serialization.SystemTextJson" Version="3.0.0" />
23+
<PackageReference Include="Amazon.Lambda.DurableExecution" Version="0.1.1-preview" />
24+
</ItemGroup>
25+
</Project>
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
using Amazon.Lambda.Core;
2+
using Amazon.Lambda.DurableExecution;
3+
using Amazon.Lambda.Serialization.SystemTextJson;
4+
using Microsoft.Extensions.Logging;
5+
6+
// The durable runtime reads this serializer off ILambdaContext.Serializer to (de)serialize the
7+
// invocation envelope and every checkpointed step input/output.
8+
[assembly: LambdaSerializer(typeof(DefaultLambdaJsonSerializer))]
9+
10+
namespace BlueprintBaseName._1;
11+
12+
public class Function
13+
{
14+
/// <summary>
15+
/// The Lambda entry point. The managed runtime invokes this method directly (via the
16+
/// <c>Assembly::Type::Method</c> handler string in aws-lambda-tools-defaults.json) and
17+
/// <see cref="DurableFunction.WrapAsync"/> bridges the durable invocation envelope to the
18+
/// strongly-typed <see cref="ProcessOrder"/> workflow below.
19+
/// </summary>
20+
public Task<DurableExecutionInvocationOutput> Handler(
21+
DurableExecutionInvocationInput input, ILambdaContext context)
22+
=> DurableFunction.WrapAsync<OrderRequest, OrderResult>(ProcessOrder, input, context);
23+
24+
public async Task<OrderResult> ProcessOrder(OrderRequest order, IDurableContext context)
25+
{
26+
// The durable logger is replay-aware: this line is emitted once, not once per replay.
27+
context.Logger.LogInformation("Processing order {OrderId}", order.OrderId);
28+
29+
// 1) VALIDATE — a plain step. The result is checkpointed; on replay the cached value is
30+
// returned instead of re-running the body.
31+
var itemCount = await context.StepAsync(
32+
async (_, _) =>
33+
{
34+
await Task.CompletedTask;
35+
if (order.Items is null || order.Items.Length == 0)
36+
throw new InvalidOperationException("Order has no items.");
37+
return order.Items.Length;
38+
},
39+
name: "validate_order");
40+
41+
// 2) CHARGE PAYMENT — a step with a retry policy. Payment gateways are flaky, so the SDK
42+
// transparently retries with exponential backoff and checkpoints only the successful
43+
// attempt. AtMostOncePerRetry avoids double-charging if Lambda is re-invoked mid-attempt.
44+
var transactionId = await context.StepAsync(
45+
async (_, _) =>
46+
{
47+
await Task.CompletedTask;
48+
return $"txn-{order.OrderId}";
49+
},
50+
name: "charge_payment",
51+
config: new StepConfig
52+
{
53+
RetryStrategy = RetryStrategy.Exponential(
54+
maxAttempts: 5,
55+
initialDelay: TimeSpan.FromSeconds(2),
56+
maxDelay: TimeSpan.FromSeconds(30),
57+
backoffRate: 2.0),
58+
Semantics = StepSemantics.AtMostOncePerRetry,
59+
});
60+
61+
// 3) SETTLEMENT WAIT — suspend the workflow for a fixed delay. While suspended there is no
62+
// compute charge; the runtime re-invokes the function when the timer fires.
63+
await context.WaitAsync(TimeSpan.FromSeconds(5), name: "settlement_delay");
64+
65+
// 4) SHIP — group related steps into a single child context. The packing and labeling steps
66+
// are checkpointed together as one logical operation.
67+
var trackingId = await context.RunInChildContextAsync(
68+
async (childContext, _) =>
69+
{
70+
await childContext.StepAsync(
71+
async (_, _) => { await Task.CompletedTask; return "packed"; },
72+
name: "pack");
73+
74+
return await childContext.StepAsync(
75+
async (_, _) => { await Task.CompletedTask; return $"trk-{order.OrderId}"; },
76+
name: "label");
77+
},
78+
name: "ship_order");
79+
80+
context.Logger.LogInformation("Order {OrderId} shipped: {TrackingId}", order.OrderId, trackingId);
81+
82+
return new OrderResult
83+
{
84+
OrderId = order.OrderId,
85+
Status = "shipped",
86+
ItemCount = itemCount,
87+
TransactionId = transactionId,
88+
TrackingId = trackingId,
89+
};
90+
}
91+
}
92+
93+
/// <summary>Input payload for the workflow.</summary>
94+
public class OrderRequest
95+
{
96+
public string? OrderId { get; set; }
97+
public string[]? Items { get; set; }
98+
}
99+
100+
/// <summary>Output payload returned when the workflow completes.</summary>
101+
public class OrderResult
102+
{
103+
public string? OrderId { get; set; }
104+
public string? Status { get; set; }
105+
public int ItemCount { get; set; }
106+
public string? TransactionId { get; set; }
107+
public string? TrackingId { get; set; }
108+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
# Durable Lambda Function
2+
3+
This project contains a Lambda **durable execution** workflow built with the
4+
[Amazon.Lambda.DurableExecution](https://github.com/aws/aws-lambda-dotnet/tree/master/Libraries/src/Amazon.Lambda.DurableExecution)
5+
**static wrapper** programming model, deployed straight to Lambda with `dotnet lambda deploy-function`.
6+
7+
Durable execution lets you write a multi-step workflow as a single straight-line method. The
8+
runtime checkpoints every operation, so the function can be **suspended** during waits and
9+
**resumed after a crash** without re-running completed work.
10+
11+
> Looking for the CloudFormation/Annotations variant? Use the **`serverless.DurableFunction`**
12+
> template, which uses `[DurableExecution]` + a `serverless.template` and deploys with
13+
> `dotnet lambda deploy-serverless`.
14+
15+
## How it works
16+
17+
`Function.Handler` is the Lambda entry point. It delegates to `DurableFunction.WrapAsync`, which
18+
bridges the durable invocation envelope to the strongly-typed `ProcessOrder` workflow:
19+
20+
```csharp
21+
public Task<DurableExecutionInvocationOutput> Handler(
22+
DurableExecutionInvocationInput input, ILambdaContext context)
23+
=> DurableFunction.WrapAsync<OrderRequest, OrderResult>(ProcessOrder, input, context);
24+
```
25+
26+
This is the **class-library** hosting model on the managed `dotnet10` runtime: there is no
27+
`Main`/`LambdaBootstrap` loop and no `[DurableExecution]` annotation. The runtime hosts the
28+
bootstrap and invokes `Handler` directly via the `Assembly::Type::Method` handler string in
29+
`aws-lambda-tools-defaults.json`. The serializer is declared with an assembly attribute:
30+
31+
```csharp
32+
[assembly: LambdaSerializer(typeof(DefaultLambdaJsonSerializer))]
33+
```
34+
35+
The workflow uses the core durable primitives on `IDurableContext`:
36+
37+
| Primitive | Used for |
38+
|-----------|----------|
39+
| `StepAsync` | A checkpointed unit of work. On replay the cached result is returned instead of re-running the body. |
40+
| `StepAsync` + `StepConfig.RetryStrategy` | Retry a flaky step with exponential backoff; only the successful attempt is checkpointed. |
41+
| `StepSemantics.AtMostOncePerRetry` | Avoid re-running a side-effecting step (e.g. charging a card) if Lambda is re-invoked mid-attempt. |
42+
| `WaitAsync` | Suspend the workflow for a delay. There is no compute charge while suspended. |
43+
| `RunInChildContextAsync` | Group related operations into a single logical operation. |
44+
45+
> **Note:** Durable execution requires the managed **`dotnet10`** runtime.
46+
47+
## Requirements
48+
49+
* [.NET 10 SDK](https://dotnet.microsoft.com/download)
50+
* [Amazon.Lambda.Tools](https://github.com/aws/aws-extensions-for-dotnet-cli#aws-lambda-amazonlambdatools)
51+
52+
```bash
53+
dotnet tool install -g Amazon.Lambda.Tools
54+
```
55+
56+
## Deploy
57+
58+
`aws-lambda-tools-defaults.json` sets the runtime (`dotnet10`), handler, and the durable execution
59+
timeout (`durable-execution-timeout`). Deploy the function directly to Lambda with:
60+
61+
```bash
62+
dotnet lambda deploy-function
63+
```
64+
65+
When the tool creates the function's execution role for you, it automatically attaches the
66+
`AWSLambdaBasicDurableExecutionRolePolicy` managed policy, which grants the durable-execution
67+
checkpoint permissions the function needs at runtime. If you supply your own role
68+
(`--function-role`), make sure that policy is attached to it.
69+
70+
## Invoke
71+
72+
Durable functions are invoked asynchronously and then monitored until the execution completes. Pass
73+
`--invoke-mode DurableExecution` so the tool starts the execution and polls it to completion:
74+
75+
```bash
76+
dotnet lambda invoke-function BlueprintBaseName.1 --invoke-mode DurableExecution --payload '{"OrderId":"order-123","Items":["sku-1","sku-2"]}'
77+
```
78+
79+
The workflow validates the order, charges payment, waits out a short settlement period, ships the
80+
order in a child context, and returns the result.
81+
82+
## Test
83+
84+
You can drive the workflow locally with the `Amazon.Lambda.DurableExecution.Testing` package — no AWS
85+
resources required. Add a test project that references your function project and
86+
`Amazon.Lambda.DurableExecution.Testing`, then use its in-memory durable execution runner to invoke
87+
`ProcessOrder` and assert on the result. If you created this project from the Visual Studio template
88+
that includes a test project, run it with:
89+
90+
```bash
91+
dotnet test
92+
```
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"Information": [
3+
"This file provides default values for the deployment wizard inside Visual Studio and the AWS Lambda commands added to the .NET Core CLI.",
4+
"To learn more about the Lambda commands with the .NET Core CLI execute the following command at the command line in the project root directory.",
5+
"dotnet lambda help",
6+
"All the command line options for the Lambda command can be specified in this file."
7+
],
8+
"profile": "DefaultProfile",
9+
"region": "DefaultRegion",
10+
"configuration": "Release",
11+
"function-runtime": "dotnet10",
12+
"function-memory-size": 512,
13+
"function-timeout": 30,
14+
"function-handler": "BlueprintBaseName.1::BlueprintBaseName._1.Function::Handler",
15+
"durable-execution-timeout": 86400
16+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
<PropertyGroup>
3+
<TargetFramework>net10.0</TargetFramework>
4+
<ImplicitUsings>enable</ImplicitUsings>
5+
<Nullable>enable</Nullable>
6+
<IsTestProject>true</IsTestProject>
7+
</PropertyGroup>
8+
<ItemGroup>
9+
<PackageReference Include="Amazon.Lambda.Core" Version="3.1.1" />
10+
<PackageReference Include="Amazon.Lambda.TestUtilities" Version="4.1.0" />
11+
<PackageReference Include="Amazon.Lambda.DurableExecution.Testing" Version="0.0.1-preview" />
12+
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
13+
<PackageReference Include="xunit.v3" Version="3.2.2" />
14+
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" />
15+
</ItemGroup>
16+
<ItemGroup>
17+
<ProjectReference Include="..\..\src\BlueprintBaseName.1\BlueprintBaseName.1.csproj" />
18+
</ItemGroup>
19+
</Project>
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
using Amazon.Lambda.DurableExecution.Testing;
2+
using Xunit;
3+
4+
namespace BlueprintBaseName._1.Tests;
5+
6+
public class FunctionTest
7+
{
8+
[Fact]
9+
public async Task ProcessOrder_ShipsOrder()
10+
{
11+
var function = new Function();
12+
13+
// The local runner drives the workflow to completion in-process using the real durable
14+
// runtime with an in-memory backend. SkipTime collapses the settlement WaitAsync delay so
15+
// the test does not actually block for 5 seconds.
16+
await using var runner = new DurableTestRunner<OrderRequest, OrderResult>(
17+
handler: function.ProcessOrder,
18+
options: new TestRunnerOptions { SkipTime = true });
19+
20+
var input = new OrderRequest
21+
{
22+
OrderId = "order-123",
23+
Items = new[] { "sku-1", "sku-2" },
24+
};
25+
26+
var result = await runner.RunAsync(input, cancellationToken: TestContext.Current.CancellationToken);
27+
28+
result.EnsureSucceeded();
29+
Assert.NotNull(result.Result);
30+
Assert.Equal("order-123", result.Result!.OrderId);
31+
Assert.Equal("shipped", result.Result.Status);
32+
Assert.Equal(2, result.Result.ItemCount);
33+
Assert.Equal("txn-order-123", result.Result.TransactionId);
34+
Assert.Equal("trk-order-123", result.Result.TrackingId);
35+
36+
// Each named operation is checkpointed and inspectable.
37+
Assert.Equal(OperationStatus.Succeeded, result.GetStep("validate_order").Status);
38+
Assert.Equal(OperationStatus.Succeeded, result.GetStep("charge_payment").Status);
39+
}
40+
41+
[Fact]
42+
public async Task ProcessOrder_EmptyOrder_Fails()
43+
{
44+
var function = new Function();
45+
46+
await using var runner = new DurableTestRunner<OrderRequest, OrderResult>(
47+
handler: function.ProcessOrder,
48+
options: new TestRunnerOptions { SkipTime = true });
49+
50+
var input = new OrderRequest { OrderId = "order-456", Items = System.Array.Empty<string>() };
51+
52+
var result = await runner.RunAsync(input, cancellationToken: TestContext.Current.CancellationToken);
53+
54+
Assert.True(result.IsFailed);
55+
}
56+
}

0 commit comments

Comments
 (0)