From 482ab3edbceb02e383f9956a6f147181362b3906 Mon Sep 17 00:00:00 2001 From: hippi345 <13539685+hippi345@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:43:33 +0000 Subject: [PATCH 1/2] fix(sdk): throw when getting a skipped parallel branch get() on a parallel branch recorded as SKIPPED now throws ParallelBranchSkippedException instead of suspending with no pending operations. This covers branches that never started and in-flight branches skipped at completion, on the first invocation and on replay. --- docs/core/parallel.md | 26 ++++- .../durable/ParallelIntegrationTest.java | 106 +++++++++++++++++- .../ParallelBranchSkippedException.java | 32 ++++++ .../operation/BaseDurableOperation.java | 10 +- .../operation/ChildContextOperation.java | 19 ++++ .../durable/operation/ParallelOperation.java | 37 ++++-- .../ParallelBranchSkippedExceptionTest.java | 25 +++++ .../operation/ParallelOperationTest.java | 7 ++ 8 files changed, 246 insertions(+), 16 deletions(-) create mode 100644 sdk/src/main/java/software/amazon/lambda/durable/exception/ParallelBranchSkippedException.java create mode 100644 sdk/src/test/java/software/amazon/lambda/durable/exception/ParallelBranchSkippedExceptionTest.java diff --git a/docs/core/parallel.md b/docs/core/parallel.md index 99bdb26f86..d56723ffc8 100644 --- a/docs/core/parallel.md +++ b/docs/core/parallel.md @@ -39,8 +39,12 @@ try (var parallel = ctx.parallel("work")) { | `size()` | Total number of registered branches | | `succeeded()` | Number of branches that succeeded | | `failed()` | Number of branches that failed | +| `skipped()` | Number of branches recorded as `SKIPPED` | +| `statuses()` | Per-branch status: `SUCCEEDED`, `FAILED`, or `SKIPPED` | | `completionStatus()` | Why the operation completed (`ALL_COMPLETED`, `MIN_SUCCESSFUL_REACHED`, `FAILURE_TOLERANCE_EXCEEDED`) | +When a completion config finishes the parallel operation early, branches that never started — and branches that were still running at completion — are recorded as `SKIPPED`. `get()` on a skipped branch throws `ParallelBranchSkippedException` on the first invocation and on every replay. The SDK does not start or resume that branch later. + ### ParallelConfig Configure concurrency limits and completion criteria: @@ -110,9 +114,29 @@ try { } ``` +A skipped branch is different: the parallel result recorded it as `SKIPPED`, so `get()` throws `ParallelBranchSkippedException` instead of waiting: + +```java +var parallel = ctx.parallel("first-successful", ParallelConfig.builder() + .maxConcurrency(1) + .completionConfig(CompletionConfig.firstSuccessful()) + .build()); +var a = parallel.branch("branch-a", String.class, branchCtx -> "A"); +var b = parallel.branch("branch-b", String.class, branchCtx -> "B"); +parallel.get(); // statuses [SUCCEEDED, SKIPPED] +a.get(); // "A" +try { + b.get(); // throws on the first invocation and on every replay +} catch (ParallelBranchSkippedException e) { + // Branch never started, or was still running when the parallel completed. + // e.getBranchName() is the name passed to branch(). +} +``` + | Exception | When Thrown | |-----------|-------------| | `ParallelBranchFailedException` | Branch failed and the original exception could not be reconstructed | +| `ParallelBranchSkippedException` | Branch was recorded as `SKIPPED` in the parallel result | | User's exception | Branch threw a reconstructable exception — propagated through `get()` | ### Checkpoint-and-Replay @@ -121,4 +145,4 @@ Parallel operations are fully durable. On replay after interruption: - Completed branches return cached results without re-execution - Incomplete branches resume from their last checkpoint -- Branches that never started execute fresh +- Branches recorded as `SKIPPED` are not started; `get()` on those branch futures throws `ParallelBranchSkippedException` diff --git a/sdk-integration-tests/src/test/java/software/amazon/lambda/durable/ParallelIntegrationTest.java b/sdk-integration-tests/src/test/java/software/amazon/lambda/durable/ParallelIntegrationTest.java index 8ec7e20578..e83c261a95 100644 --- a/sdk-integration-tests/src/test/java/software/amazon/lambda/durable/ParallelIntegrationTest.java +++ b/sdk-integration-tests/src/test/java/software/amazon/lambda/durable/ParallelIntegrationTest.java @@ -7,7 +7,9 @@ import java.time.Duration; import java.util.ArrayList; import java.util.List; +import java.util.concurrent.CountDownLatch; import java.util.concurrent.ForkJoinPool; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.Test; @@ -19,7 +21,7 @@ import software.amazon.lambda.durable.config.NestingType; import software.amazon.lambda.durable.config.ParallelConfig; import software.amazon.lambda.durable.config.WaitForConditionConfig; -import software.amazon.lambda.durable.exception.IllegalDurableOperationException; +import software.amazon.lambda.durable.exception.ParallelBranchSkippedException; import software.amazon.lambda.durable.model.ConcurrencyCompletionStatus; import software.amazon.lambda.durable.model.ExecutionStatus; import software.amazon.lambda.durable.model.ParallelResult; @@ -1387,12 +1389,104 @@ void testInvalidSuspensionWhenWaitingOnSkippedBranch() { } parallel.get(); - return futures.get(1).get(); + var skipped = assertThrows( + ParallelBranchSkippedException.class, () -> futures.get(1).get()); + assertEquals("branch-2", skipped.getBranchName()); + return "done"; }); - var ex = assertThrows(IllegalDurableOperationException.class, () -> runner.run("test")); - assertTrue(ex.isRetryable()); - assertTrue(ex.getMessage().contains("Invalid suspension. No operation is pending.")); - assertTrue(ex.getMessage().contains("branch-2")); + var result = assertTimeoutPreemptively(Duration.ofSeconds(10), () -> runner.runUntilComplete("test")); + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + assertEquals("done", result.getResult(String.class)); + } + + @Test + void getOnSkippedBranchThatNeverStartedThrowsOnFirstRunAndReplay() { + var runner = LocalDurableTestRunner.create(String.class, (input, context) -> { + var config = ParallelConfig.builder() + .maxConcurrency(1) + .completionConfig(CompletionConfig.firstSuccessful()) + .build(); + var parallel = context.parallel("first-successful", config); + var a = parallel.branch("branch-a", String.class, ctx -> "A"); + var b = parallel.branch("branch-b", String.class, ctx -> "B"); + var result = parallel.get(); + assertEquals(ConcurrencyCompletionStatus.MIN_SUCCESSFUL_REACHED, result.completionStatus()); + assertEquals(List.of(ParallelResult.Status.SUCCEEDED, ParallelResult.Status.SKIPPED), result.statuses()); + assertEquals("A", a.get()); + var skipped = assertThrows(ParallelBranchSkippedException.class, b::get); + assertEquals("branch-b", skipped.getBranchName()); + assertEquals("Parallel branch 'branch-b' was skipped", skipped.getMessage()); + return "done"; + }); + + var first = assertTimeoutPreemptively(Duration.ofSeconds(10), () -> runner.runUntilComplete("input")); + assertEquals(ExecutionStatus.SUCCEEDED, first.getStatus()); + assertEquals("done", first.getResult(String.class)); + + var replay = assertTimeoutPreemptively(Duration.ofSeconds(10), () -> runner.run("input")); + assertEquals(ExecutionStatus.SUCCEEDED, replay.getStatus()); + assertEquals("done", replay.getResult(String.class)); + } + + @Test + void getOnSkippedInFlightBranchThrowsOnFirstRunAndReplay() throws Exception { + var branchBStarted = new CountDownLatch(1); + var releaseBranchB = new CountDownLatch(1); + var runner = LocalDurableTestRunner.create(String.class, (input, context) -> { + var config = ParallelConfig.builder() + .completionConfig(CompletionConfig.firstSuccessful()) + .build(); + var parallel = context.parallel("first-successful", config); + var b = parallel.branch("branch-b", String.class, ctx -> { + branchBStarted.countDown(); + try { + if (!releaseBranchB.await(10, TimeUnit.SECONDS)) { + throw new IllegalStateException("timed out waiting to release branch-b"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException(e); + } + return "B"; + }); + var a = parallel.branch("branch-a", String.class, ctx -> { + try { + if (!branchBStarted.await(10, TimeUnit.SECONDS)) { + throw new IllegalStateException("timed out waiting for branch-b to start"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException(e); + } + return "A"; + }); + var result = parallel.get(); + assertEquals(ConcurrencyCompletionStatus.MIN_SUCCESSFUL_REACHED, result.completionStatus()); + assertEquals(List.of(ParallelResult.Status.SKIPPED, ParallelResult.Status.SUCCEEDED), result.statuses()); + try { + var skipped = assertThrows(ParallelBranchSkippedException.class, b::get); + assertEquals("branch-b", skipped.getBranchName()); + assertEquals("A", a.get()); + context.wait("pause", Duration.ofSeconds(1)); + var skippedOnReplay = assertThrows(ParallelBranchSkippedException.class, b::get); + assertEquals("branch-b", skippedOnReplay.getBranchName()); + return "done"; + } finally { + releaseBranchB.countDown(); + } + }); + + try { + var first = assertTimeoutPreemptively(Duration.ofSeconds(15), () -> runner.run("input")); + assertEquals(ExecutionStatus.PENDING, first.getStatus()); + + runner.advanceTime(); + var replay = assertTimeoutPreemptively(Duration.ofSeconds(15), () -> runner.run("input")); + assertEquals(ExecutionStatus.SUCCEEDED, replay.getStatus()); + assertEquals("done", replay.getResult(String.class)); + } finally { + releaseBranchB.countDown(); + } } } diff --git a/sdk/src/main/java/software/amazon/lambda/durable/exception/ParallelBranchSkippedException.java b/sdk/src/main/java/software/amazon/lambda/durable/exception/ParallelBranchSkippedException.java new file mode 100644 index 0000000000..9bee4766d0 --- /dev/null +++ b/sdk/src/main/java/software/amazon/lambda/durable/exception/ParallelBranchSkippedException.java @@ -0,0 +1,32 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.exception; + +/** + * Thrown when {@code get()} is called on a parallel branch that the checkpointed parallel result recorded as + * {@code SKIPPED}. + * + *
This happens when a completion config such as {@code firstSuccessful()} finishes the parallel operation before
+ * every branch has started, or while a branch is still running. The SDK does not start or resume that branch later, so
+ * {@code get()} throws this exception on the first invocation and on every replay.
+ */
+public class ParallelBranchSkippedException extends DurableExecutionException {
+ private final String branchName;
+
+ public ParallelBranchSkippedException(String branchName) {
+ super(formatMessage(branchName));
+ this.branchName = branchName;
+ }
+
+ /** Returns the name of the skipped branch. */
+ public String getBranchName() {
+ return branchName;
+ }
+
+ private static String formatMessage(String branchName) {
+ if (branchName == null) {
+ return "Parallel branch was skipped";
+ }
+ return String.format("Parallel branch '%s' was skipped", branchName);
+ }
+}
diff --git a/sdk/src/main/java/software/amazon/lambda/durable/operation/BaseDurableOperation.java b/sdk/src/main/java/software/amazon/lambda/durable/operation/BaseDurableOperation.java
index 31c9926156..efe7da8151 100644
--- a/sdk/src/main/java/software/amazon/lambda/durable/operation/BaseDurableOperation.java
+++ b/sdk/src/main/java/software/amazon/lambda/durable/operation/BaseDurableOperation.java
@@ -243,8 +243,16 @@ private static ExecutionManager getExecutionManager(List extends BaseDurableOp
*
* @return the completed operation
*/
- protected Operation waitForOperationCompletion() {
+ /**
+ * Waits for this operation's completion future. Unlike {@link #waitForOperationCompletion()}, this does not look up
+ * a checkpointed operation, so skipped branches that were never started can settle without an {@code Operation}.
+ */
+ protected void waitForCompletionFuture() {
executionManager.waitForOperationCompletion(this);
+ }
+
+ protected Operation waitForOperationCompletion() {
+ waitForCompletionFuture();
if (isVirtual) {
// We don't store virtual operations so they don't corresponding Operation in storage
diff --git a/sdk/src/main/java/software/amazon/lambda/durable/operation/ChildContextOperation.java b/sdk/src/main/java/software/amazon/lambda/durable/operation/ChildContextOperation.java
index 8c299cfa4a..d2772bddc8 100644
--- a/sdk/src/main/java/software/amazon/lambda/durable/operation/ChildContextOperation.java
+++ b/sdk/src/main/java/software/amazon/lambda/durable/operation/ChildContextOperation.java
@@ -27,6 +27,7 @@
import software.amazon.lambda.durable.exception.DurableOperationException;
import software.amazon.lambda.durable.exception.MapIterationFailedException;
import software.amazon.lambda.durable.exception.ParallelBranchFailedException;
+import software.amazon.lambda.durable.exception.ParallelBranchSkippedException;
import software.amazon.lambda.durable.exception.StepFailedException;
import software.amazon.lambda.durable.exception.StepInterruptedException;
import software.amazon.lambda.durable.exception.UnrecoverableDurableExecutionException;
@@ -53,6 +54,7 @@ public class ChildContextOperation Skipped parallel branches that never started can settle this way because they have no checkpointed operation.
*/
protected void waitForCompletionFuture() {
executionManager.waitForOperationCompletion(this);
}
+ /**
+ * Waits for the operation to complete. Deregisters the current thread to allow Lambda suspension if the operation
+ * is still in progress, then re-registers when the operation completes.
+ *
+ * @return the completed operation
+ */
protected Operation waitForOperationCompletion() {
waitForCompletionFuture();