Skip to content

Commit 06d85f3

Browse files
committed
Add per-invocation async configure
1 parent a3cfea5 commit 06d85f3

3 files changed

Lines changed: 254 additions & 10 deletions

File tree

src/Temporalio.Extensions.Aws.Lambda/README.md

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,33 @@ public class Function
3333
}
3434
```
3535

36-
`configure` runs once when the delegate is created, which is normally during Lambda cold start. Each invocation creates a
37-
fresh Temporal client and worker, runs until the Lambda deadline minus `ShutdownDeadlineBuffer`, then shuts down and runs
38-
configured shutdown hooks.
36+
The synchronous `configure` callback shown above runs once when the delegate is created, which is normally during Lambda
37+
cold start. Use it for static worker setup such as task queues, workflow/activity registration, and options that can be
38+
shared across warm invocations. Each invocation creates a fresh Temporal client and worker, runs until the Lambda
39+
deadline minus `ShutdownDeadlineBuffer`, then shuts down and runs configured shutdown hooks.
40+
41+
For setup that must be awaited per invocation, use the async overload:
42+
43+
```csharp
44+
private static readonly Func<object?, ILambdaContext, Task> WorkerHandler =
45+
TemporalLambdaWorker.CreateHandler(
46+
new WorkerDeploymentVersion("payments-worker", "2026-05-27"),
47+
async config =>
48+
{
49+
config.WorkerOptions.TaskQueue = "payments";
50+
config.WorkerOptions.AddWorkflow<PaymentWorkflow>();
51+
config.WorkerOptions.AddActivity(PaymentActivities.ChargeAsync);
52+
53+
config.ClientOptions.ApiKey = await LoadTemporalApiKeyAsync();
54+
config.ShutdownHooks.Add(async cancellationToken =>
55+
{
56+
await FlushPerInvocationResourceAsync(cancellationToken);
57+
});
58+
});
59+
```
60+
61+
The async `configure` callback is awaited once per Lambda invocation, before the Temporal client connects. It receives a
62+
fresh `LambdaWorkerConfig` each time, so use shutdown hooks for any per-invocation cleanup.
3963

4064
## Configuration
4165

@@ -70,7 +94,8 @@ config.ClientOptions = new TemporalClientConnectOptions
7094
```
7195

7296
If `TEMPORAL_TASK_QUEUE` is present, it is used as the initial `WorkerOptions.TaskQueue`. You can still override the task
73-
queue in `configure`. If no task queue is set by the environment or by `configure`, handler creation fails.
97+
queue in `configure`. If no task queue is set by the environment or by `configure`, synchronous handler creation fails;
98+
with async configure, the invocation fails after the callback is awaited.
7499

75100
`TemporalLambdaWorker.CreateHandler` requires a `WorkerDeploymentVersion` and always enables Worker Versioning by setting
76101
`WorkerOptions.DeploymentOptions` with `UseWorkerVersioning = true`. Use a deployment name and build ID that match your
@@ -80,7 +105,8 @@ value while enforcing the deployment version passed to `CreateHandler`.
80105

81106
The helper applies Lambda-oriented worker defaults before `configure`, including lower concurrency, a 5 second graceful
82107
shutdown timeout, a smaller workflow cache, simple poller limits, and disabled eager activity execution. Values you set in
83-
`configure` override these defaults except for the enforced deployment version.
108+
`configure` override these defaults except for the enforced deployment version. With sync configure this happens once at
109+
handler creation; with async configure it happens for every invocation.
84110

85111
## Shutdown Hooks
86112

@@ -94,7 +120,7 @@ config.ShutdownHooks.Add(async cancellationToken =>
94120
```
95121

96122
Hooks run in order after the worker has stopped. Hook failures are logged to the Lambda context logger and later hooks
97-
still run.
123+
still run. Hooks added from async configure are scoped to that invocation.
98124

99125
## Observability
100126

src/Temporalio.Extensions.Aws.Lambda/TemporalLambdaWorker.cs

Lines changed: 84 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,23 @@ public static class TemporalLambdaWorker
4141
LoadClientConnectOptions = options => LoadClientConnectOptions(options),
4242
});
4343

44+
/// <summary>
45+
/// Create an AWS Lambda handler that runs a Temporal worker for each invocation.
46+
/// </summary>
47+
/// <param name="version">Worker deployment version for this Lambda worker.</param>
48+
/// <param name="configureAsync">Callback to configure client and worker options per invocation.</param>
49+
/// <returns>A Lambda handler delegate.</returns>
50+
public static Func<object?, ILambdaContext, Task> CreateHandler(
51+
WorkerDeploymentVersion version,
52+
Func<LambdaWorkerConfig, Task> configureAsync) =>
53+
CreateHandler(
54+
version,
55+
configureAsync,
56+
new TemporalLambdaWorkerHandlerOptions
57+
{
58+
LoadClientConnectOptions = options => LoadClientConnectOptions(options),
59+
});
60+
4461
/// <summary>
4562
/// Load Temporal client connection options using AWS Lambda-aware config file resolution.
4663
/// </summary>
@@ -80,14 +97,44 @@ public static TemporalClientConnectOptions LoadClientConnectOptions(
8097
WorkerDeploymentVersion version,
8198
Action<LambdaWorkerConfig> configure,
8299
TemporalLambdaWorkerHandlerOptions handlerOptions)
100+
{
101+
ValidateCreateHandlerArgs(version, configure, nameof(configure), handlerOptions);
102+
var config = CreateConfig(version, handlerOptions);
103+
configure(config);
104+
var state = PrepareHandlerState(version, config, handlerOptions);
105+
return state.HandleAsync;
106+
}
107+
108+
/// <summary>
109+
/// Create an AWS Lambda handler with overridable internals for tests.
110+
/// </summary>
111+
/// <param name="version">Worker deployment version for this Lambda worker.</param>
112+
/// <param name="configureAsync">Callback to configure client and worker options per invocation.</param>
113+
/// <param name="handlerOptions">Internal handler options.</param>
114+
/// <returns>A Lambda handler delegate.</returns>
115+
internal static Func<object?, ILambdaContext, Task> CreateHandler(
116+
WorkerDeploymentVersion version,
117+
Func<LambdaWorkerConfig, Task> configureAsync,
118+
TemporalLambdaWorkerHandlerOptions handlerOptions)
119+
{
120+
ValidateCreateHandlerArgs(version, configureAsync, nameof(configureAsync), handlerOptions);
121+
var state = new AsyncLambdaWorkerHandlerState(version, configureAsync, handlerOptions);
122+
return state.HandleAsync;
123+
}
124+
125+
private static void ValidateCreateHandlerArgs(
126+
WorkerDeploymentVersion version,
127+
object configure,
128+
string configureParamName,
129+
TemporalLambdaWorkerHandlerOptions handlerOptions)
83130
{
84131
if (version == null)
85132
{
86133
throw new ArgumentNullException(nameof(version));
87134
}
88135
if (configure == null)
89136
{
90-
throw new ArgumentNullException(nameof(configure));
137+
throw new ArgumentNullException(configureParamName);
91138
}
92139
if (handlerOptions == null)
93140
{
@@ -101,7 +148,12 @@ public static TemporalClientConnectOptions LoadClientConnectOptions(
101148
{
102149
throw new ArgumentException("Build ID must be set", nameof(version));
103150
}
151+
}
104152

153+
private static LambdaWorkerConfig CreateConfig(
154+
WorkerDeploymentVersion version,
155+
TemporalLambdaWorkerHandlerOptions handlerOptions)
156+
{
105157
var loadClientConnectOptions = handlerOptions.LoadClientConnectOptions;
106158
var config = new LambdaWorkerConfig(
107159
loadClientConnectOptions == null ?
@@ -114,9 +166,7 @@ public static TemporalClientConnectOptions LoadClientConnectOptions(
114166
}
115167
ApplyDeploymentVersion(config.WorkerOptions, version);
116168

117-
configure(config);
118-
var state = PrepareHandlerState(version, config, handlerOptions);
119-
return state.HandleAsync;
169+
return config;
120170
}
121171

122172
private static LambdaWorkerHandlerState PrepareHandlerState(
@@ -345,5 +395,35 @@ private async Task RunShutdownHooksAsync(ILambdaContext context)
345395
}
346396
}
347397
}
398+
399+
private sealed class AsyncLambdaWorkerHandlerState
400+
{
401+
private readonly WorkerDeploymentVersion version;
402+
private readonly Func<LambdaWorkerConfig, Task> configureAsync;
403+
private readonly TemporalLambdaWorkerHandlerOptions handlerOptions;
404+
405+
public AsyncLambdaWorkerHandlerState(
406+
WorkerDeploymentVersion version,
407+
Func<LambdaWorkerConfig, Task> configureAsync,
408+
TemporalLambdaWorkerHandlerOptions handlerOptions)
409+
{
410+
this.version = version;
411+
this.configureAsync = configureAsync;
412+
this.handlerOptions = handlerOptions;
413+
}
414+
415+
public async Task HandleAsync(object? input, ILambdaContext context)
416+
{
417+
if (context == null)
418+
{
419+
throw new ArgumentNullException(nameof(context));
420+
}
421+
422+
var config = CreateConfig(version, handlerOptions);
423+
await configureAsync(config).ConfigureAwait(false);
424+
var state = PrepareHandlerState(version, config, handlerOptions);
425+
await state.HandleAsync(input, context).ConfigureAwait(false);
426+
}
427+
}
348428
}
349429
}

tests/Temporalio.Tests/Extensions/Aws/Lambda/TemporalLambdaWorkerTests.cs

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -752,6 +752,144 @@ public async Task Invoke_ShutdownHooksRunInOrderPerInvocationAndContinueAfterFai
752752
Assert.Equal(2, workerCreations);
753753
}
754754

755+
[Fact]
756+
public async Task CreateHandler_AsyncConfigureRunsPerInvocationWithFreshConfig()
757+
{
758+
var configureCalls = 0;
759+
var capturedConfigs = new List<LambdaWorkerConfig>();
760+
var capturedTargets = new List<string?>();
761+
var capturedTaskQueues = new List<string?>();
762+
var hookCalls = new List<string>();
763+
var handler = TemporalLambdaWorker.CreateHandler(
764+
Version,
765+
async config =>
766+
{
767+
await Task.Yield();
768+
var call = ++configureCalls;
769+
capturedConfigs.Add(config);
770+
Assert.Equal("env-task-queue", config.WorkerOptions.TaskQueue);
771+
772+
config.ClientOptions.TargetHost = $"target-{call}";
773+
config.WorkerOptions.TaskQueue = $"task-queue-{call}";
774+
config.ShutdownHooks.Add(_ =>
775+
{
776+
hookCalls.Add($"hook-{call}");
777+
return Task.CompletedTask;
778+
});
779+
},
780+
new TemporalLambdaWorkerHandlerOptions
781+
{
782+
GetEnvironmentVariable = name =>
783+
name == "TEMPORAL_TASK_QUEUE" ? "env-task-queue" : null,
784+
ConnectClientAsync = options =>
785+
{
786+
capturedTargets.Add(options.TargetHost);
787+
return Task.FromResult<object>(new object());
788+
},
789+
CreateWorker = (_, options) =>
790+
{
791+
capturedTaskQueues.Add(options.TaskQueue);
792+
return new FakeLambdaWorker(_ => Task.CompletedTask);
793+
},
794+
});
795+
796+
await handler(null, new FakeLambdaContext());
797+
await handler(null, new FakeLambdaContext());
798+
799+
Assert.Equal(2, configureCalls);
800+
Assert.Equal(2, capturedConfigs.Count);
801+
Assert.NotSame(capturedConfigs[0], capturedConfigs[1]);
802+
Assert.Equal(new[] { "target-1", "target-2" }, capturedTargets);
803+
Assert.Equal(new[] { "task-queue-1", "task-queue-2" }, capturedTaskQueues);
804+
Assert.Equal(new[] { "hook-1", "hook-2" }, hookCalls);
805+
}
806+
807+
[Fact]
808+
public async Task CreateHandler_AsyncConfigureErrorsSurfaceOnInvocation()
809+
{
810+
var configureCalls = 0;
811+
var handler = TemporalLambdaWorker.CreateHandler(
812+
Version,
813+
async config =>
814+
{
815+
_ = config;
816+
await Task.Yield();
817+
configureCalls++;
818+
throw new InvalidOperationException("bad config");
819+
},
820+
new TemporalLambdaWorkerHandlerOptions());
821+
822+
var error = await Assert.ThrowsAsync<InvalidOperationException>(() =>
823+
handler(null, new FakeLambdaContext()));
824+
Assert.Equal("bad config", error.Message);
825+
Assert.Equal(1, configureCalls);
826+
}
827+
828+
[Fact]
829+
public async Task CreateHandler_AsyncConfigureValidatesTaskQueueOnInvocation()
830+
{
831+
var handler = TemporalLambdaWorker.CreateHandler(
832+
Version,
833+
_ => Task.CompletedTask,
834+
new TemporalLambdaWorkerHandlerOptions
835+
{
836+
GetEnvironmentVariable = _ => null,
837+
});
838+
839+
await Assert.ThrowsAsync<InvalidOperationException>(() =>
840+
handler(null, new FakeLambdaContext()));
841+
}
842+
843+
[Fact]
844+
public async Task CreateHandler_AsyncConfigureShutdownHooksRunAfterFailures()
845+
{
846+
var hookCalls = new List<string>();
847+
var connectFailureHandler = TemporalLambdaWorker.CreateHandler(
848+
Version,
849+
config =>
850+
{
851+
config.WorkerOptions.TaskQueue = "task-queue";
852+
config.ShutdownHooks.Add(_ =>
853+
{
854+
hookCalls.Add("connect");
855+
return Task.CompletedTask;
856+
});
857+
return Task.CompletedTask;
858+
},
859+
new TemporalLambdaWorkerHandlerOptions
860+
{
861+
ConnectClientAsync = _ =>
862+
throw new InvalidOperationException("connect failed"),
863+
});
864+
865+
await Assert.ThrowsAsync<InvalidOperationException>(() =>
866+
connectFailureHandler(null, new FakeLambdaContext()));
867+
868+
var workerFailureHandler = TemporalLambdaWorker.CreateHandler(
869+
Version,
870+
config =>
871+
{
872+
config.WorkerOptions.TaskQueue = "task-queue";
873+
config.ShutdownHooks.Add(_ =>
874+
{
875+
hookCalls.Add("worker");
876+
return Task.CompletedTask;
877+
});
878+
return Task.CompletedTask;
879+
},
880+
new TemporalLambdaWorkerHandlerOptions
881+
{
882+
ConnectClientAsync = _ => Task.FromResult<object>(new object()),
883+
CreateWorker = (_, _) => new FakeLambdaWorker(
884+
_ => throw new InvalidOperationException("worker failed")),
885+
});
886+
887+
await Assert.ThrowsAsync<InvalidOperationException>(() =>
888+
workerFailureHandler(null, new FakeLambdaContext()));
889+
890+
Assert.Equal(new[] { "connect", "worker" }, hookCalls);
891+
}
892+
755893
private static Func<object?, ILambdaContext, Task> CreateCapturingHandler(
756894
Action<LambdaWorkerConfig> configure,
757895
Action<TemporalClientConnectOptions> captureClientOptions) =>

0 commit comments

Comments
 (0)