Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
62 commits
Select commit Hold shift + click to select a range
e00c900
feat: add per-invocation executor isolation
Sep 25, 2026
7f8b0ad
fix: align invocation executors with lifecycle
zhongkechen Oct 16, 2026
4e3863f
fix: retain updated lifecycle failure controls
zhongkechen Oct 16, 2026
b5a6bf0
fix: preserve admitted work on interrupted drain
zhongkechen Oct 16, 2026
14d8fca
test: observe budgeted lifecycle waits
zhongkechen Oct 16, 2026
f0b0173
fix: include atomic task cancellation arbitration
zhongkechen Oct 17, 2026
99a1205
test: retain observed admission contention state
zhongkechen Oct 17, 2026
b6b9767
test: include verified fixed-pool evidence handling
zhongkechen Oct 17, 2026
28d8bd6
chore: include final reviewed lifecycle base
zhongkechen Oct 17, 2026
bd0b057
test: include final healthy-request evidence checks
zhongkechen Oct 17, 2026
4968c9f
test: include bounded residual-work settlement budget
zhongkechen Oct 17, 2026
e193729
test: include final per-request lifecycle acceptance
zhongkechen Oct 17, 2026
11b1791
test: include final immutable evidence identity checks
zhongkechen Oct 17, 2026
b510ba8
Merge updated deadline lifecycle and current main into invocation exe…
zhongkechen Oct 20, 2026
df4b1ac
test: merge complete pinned warm snapshot checks
zhongkechen Oct 20, 2026
5cd59d8
test: verify invocation executor routing for nested operations
zhongkechen Oct 21, 2026
ea88116
Merge Java 3 lifecycle into invocation-owned executors
zhongkechen Oct 21, 2026
9af04ef
Merge final delivery failure controls into owned executors
zhongkechen Oct 21, 2026
87ce5c8
Merge validated cancellation and scope cutoff fixes
zhongkechen Oct 21, 2026
decdc1a
test: retain first fatal through owned executor shutdown
zhongkechen Oct 21, 2026
7123865
Preserve fatal outcomes through owned executor shutdown
zhongkechen Oct 21, 2026
aa2057b
Merge preserved fatal owner cleanup semantics
zhongkechen Oct 21, 2026
aba5d09
test: preserve pre-dispatch fatal during owned close
zhongkechen Oct 21, 2026
293e34d
Distinguish explicit close failure observations during setup
zhongkechen Oct 21, 2026
3e26032
test: preserve wrapped owned shutdown fatal priority
zhongkechen Oct 21, 2026
24ded16
Recognize wrapped JVM fatals before owned cleanup classification
zhongkechen Oct 21, 2026
f9e53a4
Merge reviewed invocation admission and terminal preparation fixes
zhongkechen Oct 21, 2026
2a4d493
test: finalize owned executors after admission rejection
zhongkechen Oct 21, 2026
fe1d663
Preserve owned shutdown failures across lease-state query errors
zhongkechen Oct 21, 2026
8bfaab6
Merge lifecycle fencing around draining registration
zhongkechen Oct 21, 2026
b6482f9
Merge canonical lifecycle failure selection during cleanup
zhongkechen Oct 21, 2026
f59de04
test: retain first lifecycle failure after owned termination timeout
zhongkechen Oct 21, 2026
f9a2b5f
Retain latched lifecycle failure through owned termination timeout
zhongkechen Oct 21, 2026
e42bf85
Clean up newly leased executors when initialization fails
zhongkechen Oct 21, 2026
ca80a56
Merge owner-drain cancellation identity into executor initialization …
zhongkechen Oct 21, 2026
4e73334
Force owned executor cleanup when graceful shutdown throws
zhongkechen Oct 21, 2026
4e60115
test: cover post-manager setup and cleanup failure priorities
zhongkechen Oct 21, 2026
61e1971
Finalize post-manager setup failures before automatic close
zhongkechen Oct 21, 2026
d8ef9ac
Combine owned executor fallback and setup failure finalization
zhongkechen Oct 21, 2026
52a25cc
test: cover owned lease release after confirmed termination
zhongkechen Oct 21, 2026
22aa7e8
Release owned executor lease when termination is confirmed
zhongkechen Oct 21, 2026
a4a7db0
test: cover MDC restoration fatal with later owned cleanup failure
zhongkechen Oct 21, 2026
3711775
Retain root completion fatal through owned executor cleanup
zhongkechen Oct 21, 2026
1291921
test: retain adjacent MDC failure diagnostics without duplication
zhongkechen Oct 21, 2026
bccc5b0
Retain each completion-only fatal diagnostic once
zhongkechen Oct 21, 2026
dbb0521
Test completion diagnostics across ACK and caller cutoff
zhongkechen Oct 21, 2026
cd2cbd5
test: retain serializer failure under owned cleanup failures
zhongkechen Oct 21, 2026
fd12e9a
Cover repeated serializer error under caller cutoff cleanup
zhongkechen Oct 21, 2026
ee70d9f
Retain completion diagnostics on the existing cleanup failure winner
zhongkechen Oct 21, 2026
d2daa01
test: cover recorded fatal cleanup diagnostics and alias cycles
zhongkechen Oct 21, 2026
55f31ef
Keep recorded fatal cleanup diagnostics without transport alias cycles
zhongkechen Oct 21, 2026
f93515f
test: cover prior fatal and wrapped executor callback diagnostics
zhongkechen Oct 21, 2026
087d303
Preserve selected fatal and wrapper diagnostics across owned callbacks
zhongkechen Oct 21, 2026
df6fcb8
chore: merge upstream 3.0.0 release versions
zhongkechen Oct 21, 2026
80ef290
chore: merge upstream trace-header documentation
zhongkechen Oct 21, 2026
9434cf5
test: cover setup wrapper diagnostics and single-pass inspection
zhongkechen Oct 21, 2026
c4ee406
fix: retain fatal setup wrapper diagnostics during finalization
zhongkechen Oct 21, 2026
7ddc417
test: retain nonfatal setup and application wrapper diagnostics
zhongkechen Oct 21, 2026
01087ee
fix: preserve diagnostics from discarded nonfatal setup wrappers
zhongkechen Oct 21, 2026
2b1ec75
test: preserve suppressed fatal diagnostics without promoting them
zhongkechen Oct 21, 2026
48c4504
fix: retain wrapper diagnostics without selecting a new failure
zhongkechen Oct 21, 2026
dc14e3a
test: await continuation observation after asserting actual exit
zhongkechen Oct 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion docs/advanced/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,22 @@ public class OrderProcessor extends DurableHandler<Order, OrderResult> {
| `withLambdaClientBuilder()` | Custom AWS Lambda client | Auto-configured Lambda client |
| `withSerDes()` | Serializer for step results | Jackson with default settings |
| `withExecutorService()` | Thread pool for user-defined operations | Cached daemon thread pool |
| `withInvocationExecutorFactory()` | Factory for isolated per-invocation user executors | Disabled |
| `withLoggerConfig()` | Logger behavior configuration | Suppress logs during replay |
| `withPollingStrategy()` | Backend polling strategy | Exponential backoff: 1s base, 2x rate, FULL jitter, 10s max |
| `withCheckpointDelay()` | How often the SDK checkpoints updates | `Duration.ofSeconds(0)` (as soon as possible) |

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.
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:

```java
DurableConfig.builder()
.withInvocationExecutorFactory(() -> Executors.newFixedThreadPool(4))
.build();
```

The factory is called once per invocation and may be called concurrently on Lambda Managed Instances. It must return a fresh executor. The invocation caller waits for actual admitted work and End before shutting that executor down, including after cancellation. Code that ignores interruption can delay this shutdown beyond the policy budget; executor termination is then observed with any cleanup time that remains. 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 plugin-enabled root handlers, steps, and child contexts. Internal SDK coordination such as checkpoint batching and polling remains on the SDK-managed executor. `toBuilder()` and the local test runner preserve the factory, including across replay/resume.

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.

### Dynamic plugin loading

Expand Down
8 changes: 8 additions & 0 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ public class MyHandler extends DurableHandler<Input, Output> {
| `lambdaClientBuilder` | Auto-created `LambdaClient` for current region, primed for performance (see [`DurableConfig.java`](../sdk/src/main/java/software/amazon/lambda/durable/DurableConfig.java)) |
| `serDes` | `JacksonSerDes` |
| `executorService` | `Executors.newCachedThreadPool()` (for user-defined operations only) |
| `invocationExecutorFactory` | Disabled; when configured, creates an SDK-owned executor for each invocation |
| `loggerConfig` | `LoggerConfig.defaults()` (suppress replay logs) |
| `pollingStrategy` | Exponential backoff: 1s base, 2x rate, FULL jitter, 10s max |
| `checkpointDelay` | `Duration.ofSeconds(0)` (checkpoint as soon as possible) |
Expand All @@ -163,8 +164,15 @@ The SDK uses two separate thread pools with distinct responsibilities:
**User Executor (`DurableConfig.executorService`):**
- Runs the root handler, steps, child-context user code, and map/parallel coordination
- Configurable via `DurableConfig.builder().withExecutorService()`
- Caller-owned and shared across invocations; the SDK never shuts it down
- Default: cached daemon thread pool

**Invocation Executor Factory (`DurableConfig.invocationExecutorFactory`):**
- Optional alternative to the shared user executor
- Creates one executor per invocation for all root, step, child-context user code, and map/parallel coordination
- SDK-owned: the invocation caller waits for actual admitted work and End before requesting executor shutdown, including after cancellation. Non-cooperative work can delay that shutdown beyond the policy budget. Executor termination is observed with any cleanup time that remains.
- Must return a fresh executor and cannot be combined with `withExecutorService()`

**Internal Executor (`InternalExecutor.INSTANCE`):**
- Runs SDK coordination tasks: checkpoint batching, polling for wait completion
- Dedicated cached thread pool with daemon threads named `durable-sdk-internal-*`
Expand Down
33 changes: 16 additions & 17 deletions lmi-tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ eventual cleanup cannot turn an earlier lifecycle violation into a pass.
SDK's minimum JDK prevents fixture-only changes from introducing newer JDK APIs
into the shared Maven reactor. On manual dispatch, a separate
serialized cloud job builds the selected commit, deploys all five
fixture functions, runs all 13 cases, then collects evidence. A persistent
fixture functions, runs all 12 gating cases, then collects evidence. A persistent
CloudFormation stack owns all five
functions and log groups. The stack, functions and bucket remain after both
successful and failed test runs; later deployments update the same resources. Each uses 2 GiB / 1 vCPU and a single
Expand Down Expand Up @@ -74,17 +74,14 @@ eventual cleanup cannot turn an earlier lifecycle violation into a pass.
Each real checkpoint/poll call has a request-local call ID paired with its
`finally` exit. Missing or mismatched exits prevent cleanup from reusing the
fixture, and an exit after wrapper return remains a lifecycle failure.
* A bounded same-JVM root barrier establishes the fixed-pool reproduction.
`fixed2` uses a shared two-thread pool so two roots starve their first steps.
`nested2` uses four threads and a separate cohort-scoped child barrier. Both
child handlers must enter before either schedules nested map/parallel work.
The child barrier has an independent 8-second setup budget; the nested progress
budget starts when it releases, not when the roots passed their earlier barrier.
A broken child barrier fails setup without growing the pool; an escape after
successful child admission still fails the starvation assertion.
Other cases hold steps with private S3 control objects. The driver requires
distinct request IDs active in the same JVM. Placement has its own deadline
and failure category. Environment replacement is never worker recovery.
* A bounded same-JVM root barrier verifies that `fixed2` gives each invocation
its own two-thread executor. `nested2` is retained in the persistent stack for
baseline diagnostics but does not produce a gating LMI case. It uses four
shared threads and a separate cohort-scoped child barrier before scheduling
nested map/parallel work. Other cases hold steps with private S3 control
objects. The driver requires distinct request IDs active in the same JVM.
Placement has its own deadline and failure category. Environment replacement
is never worker recovery.
* Timeout victims start before healthy peers, leaving the peers time to hold
the other runtime slots during recovery. Diagnostics capture the real context
deadline; no fake clock, context, checkpoint backend, or time-skipping runner
Expand Down Expand Up @@ -179,8 +176,9 @@ python3 lmi-tests/cloud_suite.py collect
```

The deploy command creates or updates and retains `default1`, `default2`, `default8`,
`fixed2`, and `nested2` together. The test command requires all five and runs the
full suite. CI publishes one combined artifact, named `lmi-e2e-RUN-ATTEMPT`, with
`fixed2`, and `nested2` together. The test command requires all five and runs 12
gating cases; `nested2` is retained but excluded. CI publishes one combined
artifact, named `lmi-e2e-RUN-ATTEMPT`, with
the single `template.json` and per-function configuration snapshots. The
CloudFormation execution role needs permission to manage the function scaling
configuration; the driver calls `lambda:GetFunctionScalingConfig` to verify it
Expand Down Expand Up @@ -260,9 +258,10 @@ a normally ordered retry cannot replace an admitted participant.
After stopping durable executions, cleanup allows any pre-stop stubborn retry its
configured residual hold (capped at 120 seconds), plus the existing 30-second
collection margin and 20-second quiet period. This cleanup allowance does not alter
the original invocation deadline + 5s or progress/recovery acceptance limits. No virtual-thread executor variant is deployed
until its executor contract is defined; default cached and shared fixed pools
are covered separately.
the original invocation deadline + 5s or progress/recovery acceptance limits.
No virtual-thread executor variant is deployed until its executor contract is defined.
The cloud suite gates default cached and invocation-local fixed pools; the shared
fixed nesting fixture remains available as a non-gating baseline diagnostic.

CloudFormation's native [FunctionScalingConfig](https://docs.aws.amazon.com/AWSCloudFormation/latest/TemplateReference/aws-properties-lambda-function-functionscalingconfig.html)
sets the limits in the resource declaration. LMI provisioning and version behavior
Expand Down
14 changes: 7 additions & 7 deletions lmi-tests/cloud_suite.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
OWNER = "java-sdk-lmi-e2e"
DEFAULT_STACK = "java-lmi-e2e"
FIXTURES = {"default1": (1, "default"), "default2": (2, "default"),
"default8": (8, "default"), "fixed2": (2, "fixed"), "nested2": (2, "nested")}
"default8": (8, "default"), "fixed2": (2, "invocation-fixed"), "nested2": (2, "nested")}
PEER_LOG_OBSERVATION_SECONDS = 20
PEER_PLACEMENT_SECONDS = 60

Expand Down Expand Up @@ -201,7 +201,7 @@ def deploy_fixtures(manifest, tags, seconds=1800):
wait_stack(manifest["stack"], expected, remaining_budget(deadline, seconds))
stack = existing_stack(manifest["stack"])
outputs = {output["OutputKey"]: output["OutputValue"] for output in stack["Outputs"]}
require(set(outputs) == set(FIXTURES), "The stack must expose all five LMI fixtures")
require(set(outputs) == set(FIXTURES), "The stack must expose all required LMI fixtures")
for fixture in FIXTURES:
arn = outputs[fixture]
record_fixture(manifest, fixture, arn)
Expand Down Expand Up @@ -893,10 +893,10 @@ def collect_warm_snapshots(cloud, items, seconds=30):
def cases_for_fixture(cloud, fixture):
if fixture == "default1":
return [("baseline-concurrency1", lambda: replay_case(cloud, fixture, "baseline"))]
if fixture in {"fixed2", "nested2"}:
scenario = "fixed" if fixture == "fixed2" else "nested"
return [("fixed-two-roots" if fixture == "fixed2" else "fixed-nested-map-parallel",
lambda: fixed_case(cloud, fixture, scenario))]
if fixture == "fixed2":
return [("isolated-fixed-two-roots", lambda: fixed_case(cloud, fixture, "fixed"))]
if fixture == "nested2":
return []
cases = [(fixture + "-isolation", lambda: overlap_case(cloud, fixture)),
(fixture + "-suspend-cleanup-replay", lambda: replay_case(cloud, fixture, "suspend")),
(fixture + "-timeout-recovery", lambda: timeout_case(cloud, fixture))]
Expand Down Expand Up @@ -1022,7 +1022,7 @@ def revalidate_cases(cloud, records):
def run_tests():
manifest = json.loads(MANIFEST.read_text())
if set(manifest["functions"]) != set(FIXTURES):
raise PreconditionError("All five functions must be deployed before running the cloud suite")
raise PreconditionError("All required functions must be deployed before running the cloud suite")
cloud = Cloud(manifest, ARTIFACTS)
suite = ET.Element("testsuite", name="LMI cloud lifecycle")
records = []
Expand Down
4 changes: 2 additions & 2 deletions lmi-tests/cloud_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ def assert_fixed(events, markers, progress_start="BARRIER_PASSED"):
require(len({e["requestId"] for e in entered}) == len(markers), "Missing distinct runtime invocations")
require(max(e["sequence"] for e in entered) < min(e["sequence"] for e in passed),
"Root invocations did not overlap at the barrier")
require(not selected(events, "ESCAPE"), "Shared fixed executor starved its own queued work")
require(not selected(events, "ESCAPE"), "Fixed executor starved its own queued work")
# Child admission is one cohort-wide phase. A participant scheduled later must
# not get a fresh progress budget after its peer has already left the barrier.
cohort_start = min((e["nanos"] for e in selected(events, progress_start)), default=None)
Expand All @@ -222,7 +222,7 @@ def assert_fixed(events, markers, progress_start="BARRIER_PASSED"):
start = selected(events, progress_start, marker)
started = cohort_start if progress_start == "CHILD_BARRIER_PASSED" else (start[0]["nanos"] if start else None)
require(start and progress and 0 <= (progress[-1]["nanos"] - started) < 8_000_000_000,
"Shared executor did not progress within budget")
"Fixed executor did not progress within budget")


def assert_nested_reached(events, markers):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicReference;
import software.amazon.awssdk.services.lambda.model.OperationType;
import software.amazon.lambda.durable.DurableConfig;
import software.amazon.lambda.durable.DurableContext;
Expand Down Expand Up @@ -75,27 +76,42 @@ public void handleRequest(InputStream source, OutputStream destination, Context
var input = WIRE.deserialize(
new String(source.readAllBytes(), StandardCharsets.UTF_8), TypeToken.get(DurableExecutionInput.class));
var trace = new InvocationTrace(readUserInput(input), runtime, input.durableExecutionArn());
var invocationExecutor = new AtomicReference<ThreadPoolExecutor>();
trace.wrapperEnter();
var status = "THREW";
try {
var config = DurableConfig.builder()
.withExecutorService(shared.getExecutorService())
.withDurableExecutionClient(new ObservedClient(shared.getDurableExecutionClient(), trace))
.build();
var configBuilder = DurableConfig.builder()
.withDurableExecutionClient(new ObservedClient(shared.getDurableExecutionClient(), trace));
if ("invocation-fixed".equals(System.getenv("LMI_EXECUTOR"))) {
configBuilder.withInvocationExecutorFactory(() -> {
var executor = (ThreadPoolExecutor) Executors.newFixedThreadPool(2);
invocationExecutor.set(executor);
return executor;
});
} else {
configBuilder.withExecutorService(shared.getExecutorService());
}
var config = configBuilder.build();
var response = DurableExecutor.execute(
input,
runtime,
TypeToken.get(FixtureInput.class),
(value, context) -> handle(value, context, trace),
(value, context) -> handle(value, context, trace, executor(invocationExecutor)),
config);
destination.write(WIRE.serialize(response).getBytes(StandardCharsets.UTF_8));
status = response.status().name();
} finally {
trace.snapshot((ThreadPoolExecutor) shared.getExecutorService());
trace.snapshot(executor(invocationExecutor));
trace.wrapperExit(status);
}
}

private ThreadPoolExecutor executor(AtomicReference<ThreadPoolExecutor> invocationExecutor) {
return invocationExecutor.get() != null
? invocationExecutor.get()
: (ThreadPoolExecutor) shared.getExecutorService();
}

private static FixtureInput readUserInput(DurableExecutionInput input) {
var operation = input.initialExecutionState().operations().stream()
.filter(op -> op.type() == OperationType.EXECUTION)
Expand All @@ -104,7 +120,8 @@ private static FixtureInput readUserInput(DurableExecutionInput input) {
return JSON.deserialize(operation.executionDetails().inputPayload(), TypeToken.get(FixtureInput.class));
}

String handle(FixtureInput input, DurableContext context, InvocationTrace trace) {
String handle(
FixtureInput input, DurableContext context, InvocationTrace trace, ThreadPoolExecutor invocationExecutor) {
trace.rootEnter();
try {
if (!context.isReplaying()
Expand All @@ -113,7 +130,7 @@ String handle(FixtureInput input, DurableContext context, InvocationTrace trace)
trace.event("PLACEMENT_MISS");
return "PLACEMENT_MISS";
}
return runScenario(input, context, trace);
return runScenario(input, context, trace, invocationExecutor);
} finally {
if ("suspend".equals(input.scenario())) {
trace.event("CLEANUP_ENTER");
Expand All @@ -124,13 +141,14 @@ String handle(FixtureInput input, DurableContext context, InvocationTrace trace)
}
}

private String runScenario(FixtureInput input, DurableContext context, InvocationTrace trace) {
private String runScenario(
FixtureInput input, DurableContext context, InvocationTrace trace, ThreadPoolExecutor invocationExecutor) {
return switch (input.scenario()) {
case "baseline", "replay", "suspend" -> replay(input, context, trace);
case "hold", "probe" -> context.step("held-step", String.class, step -> hold(input, trace), NO_RETRY);
case "timeout", "failure-inflight", "return-inflight" -> inFlight(input, context, trace);
case "stubborn" -> stubborn(input, context, trace);
case "fixed", "nested" -> fixed(input, context, trace);
case "fixed", "nested" -> fixed(input, context, trace, invocationExecutor);
case "success" -> context.step("success", String.class, step -> body(trace, "success", input.marker()));
case "failure" -> context.step("failure", String.class, step -> fail(trace, input.marker()), NO_RETRY);
default -> throw new IllegalArgumentException("Unknown fixture scenario");
Expand Down Expand Up @@ -226,8 +244,7 @@ private String stubborn(FixtureInput input, DurableContext context, InvocationTr
return input.marker();
}

private String fixed(FixtureInput input, DurableContext context, InvocationTrace trace) {
var pool = (ThreadPoolExecutor) shared.getExecutorService();
private String fixed(FixtureInput input, DurableContext context, InvocationTrace trace, ThreadPoolExecutor pool) {
awaitRootBarrier(input, trace);
if ("nested".equals(input.scenario())) {
var value = context.step("success", String.class, step -> body(trace, "success", input.marker()));
Expand Down
Loading
Loading