Skip to content

[Bug]: Remaining LMI cleanup bounds and worker recovery #726

Open

Description

@zhongkechen

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:

  1. Hard cleanup/return bounds and LMI runtime-slot/worker recovery when admitted work does not cooperate with cancellation.
  2. 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.

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

Activity

  1. changed the title [-][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[/+] on Oct 27, 2026
  2. changed the title [-][Bug]: Remaining LMI cleanup bounds, worker recovery, and shared-pool starvation[/-] [+][Bug]: Remaining LMI cleanup bounds and worker recovery[/+] on Oct 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions