Skip to content

Commit e00c900

Browse files
author
Frank Chen
authored
feat: add per-invocation executor isolation
1 parent fdc6f40 commit e00c900

15 files changed

Lines changed: 411 additions & 82 deletions

File tree

‎docs/advanced/configuration.md‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,22 @@ public class OrderProcessor extends DurableHandler<Order, OrderResult> {
3434
| `withLambdaClientBuilder()` | Custom AWS Lambda client | Auto-configured Lambda client |
3535
| `withSerDes()` | Serializer for step results | Jackson with default settings |
3636
| `withExecutorService()` | Thread pool for user-defined operations | Cached daemon thread pool |
37+
| `withInvocationExecutorFactory()` | Factory for isolated per-invocation user executors | Disabled |
3738
| `withLoggerConfig()` | Logger behavior configuration | Suppress logs during replay |
3839
| `withPollingStrategy()` | Backend polling strategy | Exponential backoff: 1s base, 2x rate, FULL jitter, 10s max |
3940
| `withCheckpointDelay()` | How often the SDK checkpoints updates | `Duration.ofSeconds(0)` (as soon as possible) |
4041

41-
The `withExecutorService()` option configures the thread pool used for running user-defined operations. Internal SDK coordination (checkpoint batching, polling) runs on an SDK-managed thread pool.
42+
The `withExecutorService()` option configures a caller-owned executor shared by every invocation that uses the configuration. The SDK never shuts it down. To isolate concurrent Lambda Managed Instances invocations, configure a factory instead:
43+
44+
```java
45+
DurableConfig.builder()
46+
.withInvocationExecutorFactory(() -> Executors.newFixedThreadPool(4))
47+
.build();
48+
```
49+
50+
The factory is called once per invocation and may be called concurrently on Lambda Managed Instances. It must return a fresh executor, which the SDK drains and shuts down before returning the invocation response. Returning an executor already leased to an active invocation is rejected. The factory and `withExecutorService()` are mutually exclusive. Both options run all user code, including the root handler, steps, and child contexts. Internal SDK coordination such as checkpoint batching and polling remains on the SDK-managed executor.
51+
52+
A bounded executor supplied through `withExecutorService()` remains shared. On Lambda Managed Instances, concurrent roots can occupy all of its workers while waiting for queued steps. Use the invocation factory when capacity must be isolated between native invocations. Isolation does not define the capacity required by arbitrarily nested synchronous child contexts; size or design those separately.
4253

4354
### Dynamic plugin loading
4455

‎docs/design.md‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ public class MyHandler extends DurableHandler<Input, Output> {
145145
| `lambdaClientBuilder` | Auto-created `LambdaClient` for current region, primed for performance (see [`DurableConfig.java`](../sdk/src/main/java/software/amazon/lambda/durable/DurableConfig.java)) |
146146
| `serDes` | `JacksonSerDes` |
147147
| `executorService` | `Executors.newCachedThreadPool()` (for user-defined operations only) |
148+
| `invocationExecutorFactory` | Disabled; when configured, creates an SDK-owned executor for each invocation |
148149
| `loggerConfig` | `LoggerConfig.defaults()` (suppress replay logs) |
149150
| `pollingStrategy` | Exponential backoff: 1s base, 2x rate, FULL jitter, 10s max |
150151
| `checkpointDelay` | `Duration.ofSeconds(0)` (checkpoint as soon as possible) |
@@ -154,10 +155,17 @@ public class MyHandler extends DurableHandler<Input, Output> {
154155
The SDK uses two separate thread pools with distinct responsibilities:
155156

156157
**User Executor (`DurableConfig.executorService`):**
157-
- Runs user-defined operations (the code passed to `ctx.step()` and `ctx.stepAsync()`)
158+
- Runs the root handler, steps, and child-context user code
158159
- Configurable via `DurableConfig.builder().withExecutorService()`
160+
- Caller-owned and shared across invocations; the SDK never shuts it down
159161
- Default: cached daemon thread pool
160162

163+
**Invocation Executor Factory (`DurableConfig.invocationExecutorFactory`):**
164+
- Optional alternative to the shared user executor
165+
- Creates one executor per invocation for all root, step, and child-context user code
166+
- SDK-owned: drained and shut down before the invocation response returns
167+
- Must return a fresh executor and cannot be combined with `withExecutorService()`
168+
161169
**Internal Executor (`InternalExecutor.INSTANCE`):**
162170
- Runs SDK coordination tasks: checkpoint batching, polling for wait completion
163171
- Dedicated cached thread pool with daemon threads named `durable-sdk-internal-*`

‎lmi-tests/README.md‎

Lines changed: 15 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ or accept a retry that happens to pass after a lifecycle violation.
1717
SDK's minimum JDK prevents fixture-only changes from introducing newer JDK APIs
1818
into the shared Maven reactor. On manual dispatch, a separate
1919
serialized cloud job builds the selected commit, deploys all five
20-
fixture functions, runs all 13 cases, then collects evidence. A persistent
20+
fixture functions, runs all 12 gating cases, then collects evidence. A persistent
2121
CloudFormation stack owns all five
2222
functions and log groups. The stack, functions and bucket remain after both
2323
successful and failed test runs; later deployments update the same resources. Each uses 2 GiB / 1 vCPU and a single
@@ -50,17 +50,14 @@ or accept a retry that happens to pass after a lifecycle violation.
5050
Each real checkpoint/poll call has a request-local call ID paired with its
5151
`finally` exit. Missing or mismatched exits prevent cleanup from reusing the
5252
fixture, and an exit after wrapper return remains a lifecycle failure.
53-
* A bounded same-JVM root barrier establishes the fixed-pool reproduction.
54-
`fixed2` uses a shared two-thread pool so two roots starve their first steps.
55-
`nested2` uses four threads and a separate cohort-scoped child barrier. Both
56-
child handlers must enter before either schedules nested map/parallel work.
57-
The child barrier has an independent 8-second setup budget; the nested progress
58-
budget starts when it releases, not when the roots passed their earlier barrier.
59-
A broken child barrier fails setup without growing the pool; an escape after
60-
successful child admission still fails the starvation assertion.
61-
Other cases hold steps with private S3 control objects. The driver requires
62-
distinct request IDs active in the same JVM. Placement has its own deadline
63-
and failure category. Environment replacement is never worker recovery.
53+
* A bounded same-JVM root barrier verifies that `fixed2` gives each invocation
54+
its own two-thread executor. `nested2` is retained in the persistent stack for
55+
baseline diagnostics but does not produce a gating LMI case. It uses four
56+
shared threads and a separate cohort-scoped child barrier before scheduling
57+
nested map/parallel work. Other cases hold steps with private S3 control
58+
objects. The driver requires distinct request IDs active in the same JVM.
59+
Placement has its own deadline and failure category. Environment replacement
60+
is never worker recovery.
6461
* Timeout victims start before healthy peers, leaving the peers time to hold
6562
the other runtime slots during recovery. Diagnostics capture the real context
6663
deadline; no fake clock, context, checkpoint backend, or time-skipping runner
@@ -146,8 +143,9 @@ python3 lmi-tests/cloud_suite.py collect
146143
```
147144

148145
The deploy command creates or updates and retains `default1`, `default2`, `default8`,
149-
`fixed2`, and `nested2` together. The test command requires all five and runs the
150-
full suite. CI publishes one combined artifact, named `lmi-e2e-RUN-ATTEMPT`, with
146+
`fixed2`, and `nested2` together. The test command requires all five and runs 12
147+
gating cases; `nested2` is retained but excluded. CI publishes one combined
148+
artifact, named `lmi-e2e-RUN-ATTEMPT`, with
151149
the single `template.json` and per-function configuration snapshots. The
152150
CloudFormation execution role needs permission to manage the function scaling
153151
configuration; the driver calls `lambda:GetFunctionScalingConfig` to verify it
@@ -204,8 +202,9 @@ wrapper entry and return. Healthy/probe scheduling
204202
uses the actual runtime deadline, excluding cold-start and request-queue delay.
205203
A successful durable retry cannot erase an old invocation
206204
that exceeds the cleanup budget. No virtual-thread executor variant is deployed
207-
until its executor contract is defined; default cached and shared fixed pools
208-
are covered separately.
205+
until its executor contract is defined. The cloud suite gates default cached and
206+
invocation-local fixed pools; the shared fixed nesting fixture remains available
207+
as a non-gating baseline diagnostic.
209208

210209
CloudFormation's native [FunctionScalingConfig](https://docs.aws.amazon.com/AWSCloudFormation/latest/TemplateReference/aws-properties-lambda-function-functionscalingconfig.html)
211210
sets the limits in the resource declaration. LMI provisioning and version behavior

‎lmi-tests/cloud_suite.py‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
OWNER = "java-sdk-lmi-e2e"
2626
DEFAULT_STACK = "java-lmi-e2e"
2727
FIXTURES = {"default1": (1, "default"), "default2": (2, "default"),
28-
"default8": (8, "default"), "fixed2": (2, "fixed"), "nested2": (2, "nested")}
28+
"default8": (8, "default"), "fixed2": (2, "invocation-fixed"), "nested2": (2, "nested")}
2929
PEER_LOG_OBSERVATION_SECONDS = 20
3030
PEER_PLACEMENT_SECONDS = 60
3131

@@ -201,7 +201,7 @@ def deploy_fixtures(manifest, tags, seconds=1800):
201201
wait_stack(manifest["stack"], expected, remaining_budget(deadline, seconds))
202202
stack = existing_stack(manifest["stack"])
203203
outputs = {output["OutputKey"]: output["OutputValue"] for output in stack["Outputs"]}
204-
require(set(outputs) == set(FIXTURES), "The stack must expose all five LMI fixtures")
204+
require(set(outputs) == set(FIXTURES), "The stack must expose all required LMI fixtures")
205205
for fixture in FIXTURES:
206206
arn = outputs[fixture]
207207
record_fixture(manifest, fixture, arn)
@@ -729,10 +729,10 @@ def warm_case(cloud, fixture):
729729
def cases_for_fixture(cloud, fixture):
730730
if fixture == "default1":
731731
return [("baseline-concurrency1", lambda: replay_case(cloud, fixture, "baseline"))]
732-
if fixture in {"fixed2", "nested2"}:
733-
scenario = "fixed" if fixture == "fixed2" else "nested"
734-
return [("fixed-two-roots" if fixture == "fixed2" else "fixed-nested-map-parallel",
735-
lambda: fixed_case(cloud, fixture, scenario))]
732+
if fixture == "fixed2":
733+
return [("isolated-fixed-two-roots", lambda: fixed_case(cloud, fixture, "fixed"))]
734+
if fixture == "nested2":
735+
return []
736736
cases = [(fixture + "-isolation", lambda: overlap_case(cloud, fixture)),
737737
(fixture + "-suspend-cleanup-replay", lambda: replay_case(cloud, fixture, "suspend")),
738738
(fixture + "-timeout-recovery", lambda: timeout_case(cloud, fixture))]
@@ -763,7 +763,7 @@ def execute_case(cloud, fixture, case):
763763
def run_tests():
764764
manifest = json.loads(MANIFEST.read_text())
765765
if set(manifest["functions"]) != set(FIXTURES):
766-
raise PreconditionError("All five functions must be deployed before running the cloud suite")
766+
raise PreconditionError("All required functions must be deployed before running the cloud suite")
767767
cloud = Cloud(manifest, ARTIFACTS)
768768
suite = ET.Element("testsuite", name="LMI cloud lifecycle")
769769
cases = [(fixture, name, case) for fixture in FIXTURES

‎lmi-tests/cloud_support.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,7 @@ def assert_fixed(events, markers, progress_start="BARRIER_PASSED"):
209209
require(len({e["requestId"] for e in entered}) == len(markers), "Missing distinct runtime invocations")
210210
require(max(e["sequence"] for e in entered) < min(e["sequence"] for e in passed),
211211
"Root invocations did not overlap at the barrier")
212-
require(not selected(events, "ESCAPE"), "Shared fixed executor starved its own queued work")
212+
require(not selected(events, "ESCAPE"), "Fixed executor starved its own queued work")
213213
# Child admission is one cohort-wide phase. A participant scheduled later must
214214
# not get a fresh progress budget after its peer has already left the barrier.
215215
cohort_start = min((e["nanos"] for e in selected(events, progress_start)), default=None)
@@ -218,7 +218,7 @@ def assert_fixed(events, markers, progress_start="BARRIER_PASSED"):
218218
start = selected(events, progress_start, marker)
219219
started = cohort_start if progress_start == "CHILD_BARRIER_PASSED" else (start[0]["nanos"] if start else None)
220220
require(start and progress and 0 <= (progress[-1]["nanos"] - started) < 8_000_000_000,
221-
"Shared executor did not progress within budget")
221+
"Fixed executor did not progress within budget")
222222

223223

224224
def assert_nested_reached(events, markers):

‎lmi-tests/src/main/java/software/amazon/lambda/durable/lmi/LifecycleHandler.java‎

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import java.util.concurrent.ThreadPoolExecutor;
2626
import java.util.concurrent.TimeUnit;
2727
import java.util.concurrent.TimeoutException;
28+
import java.util.concurrent.atomic.AtomicReference;
2829
import software.amazon.awssdk.services.lambda.model.OperationType;
2930
import software.amazon.lambda.durable.DurableConfig;
3031
import software.amazon.lambda.durable.DurableContext;
@@ -75,27 +76,42 @@ public void handleRequest(InputStream source, OutputStream destination, Context
7576
var input = WIRE.deserialize(
7677
new String(source.readAllBytes(), StandardCharsets.UTF_8), TypeToken.get(DurableExecutionInput.class));
7778
var trace = new InvocationTrace(readUserInput(input), runtime, input.durableExecutionArn());
79+
var invocationExecutor = new AtomicReference<ThreadPoolExecutor>();
7880
trace.wrapperEnter();
7981
var status = "THREW";
8082
try {
81-
var config = DurableConfig.builder()
82-
.withExecutorService(shared.getExecutorService())
83-
.withDurableExecutionClient(new ObservedClient(shared.getDurableExecutionClient(), trace))
84-
.build();
83+
var configBuilder = DurableConfig.builder()
84+
.withDurableExecutionClient(new ObservedClient(shared.getDurableExecutionClient(), trace));
85+
if ("invocation-fixed".equals(System.getenv("LMI_EXECUTOR"))) {
86+
configBuilder.withInvocationExecutorFactory(() -> {
87+
var executor = (ThreadPoolExecutor) Executors.newFixedThreadPool(2);
88+
invocationExecutor.set(executor);
89+
return executor;
90+
});
91+
} else {
92+
configBuilder.withExecutorService(shared.getExecutorService());
93+
}
94+
var config = configBuilder.build();
8595
var response = DurableExecutor.execute(
8696
input,
8797
runtime,
8898
TypeToken.get(FixtureInput.class),
89-
(value, context) -> handle(value, context, trace),
99+
(value, context) -> handle(value, context, trace, executor(invocationExecutor)),
90100
config);
91101
destination.write(WIRE.serialize(response).getBytes(StandardCharsets.UTF_8));
92102
status = response.status().name();
93103
} finally {
94-
trace.snapshot((ThreadPoolExecutor) shared.getExecutorService());
104+
trace.snapshot(executor(invocationExecutor));
95105
trace.wrapperExit(status);
96106
}
97107
}
98108

109+
private ThreadPoolExecutor executor(AtomicReference<ThreadPoolExecutor> invocationExecutor) {
110+
return invocationExecutor.get() != null
111+
? invocationExecutor.get()
112+
: (ThreadPoolExecutor) shared.getExecutorService();
113+
}
114+
99115
private static FixtureInput readUserInput(DurableExecutionInput input) {
100116
var operation = input.initialExecutionState().operations().stream()
101117
.filter(op -> op.type() == OperationType.EXECUTION)
@@ -104,7 +120,8 @@ private static FixtureInput readUserInput(DurableExecutionInput input) {
104120
return JSON.deserialize(operation.executionDetails().inputPayload(), TypeToken.get(FixtureInput.class));
105121
}
106122

107-
String handle(FixtureInput input, DurableContext context, InvocationTrace trace) {
123+
String handle(
124+
FixtureInput input, DurableContext context, InvocationTrace trace, ThreadPoolExecutor invocationExecutor) {
108125
trace.rootEnter();
109126
try {
110127
if (!context.isReplaying()
@@ -113,7 +130,7 @@ String handle(FixtureInput input, DurableContext context, InvocationTrace trace)
113130
trace.event("PLACEMENT_MISS");
114131
return "PLACEMENT_MISS";
115132
}
116-
return runScenario(input, context, trace);
133+
return runScenario(input, context, trace, invocationExecutor);
117134
} finally {
118135
if ("suspend".equals(input.scenario())) {
119136
trace.event("CLEANUP_ENTER");
@@ -124,13 +141,14 @@ String handle(FixtureInput input, DurableContext context, InvocationTrace trace)
124141
}
125142
}
126143

127-
private String runScenario(FixtureInput input, DurableContext context, InvocationTrace trace) {
144+
private String runScenario(
145+
FixtureInput input, DurableContext context, InvocationTrace trace, ThreadPoolExecutor invocationExecutor) {
128146
return switch (input.scenario()) {
129147
case "baseline", "replay", "suspend" -> replay(input, context, trace);
130148
case "hold", "probe" -> context.step("held-step", String.class, step -> hold(input, trace), NO_RETRY);
131149
case "timeout", "failure-inflight", "return-inflight" -> inFlight(input, context, trace);
132150
case "stubborn" -> stubborn(input, context, trace);
133-
case "fixed", "nested" -> fixed(input, context, trace);
151+
case "fixed", "nested" -> fixed(input, context, trace, invocationExecutor);
134152
case "success" -> context.step("success", String.class, step -> body(trace, "success", input.marker()));
135153
case "failure" -> context.step("failure", String.class, step -> fail(trace, input.marker()), NO_RETRY);
136154
default -> throw new IllegalArgumentException("Unknown fixture scenario");
@@ -226,8 +244,7 @@ private String stubborn(FixtureInput input, DurableContext context, InvocationTr
226244
return input.marker();
227245
}
228246

229-
private String fixed(FixtureInput input, DurableContext context, InvocationTrace trace) {
230-
var pool = (ThreadPoolExecutor) shared.getExecutorService();
247+
private String fixed(FixtureInput input, DurableContext context, InvocationTrace trace, ThreadPoolExecutor pool) {
231248
awaitRootBarrier(input, trace);
232249
if ("nested".equals(input.scenario())) {
233250
var value = context.step("success", String.class, step -> body(trace, "success", input.marker()));

0 commit comments

Comments
 (0)