Skip to content

Commit 7cd5805

Browse files
committed
Add per-invocation Lambda worker configuration
Add LambdaWorker overloads that accept an invocation configurator so handlers can adjust worker options using the AWS Lambda Context before Temporal service stubs, clients, and workers are created. Copy the base options for each invocation, allow required fields such as task queue to be supplied dynamically, and keep invocation-local shutdown hooks from leaking across warm invocations. Run those hooks if the per-invocation configurator or final option validation fails. Document the new overload and cover the lifecycle behavior with unit tests.
1 parent d6a2548 commit 7cd5805

4 files changed

Lines changed: 428 additions & 32 deletions

File tree

contrib/temporal-aws-lambda/README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,21 @@ public final class Handler implements RequestHandler<Object, Void> {
3333

3434
Connection options are loaded with `temporal-envconfig` when the handler is constructed during Lambda cold start. The Lambda worker checks `TEMPORAL_CONFIG_FILE` first, then readable `$LAMBDA_TASK_ROOT/temporal.toml`, then readable `./temporal.toml`, then falls back to the envconfig defaults and Temporal environment variables. The `configure` callback also runs during handler construction, so non-invocation configuration is prepared once and reused.
3535

36+
Use the per-invocation configuration overload when final options depend on the Lambda `Context` or on resources opened for one invocation. The cold-start callback still runs once; the invocation callback runs before Temporal service stubs, client, and worker are created:
37+
38+
```java
39+
private static final RequestHandler<Object, Void> WORKER =
40+
LambdaWorker.run(
41+
new WorkerDeploymentVersion("orders-worker", "2026-06-02"),
42+
builder -> builder.registerWorkflowImplementationTypes(OrderWorkflowImpl.class),
43+
(builder, context) -> {
44+
builder.setTaskQueue(taskQueueFor(context));
45+
builder.addShutdownHook(() -> cleanupInvocationResources(context));
46+
});
47+
```
48+
49+
Each invocation receives a fresh copy of the base options, so mutations and shutdown hooks added by the invocation callback do not leak across warm invocations. If the invocation callback throws after adding shutdown hooks, those hooks still run for cleanup. Reusable resources such as AWS SDK clients should usually be created during cold start and reused across warm invocations.
50+
3651
If you need to assemble options outside the `run` callback, call `LambdaWorkerOptions.newBuilderFromEnvironment()`, configure the returned builder, call `build()`, and pass the options to `LambdaWorker.newHandler(...)`.
3752

3853
Dynamic workflow and activity implementations can be registered with `registerDynamicWorkflowImplementationType(...)` and `registerDynamicActivityImplementation(...)`. Java SDK worker rules still apply: only one dynamic workflow implementation type and one dynamic activity implementation can be registered per worker.

contrib/temporal-aws-lambda/src/main/java/io/temporal/aws/lambda/LambdaWorker.java

Lines changed: 211 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import io.temporal.common.WorkerDeploymentVersion;
66
import java.io.IOException;
77
import java.time.Duration;
8+
import java.util.List;
89
import java.util.Objects;
910
import java.util.function.Consumer;
1011
import org.slf4j.Logger;
@@ -19,6 +20,19 @@ public final class LambdaWorker {
1920

2021
private LambdaWorker() {}
2122

23+
/**
24+
* Configures options for one Lambda invocation before Temporal service stubs, client, and worker
25+
* are created.
26+
*
27+
* <p>The supplied builder is a fresh copy of the base options for the current invocation.
28+
* Shutdown hooks added by this callback run for only that invocation, including when this
29+
* callback throws.
30+
*/
31+
@FunctionalInterface
32+
public interface InvocationConfigurator {
33+
void configure(LambdaWorkerOptions.Builder builder, Context context);
34+
}
35+
2236
/**
2337
* Returns an AWS Lambda Java handler that creates, starts, and shuts down one Temporal worker per
2438
* invocation.
@@ -40,13 +54,57 @@ public static RequestHandler<Object, Void> run(
4054
}
4155
}
4256

57+
/**
58+
* Returns an AWS Lambda Java handler with both cold-start and per-invocation configuration.
59+
*
60+
* @param version worker deployment version to advertise for this worker.
61+
* @param configure callback invoked once while the Lambda handler is constructed.
62+
* @param invocationConfigure callback invoked for each Lambda invocation before Temporal service
63+
* stubs, client, and worker are created. Required fields may be supplied by this callback.
64+
*/
65+
public static RequestHandler<Object, Void> run(
66+
WorkerDeploymentVersion version,
67+
Consumer<LambdaWorkerOptions.Builder> configure,
68+
InvocationConfigurator invocationConfigure) {
69+
LambdaWorkerOptions.validateVersion(version);
70+
Objects.requireNonNull(configure, "configure");
71+
Objects.requireNonNull(invocationConfigure, "invocationConfigure");
72+
try {
73+
LambdaWorkerOptions.Builder builder =
74+
LambdaWorkerOptions.newBuilderFromEnvironment(System.getenv());
75+
configure.accept(builder);
76+
return newHandler(version, builder.build(), invocationConfigure);
77+
} catch (IOException e) {
78+
throw new RuntimeException("Unable to load Temporal client configuration", e);
79+
}
80+
}
81+
4382
/** Returns an AWS Lambda Java handler using already-configured Lambda worker options. */
4483
public static RequestHandler<Object, Void> newHandler(
4584
WorkerDeploymentVersion version, LambdaWorkerOptions options) {
4685
return newHandler(
4786
version, options, new DefaultLambdaWorkerRuntime(), sleep(), systemNanoClock());
4887
}
4988

89+
/**
90+
* Returns an AWS Lambda Java handler using base options and per-invocation configuration.
91+
*
92+
* <p>The supplied options are copied for each invocation before {@code invocationConfigure} runs.
93+
* Required fields may be supplied by {@code invocationConfigure}.
94+
*/
95+
public static RequestHandler<Object, Void> newHandler(
96+
WorkerDeploymentVersion version,
97+
LambdaWorkerOptions options,
98+
InvocationConfigurator invocationConfigure) {
99+
return newHandler(
100+
version,
101+
options,
102+
invocationConfigure,
103+
new DefaultLambdaWorkerRuntime(),
104+
sleep(),
105+
systemNanoClock());
106+
}
107+
50108
static RequestHandler<Object, Void> newHandler(
51109
WorkerDeploymentVersion version,
52110
LambdaWorkerOptions options,
@@ -56,6 +114,16 @@ static RequestHandler<Object, Void> newHandler(
56114
return newHandler(version, options, runtime, sleeper, systemNanoClock());
57115
}
58116

117+
static RequestHandler<Object, Void> newHandler(
118+
WorkerDeploymentVersion version,
119+
LambdaWorkerOptions options,
120+
InvocationConfigurator invocationConfigure,
121+
LambdaWorkerRuntime runtime,
122+
Sleeper sleeper) {
123+
// This overload exists to let tests inject a fake runtime and sleeper.
124+
return newHandler(version, options, invocationConfigure, runtime, sleeper, systemNanoClock());
125+
}
126+
59127
static RequestHandler<Object, Void> newHandler(
60128
WorkerDeploymentVersion version,
61129
LambdaWorkerOptions options,
@@ -67,7 +135,34 @@ static RequestHandler<Object, Void> newHandler(
67135
Objects.requireNonNull(options, "options").prepare(version),
68136
Objects.requireNonNull(runtime, "runtime"),
69137
Objects.requireNonNull(sleeper, "sleeper"),
70-
Objects.requireNonNull(clock, "clock"));
138+
Objects.requireNonNull(clock, "clock"),
139+
false);
140+
}
141+
142+
static RequestHandler<Object, Void> newHandler(
143+
WorkerDeploymentVersion version,
144+
LambdaWorkerOptions options,
145+
InvocationConfigurator invocationConfigure,
146+
LambdaWorkerRuntime runtime,
147+
Sleeper sleeper,
148+
NanoClock clock) {
149+
LambdaWorkerOptions.validateVersion(version);
150+
Objects.requireNonNull(options, "options");
151+
Objects.requireNonNull(invocationConfigure, "invocationConfigure");
152+
return new Handler(
153+
context -> {
154+
LambdaWorkerOptions.Builder builder = options.toBuilder();
155+
try {
156+
invocationConfigure.configure(builder, context);
157+
return builder.build().prepare(version).materialize(identityFor(context));
158+
} catch (RuntimeException e) {
159+
throw new InvocationConfigurationException(e, builder);
160+
}
161+
},
162+
Objects.requireNonNull(runtime, "runtime"),
163+
Objects.requireNonNull(sleeper, "sleeper"),
164+
Objects.requireNonNull(clock, "clock"),
165+
true);
71166
}
72167

73168
private static Sleeper sleep() {
@@ -86,32 +181,73 @@ private static NanoClock systemNanoClock() {
86181
return System::nanoTime;
87182
}
88183

184+
private static String identityFor(Context context) {
185+
return emptyToUnknown(context.getAwsRequestId())
186+
+ "@"
187+
+ emptyToUnknown(context.getInvokedFunctionArn());
188+
}
189+
190+
private static String emptyToUnknown(String value) {
191+
return value == null || value.isEmpty() ? "unknown" : value;
192+
}
193+
194+
private interface OptionsMaterializer {
195+
LambdaWorkerOptions.Materialized materialize(Context context);
196+
}
197+
89198
private static final class Handler implements RequestHandler<Object, Void> {
90-
private final LambdaWorkerOptions.Prepared preparedOptions;
199+
private final OptionsMaterializer optionsMaterializer;
91200
private final LambdaWorkerRuntime runtime;
92201
private final Sleeper sleeper;
93202
private final NanoClock clock;
203+
private final boolean runShutdownHooksBeforeRuntimeCreation;
94204

95205
private Handler(
96206
LambdaWorkerOptions.Prepared preparedOptions,
97207
LambdaWorkerRuntime runtime,
98208
Sleeper sleeper,
209+
NanoClock clock,
210+
boolean runShutdownHooksBeforeRuntimeCreation) {
211+
this(
212+
context -> preparedOptions.materialize(identityFor(context)),
213+
runtime,
214+
sleeper,
215+
clock,
216+
runShutdownHooksBeforeRuntimeCreation);
217+
Objects.requireNonNull(preparedOptions, "preparedOptions");
218+
}
219+
220+
private Handler(
221+
OptionsMaterializer optionsMaterializer,
222+
LambdaWorkerRuntime runtime,
223+
Sleeper sleeper,
99224
NanoClock clock) {
100-
this.preparedOptions = Objects.requireNonNull(preparedOptions, "preparedOptions");
225+
this(optionsMaterializer, runtime, sleeper, clock, false);
226+
}
227+
228+
private Handler(
229+
OptionsMaterializer optionsMaterializer,
230+
LambdaWorkerRuntime runtime,
231+
Sleeper sleeper,
232+
NanoClock clock,
233+
boolean runShutdownHooksBeforeRuntimeCreation) {
234+
this.optionsMaterializer = Objects.requireNonNull(optionsMaterializer, "optionsMaterializer");
101235
this.runtime = runtime;
102236
this.sleeper = sleeper;
103237
this.clock = clock;
238+
this.runShutdownHooksBeforeRuntimeCreation = runShutdownHooksBeforeRuntimeCreation;
104239
}
105240

106241
@Override
107242
public Void handleRequest(Object input, Context context) {
108243
Objects.requireNonNull(context, "context");
109244

110-
LambdaWorkerOptions.Materialized options = preparedOptions.materialize(identityFor(context));
111-
validateRemainingTime(context, options.shutdownDeadlineBuffer);
112-
245+
LambdaWorkerOptions.Materialized options = null;
113246
LambdaWorkerRuntime.Invocation invocation = null;
114247
try {
248+
options = optionsMaterializer.materialize(context);
249+
validateRemainingTime(context, options.shutdownDeadlineBuffer);
250+
115251
invocation =
116252
runtime.create(
117253
options.serviceStubsOptions,
@@ -134,8 +270,26 @@ public Void handleRequest(Object input, Context context) {
134270

135271
sleepUntilShutdownWindow(context, options);
136272
return null;
273+
} catch (InvocationConfigurationException e) {
274+
runShutdownHooks(
275+
context,
276+
e.taskQueue,
277+
e.shutdownHooks,
278+
cleanupDeadlineNanos(context, e.gracefulShutdownTimeout, e.shutdownDeadlineBuffer));
279+
throw e.failure;
280+
} catch (RuntimeException e) {
281+
if (invocation == null && options != null && runShutdownHooksBeforeRuntimeCreation) {
282+
runShutdownHooks(
283+
context,
284+
options.taskQueue,
285+
options.shutdownHooks,
286+
cleanupDeadlineNanos(context, options));
287+
}
288+
throw e;
137289
} finally {
138-
shutdownInvocation(context, invocation, options);
290+
if (invocation != null) {
291+
shutdownInvocation(context, invocation, options);
292+
}
139293
}
140294
}
141295

@@ -191,17 +345,15 @@ private void shutdownInvocation(
191345
}
192346
runShutdownHooks(context, options, cleanupDeadlineNanos.longValue());
193347

194-
if (invocation != null) {
195-
try {
196-
invocation.closeStubs(remainingCleanupTime(cleanupDeadlineNanos.longValue()));
197-
} catch (RuntimeException e) {
198-
log.error(
199-
"Temporal Lambda worker service stubs close failed awsRequestId={} invokedFunctionArn={} taskQueue={}",
200-
context.getAwsRequestId(),
201-
context.getInvokedFunctionArn(),
202-
options.taskQueue,
203-
e);
204-
}
348+
try {
349+
invocation.closeStubs(remainingCleanupTime(cleanupDeadlineNanos.longValue()));
350+
} catch (RuntimeException e) {
351+
log.error(
352+
"Temporal Lambda worker service stubs close failed awsRequestId={} invokedFunctionArn={} taskQueue={}",
353+
context.getAwsRequestId(),
354+
context.getInvokedFunctionArn(),
355+
options.taskQueue,
356+
e);
205357
}
206358
}
207359

@@ -267,7 +419,15 @@ private boolean isTerminated(
267419

268420
private void runShutdownHooks(
269421
Context context, LambdaWorkerOptions.Materialized options, long cleanupDeadlineNanos) {
270-
for (Runnable hook : options.shutdownHooks) {
422+
runShutdownHooks(context, options.taskQueue, options.shutdownHooks, cleanupDeadlineNanos);
423+
}
424+
425+
private void runShutdownHooks(
426+
Context context,
427+
String taskQueue,
428+
List<Runnable> shutdownHooks,
429+
long cleanupDeadlineNanos) {
430+
for (Runnable hook : shutdownHooks) {
271431
try {
272432
if (hook instanceof TimedShutdownHook) {
273433
((TimedShutdownHook) hook).run(remainingCleanupTime(cleanupDeadlineNanos));
@@ -279,7 +439,7 @@ private void runShutdownHooks(
279439
"Temporal Lambda worker shutdown hook failed awsRequestId={} invokedFunctionArn={} taskQueue={}",
280440
context.getAwsRequestId(),
281441
context.getInvokedFunctionArn(),
282-
options.taskQueue,
442+
taskQueue,
283443
e);
284444
}
285445
}
@@ -311,12 +471,25 @@ private void validateRemainingTime(Context context, Duration shutdownDeadlineBuf
311471
}
312472

313473
private long cleanupDeadlineNanos(Context context, LambdaWorkerOptions.Materialized options) {
314-
return clock.nanoTime() + cleanupWindow(context, options).toNanos();
474+
return cleanupDeadlineNanos(
475+
context, options.gracefulShutdownTimeout, options.shutdownDeadlineBuffer);
476+
}
477+
478+
private long cleanupDeadlineNanos(
479+
Context context, Duration gracefulShutdownTimeout, Duration shutdownDeadlineBuffer) {
480+
return clock.nanoTime()
481+
+ cleanupWindow(context, gracefulShutdownTimeout, shutdownDeadlineBuffer).toNanos();
315482
}
316483

317484
private Duration cleanupWindow(Context context, LambdaWorkerOptions.Materialized options) {
485+
return cleanupWindow(
486+
context, options.gracefulShutdownTimeout, options.shutdownDeadlineBuffer);
487+
}
488+
489+
private Duration cleanupWindow(
490+
Context context, Duration gracefulShutdownTimeout, Duration shutdownDeadlineBuffer) {
318491
Duration configuredWindow =
319-
nonNegative(options.shutdownDeadlineBuffer.minus(options.gracefulShutdownTimeout));
492+
nonNegative(shutdownDeadlineBuffer.minus(gracefulShutdownTimeout));
320493
Duration remaining = remainingInvocationTime(context);
321494
return configuredWindow.compareTo(remaining) <= 0 ? configuredWindow : remaining;
322495
}
@@ -332,15 +505,23 @@ private Duration remainingInvocationTime(Context context) {
332505
private static Duration nonNegative(Duration duration) {
333506
return duration.isNegative() ? Duration.ZERO : duration;
334507
}
508+
}
335509

336-
private static String identityFor(Context context) {
337-
return emptyToUnknown(context.getAwsRequestId())
338-
+ "@"
339-
+ emptyToUnknown(context.getInvokedFunctionArn());
340-
}
341-
342-
private static String emptyToUnknown(String value) {
343-
return value == null || value.isEmpty() ? "unknown" : value;
510+
private static final class InvocationConfigurationException extends RuntimeException {
511+
private final RuntimeException failure;
512+
private final String taskQueue;
513+
private final Duration gracefulShutdownTimeout;
514+
private final Duration shutdownDeadlineBuffer;
515+
private final List<Runnable> shutdownHooks;
516+
517+
private InvocationConfigurationException(
518+
RuntimeException failure, LambdaWorkerOptions.Builder builder) {
519+
super(failure);
520+
this.failure = failure;
521+
this.taskQueue = builder.getTaskQueue();
522+
this.gracefulShutdownTimeout = builder.getGracefulShutdownTimeout();
523+
this.shutdownDeadlineBuffer = builder.getShutdownDeadlineBuffer();
524+
this.shutdownHooks = builder.getShutdownHooks();
344525
}
345526
}
346527
}

0 commit comments

Comments
 (0)