Repository navigation
[Bug]: Remaining LMI cleanup bounds and worker recovery #726
Description
Current Scope
This issue tracks the remaining LMI cleanup bounds and worker-recovery limitations. The deadline-admission, cooperative-cancellation, and cancellation-result correctness work implemented by #733 has moved to #838. PR #733 is still open; moving its scope does not claim that it has merged or that the remaining LMI acceptance criteria have passed.
The actual-task registry and root/handler-owned End-before-return ordering are already in main through merged #782, which incorporated #731 and the #728 harness. Those completed prerequisites are not outstanding work here. #733 preserves that ordering while adding deadline/cancellation policy.
Shared/nested bounded-pool progress and resource-capacity work has moved to #839. The invocation-owned executor mitigation implemented by open PR #735 is tracked separately in #840. Neither issue is claimed resolved by this scope split.
This issue remains open for:
- Hard cleanup/return bounds and LMI runtime-slot/worker recovery when admitted work does not cooperate with cancellation.
- Deployed LMI timeout, recovery, overlap, and replay acceptance for the resulting implementation.
Expected Behavior
The supported lifecycle must provide a defined cleanup budget and recovery behavior for concurrent invocations sharing an LMI execution environment. SDK-controlled waits and downstream I/O need finite bounds; the remaining bounded-return and worker-recovery acceptance must be demonstrated rather than inferred from a cancellation request.
Preserve the established requirement that a normal response, including PENDING, follows actual root/task cleanup and the matching End on its owner. Any solution to the remaining bound must reconcile that ordering with non-cooperative work. Arbitrary application code cannot be safely forcibly terminated by the SDK; document the supported cancellation boundary and recovery limits without relying on process freezing, LMI invocation timeout, or unsafe JVM thread termination.
Executor progress, supported nesting, and resource-capacity requirements are tracked in #839. Recovery changes in this issue must still preserve other invocations and caller-owned executors.
Remaining Problems
1. Cooperative deadline cancellation does not bound actual cleanup or reclaim non-cooperative workers.
#733 fences new SDK work and requests cancellation, but still waits for actual owner/task exit, admitted checkpoint/poll transport, and End before returning. Already-running application I/O, a custom client, a plugin hook, synchronous initial-state reads, or code that ignores interruption can therefore hold the SDK call beyond its deadline. An inline root executor, including saturated CallerRunsPolicy, can block the runtime caller before it reaches a timeout-aware wait.
LMI reports invocation timeout without forcibly terminating the function's code. A stuck invocation can retain a runtime slot or worker and continue already-started side effects. Deadline admission fencing does not cancel a side effect already accepted by a downstream service or prove recovery of the slot.
The remaining work is the safe, supported bound/recovery policy and its implementation and validation. Do not reimplement the deadline admission, cancellation broadcast, failure-preservation, or End-reporting safeguards now tracked in #838.
See the Java runtime guide and execution-environment lifecycle.
Executor-progress scope moved to #839.
The shared fixed-pool and arbitrary nested bounded-pool problem, reproduction, resource-capacity requirements, and progress acceptance now live in #839. PR #735 implements only the invocation-owned executor isolation tracked in #840; it does not resolve the broader progress problem or the non-cooperative cleanup bounds retained here. These anchors are preserved for existing links.
2. Deployed LMI recovery and overlap acceptance remains unverified.
Local mocked-backend tests and packaged-JAR controls do not establish real timeout recovery, worker-slot reclamation, or long-running resource behavior on LMI. #782 and #733 do not claim a fresh deployed acceptance run; #735 also states that deployed validation of its final tree remains pending.
Acceptance Criteria for This Remaining Scope
- Normal completion and PENDING cleanup finish within a defined supported budget while retaining actual root/task exit and owner End-before-return ordering.
- A simulated invocation deadline bounds the caller's wait for a stuck task or checkpoint request under the supported policy, with explicit handling and documented limits for non-cooperative code; no reliance on process freezing or runtime termination.
- Timeout/recovery tests establish whether runtime slots and workers become reusable; a cancellation request or eventual cleanup alone is not evidence of a bounded recovery.
- Recovery changes preserve invocation isolation, caller-owned executor lifetime, actual-owner/End ordering, replay operation identity, stored successes/failures, checkpoint ordering, and side-effect counts. Deadline/cancellation-specific regressions are tracked in [Bug]: Enforce invocation deadlines while awaiting owner cleanup #838; executor progress and owned-executor acceptance are tracked in [Bug]: Shared and nested bounded pools can starve queued child work #839 and [Feature]: Add invocation-owned executor isolation #840.
- Cloud LMI acceptance validates timeout handling, worker-slot recovery, overlapping invocations, replay/resume, and sustained resource behavior on the final implementation. Retain the test: add LMI cloud lifecycle and concurrency regressions #728 harness acceptance limits; do not substitute local tests or eventual cleanup for a failed lifecycle bound.
Historical Reproduction and Evidence
The original report reproduced three scenarios locally on SDK v2.2.0, commit 638ded39318fbe8004fd74bc1c24da315fc96d29, using Java 17 (Amazon Corretto 17.0.20.1, Linux) and a mocked Durable Execution backend. No earlier working release was established.
- Unfinished asynchronous work kept invocation cleanup blocked. The absence of deadline-aware admission/cancellation and interruption-safe cleanup is separated into [Bug]: Enforce invocation deadlines while awaiting owner cleanup #838; actual bounded cleanup and worker recovery remain here.
- PENDING could precede the root handler's finally completion. The actual-owner ordering and task-registry work is now incorporated through refactor(plugin)!: create one plugin instance per invocation #782/feat: add invocation-scoped task lifecycle foundation #731.
- Two concurrent roots starved their own steps in a shared two-thread pool. This historical reproduction now supports [Bug]: Shared and nested bounded pools can starve queued child work #839.
The original diagnostic class is retained below as historical evidence. It targets the 2.x API and asserts the old incorrect behavior; it is not a current 3.x acceptance test. In particular, its early-PENDING and early-End expectations are superseded by the owner ordering already merged in #782.
Complete local reproduction (JUnit, no OTel dependency)
Add this file as sdk/src/test/java/software/amazon/lambda/durable/execution/LmiInvocationLifecycleReproTest.java and run mvn -pl sdk -Dtest=LmiInvocationLifecycleReproTest test.
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package software.amazon.lambda.durable.execution;
import static org.junit.jupiter.api.Assertions.*;
import static software.amazon.lambda.durable.model.ExecutionStatus.*;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import org.junit.jupiter.api.Test;
import software.amazon.awssdk.services.lambda.model.CheckpointUpdatedExecutionState;
import software.amazon.awssdk.services.lambda.model.ExecutionDetails;
import software.amazon.awssdk.services.lambda.model.Operation;
import software.amazon.awssdk.services.lambda.model.OperationStatus;
import software.amazon.awssdk.services.lambda.model.OperationType;
import software.amazon.lambda.durable.DurableConfig;
import software.amazon.lambda.durable.TestUtils;
import software.amazon.lambda.durable.TypeToken;
import software.amazon.lambda.durable.model.DurableExecutionInput;
import software.amazon.lambda.durable.plugin.DurableExecutionPlugin;
import software.amazon.lambda.durable.plugin.InvocationEndInfo;
/** Diagnostic assertions describe the observed defects, not the desired contract. */
class LmiInvocationLifecycleReproTest {
private DurableExecutionInput input(String id) {
var execution = Operation.builder()
.id(id)
.type(OperationType.EXECUTION)
.status(OperationStatus.STARTED)
.startTimestamp(Instant.now())
.executionDetails(
ExecutionDetails.builder().inputPayload("\"input\"").build())
.build();
return new DurableExecutionInput(
"arn:aws:lambda:us-east-1:123456789012:function:test/durable-execution/test/" + id,
"token-" + id,
CheckpointUpdatedExecutionState.builder()
.operations(List.of(execution))
.build());
}
private static void await(CountDownLatch latch) {
try {
assertTrue(latch.await(5, TimeUnit.SECONDS), "test synchronization timed out");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new AssertionError(e);
}
}
@Test
void pendingResponseCanPrecedeRootHandlerFinallyCompletion() throws Exception {
var runtime = Executors.newSingleThreadExecutor();
var users = Executors.newCachedThreadPool();
var enteredFinally = new CountDownLatch(1);
var releaseFinally = new CountDownLatch(1);
var finishedFinally = new CountDownLatch(1);
var config = DurableConfig.builder()
.withDurableExecutionClient(TestUtils.createMockClient())
.withExecutorService(users)
.build();
try {
var response = runtime.submit(() -> DurableExecutor.execute(
input("pending"),
null,
TypeToken.get(String.class),
(value, context) -> {
try {
context.wait("pause", Duration.ofSeconds(30));
return "done";
} finally {
enteredFinally.countDown();
await(releaseFinally);
finishedFinally.countDown();
}
},
config));
await(enteredFinally);
assertEquals(PENDING, response.get(2, TimeUnit.SECONDS).status());
assertEquals(1, finishedFinally.getCount(), "root cleanup still running after PENDING response");
} finally {
releaseFinally.countDown();
users.shutdown();
assertTrue(users.awaitTermination(5, TimeUnit.SECONDS));
runtime.shutdownNow();
}
}
@Test
void rootHandlersExhaustSharedFixedPoolAndStarveTheirOwnSteps() throws Exception {
var runtime = Executors.newFixedThreadPool(2);
var users = (ThreadPoolExecutor) Executors.newFixedThreadPool(2);
var bothHandlers = new CountDownLatch(2);
var config = DurableConfig.builder()
.withDurableExecutionClient(TestUtils.createMockClient())
.withExecutorService(users)
.build();
try {
var responses = List.of("A", "B").stream()
.map(id -> runtime.submit(() -> DurableExecutor.execute(
input(id),
null,
TypeToken.get(String.class),
(value, context) -> {
bothHandlers.countDown();
await(bothHandlers);
return context.step("step", String.class, stepContext -> "done");
},
config)))
.toList();
await(bothHandlers);
for (var response : responses) {
assertThrows(TimeoutException.class, () -> response.get(250, TimeUnit.MILLISECONDS));
}
assertEquals(2, users.getActiveCount());
assertEquals(2, users.getQueue().size(), "both step bodies are queued behind blocked handlers");
users.setMaximumPoolSize(4);
users.setCorePoolSize(4);
for (var response : responses) {
assertEquals(SUCCEEDED, response.get(3, TimeUnit.SECONDS).status());
}
} finally {
users.shutdownNow();
runtime.shutdownNow();
}
}
@Test
void invocationEndRunsBeforeUnfinishedStepIsDrainedAndCloseBlocks() throws Exception {
var runtime = Executors.newSingleThreadExecutor();
var users = Executors.newCachedThreadPool();
var stepStarted = new CountDownLatch(1);
var releaseStep = new CountDownLatch(1);
var endCalled = new CountDownLatch(1);
var config = DurableConfig.builder()
.withDurableExecutionClient(TestUtils.createMockClient())
.withExecutorService(users)
.withPlugins(new DurableExecutionPlugin() {
@Override
public void onInvocationEnd(InvocationEndInfo info) {
endCalled.countDown();
}
})
.build();
try {
var response = runtime.submit(() -> DurableExecutor.execute(
input("unfinished"),
null,
TypeToken.get(String.class),
(value, context) -> {
context.stepAsync("slow-step", String.class, stepContext -> {
stepStarted.countDown();
await(releaseStep);
return "step-done";
});
await(stepStarted);
return "handler-done";
},
config));
await(endCalled);
assertThrows(TimeoutException.class, () -> response.get(250, TimeUnit.MILLISECONDS));
releaseStep.countDown();
assertEquals(SUCCEEDED, response.get(3, TimeUnit.SECONDS).status());
} finally {
releaseStep.countDown();
users.shutdown();
assertTrue(users.awaitTermination(5, TimeUnit.SECONDS));
runtime.shutdownNow();
}
}
}Related Work
- [Bug]: Enforce invocation deadlines while awaiting owner cleanup #838: focused deadline/cooperative-cancellation issue implemented by fix: enforce invocation deadlines while awaiting owner cleanup #733.
- [Bug]: Shared and nested bounded pools can starve queued child work #839: shared/nested bounded-pool progress and resource-capacity requirements extracted from this issue.
- [Feature]: Add invocation-owned executor isolation #840 / feat: add invocation-owned executor isolation #735: focused invocation-owned executor isolation; broader nested progress remains in [Bug]: Shared and nested bounded pools can starve queued child work #839, and non-cooperative cleanup bounds remain here.
- refactor(plugin)!: create one plugin instance per invocation #782 / feat: add invocation-scoped task lifecycle foundation #731: merged plugin ownership, actual-task registry, and owner/End ordering prerequisites.
- test: add LMI cloud lifecycle and concurrency regressions #728: LMI lifecycle test harness; cloud acceptance remains required.
- [Bug]: Single threaded executor causes dead-lock #12: earlier single-thread executor deadlock; this report's historical reproduction uses concurrent invocations and a shared two-thread pool.
- [Feature]: Add a validation to user thread pool #297: executor leak diagnostics must identify invocation-local tasks on LMI.
- [Docs]: Document finally cleanup behavior during durable suspension #645 and [Feature]: Add durable terminal-scope API for cleanup and compensation #646: durable terminal cleanup/compensation; this issue concerns invocation-local lifecycle and executor progress.
Activity
[-][Bug]: LMI invocation cleanup can hang or outlive the response; shared fixed pools can deadlock[/-][+][Bug]: Remaining LMI cleanup bounds, worker recovery, and shared-pool starvation[/+][-][Bug]: Remaining LMI cleanup bounds, worker recovery, and shared-pool starvation[/-][+][Bug]: Remaining LMI cleanup bounds and worker recovery[/+]