Repository navigation
fix(sdk): throw when getting a skipped parallel branch #799
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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}. | ||
| * | ||
| * <p>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); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -17,6 +17,7 @@ | |
| import software.amazon.lambda.durable.config.ParallelBranchConfig; | ||
| import software.amazon.lambda.durable.config.ParallelConfig; | ||
| import software.amazon.lambda.durable.context.DurableContextImpl; | ||
| import software.amazon.lambda.durable.exception.ParallelBranchSkippedException; | ||
| import software.amazon.lambda.durable.execution.ExecutionManager; | ||
| import software.amazon.lambda.durable.model.OperationIdentifier; | ||
| import software.amazon.lambda.durable.model.OperationSubType; | ||
|
|
@@ -85,6 +86,7 @@ protected void handleCompletion(CompletionConfig.CompletionDecision completionDe | |
| statuses); | ||
| var serializedResult = serializeAndDeserializeResult(cachedResult); | ||
| cachedResult = serializedResult.deserialized(); | ||
| completeSkippedBranches(items, statuses); | ||
|
|
||
| // Branches added after checkpoint will not exist in the checkpointed result, but they'll be in the returned | ||
| // value from get() method. | ||
|
|
@@ -95,13 +97,23 @@ protected void handleCompletion(CompletionConfig.CompletionDecision completionDe | |
| .contextOptions(ContextOptions.builder().replayChildren(true).build())); | ||
| } | ||
|
|
||
| private void completeSkippedBranches(List<ChildContextOperation<?>> items, List<ParallelResult.Status> statuses) { | ||
| for (int i = 0; i < items.size(); i++) { | ||
| if (statuses.get(i) == ParallelResult.Status.SKIPPED) { | ||
| items.get(i).completeAsSkipped(); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private ParallelResult.Status getParallelItemStatus(ChildContextOperation<?> childContextOperation) { | ||
| if (!childContextOperation.isOperationCompleted()) { | ||
| if (!childContextOperation.isOperationCompleted() || childContextOperation.isSkipped()) { | ||
| return ParallelResult.Status.SKIPPED; | ||
| } | ||
| try { | ||
| childContextOperation.get(); | ||
| return ParallelResult.Status.SUCCEEDED; | ||
| } catch (ParallelBranchSkippedException e) { | ||
| return ParallelResult.Status.SKIPPED; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P2] Keep propagated nested exceptions classified as failures An outer branch can fail by calling A nested-parallel integration repro confirms that the outer checkpoint is |
||
| } catch (Throwable t) { | ||
| return ParallelResult.Status.FAILED; | ||
| } | ||
|
|
@@ -180,13 +192,22 @@ public <T> DurableFuture<T> branch( | |
|
|
||
| var nextBranchIndex = getBranches().size(); | ||
|
|
||
| // ConcurrencyOperation will skip this branch if skip=true: | ||
| // 1. if the parallel operation is already completed (partialResult is not null) | ||
| // 2. if the branch is already skipped in the partialResult or nonexistent in the partialResult | ||
| var skip = partialResult != null | ||
| && (partialResult.statuses().size() <= nextBranchIndex | ||
| || partialResult.statuses().get(nextBranchIndex) == ParallelResult.Status.SKIPPED); | ||
| // Skip when the checkpointed or in-memory parallel result already completed without this branch. | ||
| var skip = shouldSkipBranch(nextBranchIndex); | ||
| var serDes = config.serDes() == null ? getContext().getDurableConfig().getSerDes() : config.serDes(); | ||
| return enqueueItem(name, func, resultType, serDes, OperationSubType.PARALLEL_BRANCH, skip); | ||
| var branch = enqueueItem(name, func, resultType, serDes, OperationSubType.PARALLEL_BRANCH, skip); | ||
| if (skip) { | ||
| branch.completeAsSkipped(); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P1] Preserve Immediately completing skipped futures changes which branch wins A deterministic integration repro observes |
||
| } | ||
| return branch; | ||
| } | ||
|
|
||
| private boolean shouldSkipBranch(int nextBranchIndex) { | ||
| var result = partialResult != null ? partialResult : cachedResult; | ||
| if (result == null) { | ||
| return false; | ||
| } | ||
| return result.statuses().size() <= nextBranchIndex | ||
| || result.statuses().get(nextBranchIndex) == ParallelResult.Status.SKIPPED; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
| package software.amazon.lambda.durable.exception; | ||
|
|
||
| import static org.junit.jupiter.api.Assertions.assertEquals; | ||
| import static org.junit.jupiter.api.Assertions.assertNull; | ||
|
|
||
| import org.junit.jupiter.api.Test; | ||
|
|
||
| class ParallelBranchSkippedExceptionTest { | ||
|
|
||
| @Test | ||
| void namesTheSkippedBranchInTheMessage() { | ||
| var exception = new ParallelBranchSkippedException("branch-b"); | ||
| assertEquals("branch-b", exception.getBranchName()); | ||
| assertEquals("Parallel branch 'branch-b' was skipped", exception.getMessage()); | ||
| } | ||
|
|
||
| @Test | ||
| void formatsAFallbackMessageWhenTheBranchNameIsNull() { | ||
| var exception = new ParallelBranchSkippedException(null); | ||
| assertNull(exception.getBranchName()); | ||
| assertEquals("Parallel branch was skipped", exception.getMessage()); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[P2] Make the completion snapshot and skip transition atomic
A branch can finish after the status snapshot records it as
SKIPPEDbut beforecompleteSkippedBranches()runs, particularly during result serialization. Itsget()can then return a successful value beforecompleteAsSkipped()changes that same future to throw.A controlled integration repro gates
ParallelResultserialization while branch B finishes: the firstb.get()returns"B", but afterparallel.get(), the second throwsParallelBranchSkippedException. The parent commit returns"B"both times. Coordinate the snapshot and skip transition with branch completion so an already-observed outcome cannot change.